Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 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
fb46f14
feat(web): in-place cursor variant drill-down (closes #48)
heavygee Jun 18, 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
87 changes: 87 additions & 0 deletions cli/src/agent/runnerLifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
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'];
}

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');
});
});
});
6 changes: 6 additions & 0 deletions 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 @@ -25,6 +26,7 @@ export function createRunnerLifecycle(options: RunnerLifecycleOptions): RunnerLi
let exitCode = 0
let archiveReason = 'User terminated'
let sessionEndReason: SessionEndReason = 'terminated'
let sessionEndReasonExplicit = false
let cleanupStarted = false
let cleanupPromise: Promise<void> | null = null

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

const setSessionEndReason = (reason: SessionEndReason) => {
sessionEndReason = reason
sessionEndReasonExplicit = true
}

const hasExplicitSessionEndReason = () => sessionEndReasonExplicit

const markCrash = (error: unknown) => {
logger.debug(`${logPrefix} Unhandled error:`, error)
exitCode = 1
Expand Down Expand Up @@ -128,6 +133,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
8 changes: 8 additions & 0 deletions cli/src/api/apiSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,14 @@ export class ApiSessionClient extends EventEmitter {
})
}

/** Hub waits for this before mergeSessions on Cursor ACP reopen (tiann/hapi#939). */
emitSessionReady(): void {
this.socket.emit('session-ready', {
sid: this.sessionId,
time: Date.now()
})
}

emitMessagesConsumed(localIds: string[], options?: { clearQueuedThinkingGrace?: boolean }): void {
if (localIds.length === 0) return
// `clearQueuedThinkingGrace` is an opt-in signal for the hub to drop
Expand Down
8 changes: 8 additions & 0 deletions cli/src/claude/claudeRemoteLauncher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,14 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase {
return permissionHandler.isAborted(toolCallId);
},
nextMessage: async () => {
// Flush any pending outgoing messages before consuming the next user
// turn. Without this, scheduleProcessing()'s setTimeout(fn,0) fires
// after the microtask that sends messages-consumed, causing the hub
// to stamp invokedAt on the next user message before it stores the
// current turn's queued agent messages — making them sort permanently
// below the next user message.
await messageQueue.flush();

if (pending) {
let p = pending;
pending = null;
Expand Down
Loading
Loading