Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
e23ae1b
feat: add Pi Coding Agent support (#862)
zhushanwen321 Jun 18, 2026
0bf17b8
fix(web): use button CSS variables for Retry button on error screen (…
heavygee Jun 18, 2026
26d3c2e
fix(hub+cli): defer mergeSessions on cursor ACP reopen until session/…
heavygee Jun 18, 2026
b3add07
fix: detect stale PID in runner state after abnormal shutdown (#931)
KorenKrita Jun 18, 2026
4bc3393
fix(cli): stateful MCP HTTP transport for display_image (#944)
heavygee Jun 18, 2026
fc8c32e
fix: reliable generated-image display + stop client remount storm (#9…
swear01 Jun 18, 2026
a858256
fix(web,hub): surface inactive-session error on text-only send (close…
heavygee Jun 18, 2026
4e4043d
fix(claude): flush OutgoingMessageQueue before consuming next user tu…
swear01 Jun 18, 2026
3dfbd61
fix(runner): surface dangling-symlink errors instead of misleading EE…
heavygee Jun 18, 2026
a2862a3
docs(installation): add KillMode=process to runner systemd unit (clos…
heavygee Jun 18, 2026
78155a9
perf(web): add staleTime to useSession to suppress focus/mount refetc…
heavygee Jun 18, 2026
5f27abd
feat(web): in-app PWA update prompt when new service worker is availa…
heavygee Jun 18, 2026
ce67823
feat(web,hub): rich hover tooltips on session-list attention indicato…
heavygee Jun 18, 2026
dfb1805
feat(web): OLED Black theme + per-appearance custom colors (#937)
swear01 Jun 18, 2026
2643f17
feat(web): Web Share Target -> Android system share sheet integration…
heavygee Jun 18, 2026
a8e08e8
feat(web): add download button to file viewer (#926)
swear01 Jun 18, 2026
22bf7e0
fix(web): persist file explorer expanded tree and scroll position acr…
swear01 Jun 18, 2026
f5c0ef2
fix: active-only session filter + paginated "Show N more" (closes #90…
swear01 Jun 18, 2026
8f3ea10
fix(web): mechanical repair of GFM tables with off-by-one separator r…
heavygee Jun 18, 2026
d1a686f
fix(cursor): map base-only CLI sku to fast=false to avoid silent vari…
swear01 Jun 18, 2026
bfd0f4a
fix(web): keep Codex restart control clear of close button (#880)
DolphinZZZZZ Jun 18, 2026
02a0aa6
fix(cursor): surface agent errors with warning styling in web UI (#871)
swear01 Jun 18, 2026
b1910b6
feat(web): session header files and outline view toggles (#952)
heavygee Jun 19, 2026
a0259b5
feat(web): drag-and-drop files onto chat panel to add as attachments …
swear01 Jun 19, 2026
2ab3b39
fix(hub,cli): four hub-restart-cascade cleanup bugs (#913 #914 #916 #…
heavygee Jun 19, 2026
53d8631
feat(web): markdown Source | Preview toggle in session file pane
heavygee Jun 19, 2026
271492d
fix(web): cast standalone MarkdownRenderer components for react-markdown
heavygee Jun 19, 2026
2daee4f
fix(web): route file-pane markdown fences through SyntaxHighlighter
heavygee Jun 20, 2026
cb03055
fix(web): detect fenced vs inline code in standalone markdown preview
heavygee Jun 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,6 @@ cli/npm/main/
test-results/
playwright-report/
e2e-output/
.xyz-harness
.agents/
.pi/
2 changes: 2 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,4 @@
"@types/parse-path": "7.0.3"
},
"packageManager": "bun@1.3.14"
}
}
1 change: 1 addition & 0 deletions cli/src/agent/localHandoff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ describe('registerLocalHandoffHandler', () => {
const lifecycle = {
setArchiveReason: vi.fn(),
setSessionEndReason: vi.fn(),
hasExplicitSessionEndReason: vi.fn(() => false),
cleanupAndExit: vi.fn(async () => {})
}

Expand Down
12 changes: 12 additions & 0 deletions cli/src/agent/messageConverter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,18 @@ describe('convertAgentMessage', () => {
});
});

it('converts agent errors into error wire payloads', () => {
const converted = convertAgentMessage({
type: 'error',
message: 'Cursor Agent failed: authentication required'
});

expect(converted).toEqual({
type: 'error',
message: 'Cursor Agent failed: authentication required'
});
});

it('converts usage messages into token_count payloads', () => {
const converted = convertAgentMessage({
type: 'usage',
Expand Down
196 changes: 196 additions & 0 deletions cli/src/agent/runnerLifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createRunnerLifecycle } from './runnerLifecycle';
import type { RunnerLifecycle } from './runnerLifecycle';

// Mock heavy deps
vi.mock('@/ui/logger', () => ({
logger: {
debug: vi.fn(),
getLogPath: vi.fn(() => '/tmp/test.log'),
},
}));

vi.mock('@/ui/terminalState', () => ({
restoreTerminalState: vi.fn(),
}));

function createMockApiSession() {
return {
updateMetadata: vi.fn(),
sendSessionDeath: vi.fn(),
flush: vi.fn(),
close: vi.fn(),
} as unknown as Parameters<typeof createRunnerLifecycle>[0]['session'];
}

function createMockApiSessionWithMetadataCapture() {
const metadataWrites: Array<Record<string, unknown>> = []
return {
updateMetadata: vi.fn((handler: (m: Record<string, unknown>) => Record<string, unknown>) => {
const next = handler({})
metadataWrites.push(next)
return next
}),
sendSessionDeath: vi.fn(),
flush: vi.fn(async () => {}),
close: vi.fn(async () => {}),
metadataWrites
} as unknown as Parameters<typeof createRunnerLifecycle>[0]['session'] & {
metadataWrites: Array<Record<string, unknown>>
}
}

describe('createRunnerLifecycle', () => {
let lifecycle: RunnerLifecycle;

beforeEach(() => {
vi.clearAllMocks();
lifecycle = createRunnerLifecycle({
session: createMockApiSession(),
logTag: 'test',
});
});

// --- D-9: hasExplicitSessionEndReason ---

describe('hasExplicitSessionEndReason', () => {
it('returns false initially', () => {
expect(lifecycle.hasExplicitSessionEndReason()).toBe(false);
});

it('returns true after setSessionEndReason is called', () => {
lifecycle.setSessionEndReason('completed');
expect(lifecycle.hasExplicitSessionEndReason()).toBe(true);
});

it('returns false after markCrash — markCrash does NOT set explicit flag', () => {
lifecycle.markCrash(new Error('boom'));
expect(lifecycle.hasExplicitSessionEndReason()).toBe(false);
});

it('stays true once set — subsequent markCrash does not clear it', () => {
lifecycle.setSessionEndReason('handoff');
lifecycle.markCrash(new Error('late crash'));
expect(lifecycle.hasExplicitSessionEndReason()).toBe(true);
});
});

// --- markCrash sets reason to 'error' but not explicit ---

describe('markCrash', () => {
it('sets sessionEndReason to error via sendSessionDeath during cleanup', async () => {
const session = createMockApiSession();
const lc = createRunnerLifecycle({ session, logTag: 'test' });
lc.markCrash(new Error('fatal'));

// cleanup triggers sendSessionDeath — verify 'error' reason
await lc.cleanup();
expect(session.sendSessionDeath).toHaveBeenCalledWith('error');
});
});

// --- setSessionEndReason + cleanup propagates correct reason ---

describe('setSessionEndReason + cleanup', () => {
it('sends explicit reason via sendSessionDeath during cleanup', async () => {
const session = createMockApiSession();
const lc = createRunnerLifecycle({ session, logTag: 'test' });
lc.setSessionEndReason('completed');

await lc.cleanup();
expect(session.sendSessionDeath).toHaveBeenCalledWith('completed');
});
});
});

// tiann/hapi#914: the runnerLifecycle's default archiveReason is now
// 'Hub restart' (was 'User terminated'). Out-of-band SIGTERM from the
// hub-restart cascade keeps that default. Explicit user actions
// (clicking Archive in the web UI, Ctrl-C in a local terminal,
// uncaught exception) reassign the reason before archive metadata is
// written.
describe('createRunnerLifecycle archiveReason defaults (tiann/hapi#914)', () => {
it('uses Hub restart as the default archiveReason when no override is applied', async () => {
const session = createMockApiSessionWithMetadataCapture()
const lifecycle = createRunnerLifecycle({
session,
logTag: 'test'
})

await lifecycle.cleanup()

expect(session.metadataWrites).toHaveLength(1)
expect(session.metadataWrites[0]).toMatchObject({
lifecycleState: 'archived',
archivedBy: 'cli',
archiveReason: 'Hub restart'
})
})

it('writes the operator-supplied reason when setArchiveReason is called (e.g. KillSession RPC)', async () => {
const session = createMockApiSessionWithMetadataCapture()
const lifecycle = createRunnerLifecycle({
session,
logTag: 'test'
})

lifecycle.setArchiveReason('User terminated')
await lifecycle.cleanup()

expect(session.metadataWrites[0]).toMatchObject({
archiveReason: 'User terminated'
})
})

it('markCrash overrides the default reason to "Session crashed"', async () => {
const session = createMockApiSessionWithMetadataCapture()
const lifecycle = createRunnerLifecycle({
session,
logTag: 'test'
})

lifecycle.markCrash(new Error('boom'))
await lifecycle.cleanup()

expect(session.metadataWrites[0]).toMatchObject({
archiveReason: 'Session crashed'
})
})

// tiann/hapi#914 review round 4: clean agent-loop completions
// (runClaude / runCodex / runCursor / runGemini / runKimi /
// runOpencode all call setSessionEndReason('completed') without
// touching archiveReason) must not be archived as 'Hub restart'.
// The setSessionEndReason setter flips the default when the runner
// transitions to 'completed'.
it('setSessionEndReason("completed") flips the default reason to "Session completed"', async () => {
const session = createMockApiSessionWithMetadataCapture()
const lifecycle = createRunnerLifecycle({
session,
logTag: 'test'
})

lifecycle.setSessionEndReason('completed')
await lifecycle.cleanup()

expect(session.metadataWrites[0]).toMatchObject({
archiveReason: 'Session completed'
})
})

it('an explicit setArchiveReason before setSessionEndReason("completed") still wins', async () => {
const session = createMockApiSessionWithMetadataCapture()
const lifecycle = createRunnerLifecycle({
session,
logTag: 'test'
})

lifecycle.setArchiveReason('User terminated')
lifecycle.setSessionEndReason('completed')
await lifecycle.cleanup()

expect(session.metadataWrites[0]).toMatchObject({
archiveReason: 'User terminated'
})
})
})
48 changes: 47 additions & 1 deletion cli/src/agent/runnerLifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export type RunnerLifecycle = {
setExitCode: (code: number) => void
setArchiveReason: (reason: string) => void
setSessionEndReason: (reason: SessionEndReason) => void
hasExplicitSessionEndReason: () => boolean
markCrash: (error: unknown) => void
cleanup: () => Promise<void>
cleanupAndExit: (codeOverride?: number) => Promise<void>
Expand All @@ -23,8 +24,29 @@ export type RunnerLifecycle = {

export function createRunnerLifecycle(options: RunnerLifecycleOptions): RunnerLifecycle {
let exitCode = 0
let archiveReason = 'User terminated'
// tiann/hapi#914: default reason is 'Hub restart' (parent-driven SIGTERM
// is the most common non-user cause). Genuine user actions (clicking
// Archive in the web UI, or Ctrl-C in a local terminal) explicitly
// reassign this via `setArchiveReason` BEFORE `cleanupAndExit` runs:
// - KillSession RPC handler → 'User terminated' (see registerKillSessionHandler)
// - SIGINT handler → 'User terminated' (Ctrl-C in local terminal)
// - uncaughtException/Reject → 'Session crashed' (via markCrash)
//
// Out-of-band SIGTERM (hub-restart cascade, systemd cgroup kill on
// hapi-runner.service stop, `kill <pid>` from the operator) keeps the
// default and is correctly labelled 'Hub restart' on the audit trail.
//
// Runner-internal stop paths (`hapi runner stop-session`, webhook-timeout
// cleanup at run.ts:587, orphan cleanup at run.ts:267) also currently
// hit this default - that is technically inaccurate but follows the
// friction-mode "smallest defensible change" rule for this PR. Finer
// attribution would require an IPC channel (stdio: 'ipc' on spawn) so
// the runner can stamp `setArchiveReason` before SIGTERMing; tracked as
// a follow-up to keep this PR focussed on the user-action lie that
// motivated #914.
let archiveReason = 'Hub restart'
let sessionEndReason: SessionEndReason = 'terminated'
let sessionEndReasonExplicit = false
let cleanupStarted = false
let cleanupPromise: Promise<void> | null = null

Expand Down Expand Up @@ -95,8 +117,23 @@ export function createRunnerLifecycle(options: RunnerLifecycleOptions): RunnerLi

const setSessionEndReason = (reason: SessionEndReason) => {
sessionEndReason = reason
sessionEndReasonExplicit = true
// tiann/hapi#914 review round 4: every agent runner
// (runClaude / runCodex / runCursor / runGemini / runKimi /
// runOpencode) calls setSessionEndReason('completed') before
// cleanupAndExit() on the natural-exit path without setting an
// archive reason. With the SIGTERM-driven default of 'Hub restart',
// clean completions would otherwise be audit-trailed as restart
// cascades. Flip the default to 'Session completed' when the end
// reason transitions to 'completed' AND no caller has already
// overridden the archive reason.
if (reason === 'completed' && archiveReason === 'Hub restart') {
archiveReason = 'Session completed'
}
}

const hasExplicitSessionEndReason = () => sessionEndReasonExplicit

const markCrash = (error: unknown) => {
logger.debug(`${logPrefix} Unhandled error:`, error)
exitCode = 1
Expand All @@ -105,11 +142,19 @@ export function createRunnerLifecycle(options: RunnerLifecycleOptions): RunnerLi
}

const registerProcessHandlers = () => {
// tiann/hapi#914: SIGTERM is treated as the default reason ('Hub restart')
// because the runner is restarted by systemd as part of hub restart in
// production. If a future code path needs to distinguish "operator
// killed the host process" from "hub restart", it can call
// setArchiveReason() before the runner exits.
process.on('SIGTERM', () => {
void cleanupAndExit()
})

// Ctrl-C in a local terminal is genuine user intent — keep the
// pre-#914 label so the audit trail still shows it.
process.on('SIGINT', () => {
archiveReason = 'User terminated'
void cleanupAndExit()
})

Expand All @@ -128,6 +173,7 @@ export function createRunnerLifecycle(options: RunnerLifecycleOptions): RunnerLi
setExitCode,
setArchiveReason,
setSessionEndReason,
hasExplicitSessionEndReason,
markCrash,
cleanup,
cleanupAndExit,
Expand Down
12 changes: 12 additions & 0 deletions cli/src/agent/sessionConfigRpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,22 @@ export function resolveSessionConfigPermissionMode<TPermissionMode extends Permi
return parsed.data as TPermissionMode
}

/** Extract `modelId` from either a plain string or a `{ provider, modelId }`
* object (the form Pi sessions receive from the hub). Other agents only pass
* plain strings; the object branch is here for schema consistency so this
* function doesn't throw if the hub later sends the union form to any agent. */
export function resolveNullableSessionModel(value: unknown): string | null {
if (value === null) {
return null
}
// Pi sessions receive model as { provider, modelId }; extract modelId
if (typeof value === 'object' && value !== null) {
const modelObj = value as { modelId?: unknown }
if (typeof modelObj.modelId === 'string' && modelObj.modelId.trim().length > 0) {
return modelObj.modelId.trim()
}
throw new Error('Invalid model')
}
if (typeof value !== 'string' || value.trim().length === 0) {
throw new Error('Invalid model')
}
Expand Down
6 changes: 6 additions & 0 deletions cli/src/agent/sessionFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,15 @@ function pickExistingSessionMetadata(metadata: Metadata | null | undefined): Par
if (metadata.cursorSessionId !== undefined) preserved.cursorSessionId = metadata.cursorSessionId
if (metadata.cursorSessionProtocol !== undefined) preserved.cursorSessionProtocol = metadata.cursorSessionProtocol
if (metadata.kimiSessionId !== undefined) preserved.kimiSessionId = metadata.kimiSessionId
if (metadata.piSessionId !== undefined) preserved.piSessionId = metadata.piSessionId
if (metadata.tools !== undefined) preserved.tools = metadata.tools
if (metadata.slashCommands !== undefined) preserved.slashCommands = metadata.slashCommands
if (metadata.worktree !== undefined) preserved.worktree = metadata.worktree
// Preserve cached Pi model list so the web can show models immediately
// on inactive-session view without waiting for an RPC round-trip.
if (metadata.piAvailableModels !== undefined) preserved.piAvailableModels = metadata.piAvailableModels
// Preserve provider-qualified Pi model selection (disambiguates duplicate modelIds).
if (metadata.piSelectedModel !== undefined) preserved.piSelectedModel = metadata.piSelectedModel

return preserved
}
Expand Down
6 changes: 4 additions & 2 deletions cli/src/api/apiMachine.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync, mkdirSync } from 'node:fs'
import { mkdtempSync, rmSync, mkdirSync, realpathSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'

Expand Down Expand Up @@ -135,7 +135,9 @@ describe('ApiMachineClient listOpencodeModelsForCwd handler', () => {
availableModels: [{ modelId: 'x/y' }],
currentModelId: 'x/y'
})
expect(listOpencodeModelsForCwdMock).toHaveBeenCalledWith(secondWorkspaceRoot)
// The handler realpaths the cwd (security: prevents symlink escape),
// so on macOS /var/folders/... resolves to /private/var/folders/...
expect(listOpencodeModelsForCwdMock).toHaveBeenCalledWith(realpathSync(secondWorkspaceRoot))
} finally {
rmSync(secondWorkspaceRoot, { recursive: true, force: true })
client.shutdown()
Expand Down
Loading
Loading