diff --git a/bun.lock b/bun.lock index 8400dfe307..7a40e43908 100644 --- a/bun.lock +++ b/bun.lock @@ -1074,6 +1074,8 @@ "@twsxtd/hapi-linux-x64": ["@twsxtd/hapi-linux-x64@0.20.2", "", { "os": "linux", "cpu": "x64", "bin": { "hapi": "bin/hapi" } }, "sha512-AWFK3ERb6oY0tOzGaNrKEOqSFWBb/HjJ90Q8TOOLZIlckSVFSa5l5ortDOpiTlLf5fTIgfx3hRlR56eOrVfP4Q=="], + "@twsxtd/hapi-win32-x64": ["@twsxtd/hapi-win32-x64@0.20.2", "", { "os": "win32", "cpu": "x64", "bin": { "hapi": "bin/hapi.exe" } }, "sha512-o4O/q+vvVrOt4kLy2uBcR/ubQChQeDvq1TtybGkyPq9u1Y4LZkBbM36++TBzAXXaCNn86hQDOUjZs9seXoi18A=="], + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], diff --git a/cli/src/claude/claudeRemote.fork.test.ts b/cli/src/claude/claudeRemote.fork.test.ts new file mode 100644 index 0000000000..f4c2dea01e --- /dev/null +++ b/cli/src/claude/claudeRemote.fork.test.ts @@ -0,0 +1,223 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as claudeSdk from '@/claude/sdk'; +import type { SDKMessage } from '@/claude/sdk/types'; +import type { Metadata } from '@/api/types'; + +vi.mock('@/claude/utils/claudeCheckSession', () => ({ + claudeCheckSession: () => true +})); + +vi.mock('@/modules/watcher/awaitFileExist', () => ({ + awaitFileExist: async () => true +})); + +vi.mock('@/claude/sdk/utils', () => ({ + getDefaultClaudeCodePath: () => '/usr/bin/claude' +})); + +const { getLiveAgentKindMock } = vi.hoisted(() => ({ + getLiveAgentKindMock: vi.fn<(sessionId: string) => 'background' | 'interactive' | null>() +})); + +vi.mock('@/claude/utils/getLiveAgentKind', () => ({ + getLiveAgentKind: getLiveAgentKindMock +})); + +const queryMock = vi.fn(); + +function createAsyncStream(messages: SDKMessage[]): AsyncIterable { + return { + async *[Symbol.asyncIterator]() { + for (const message of messages) { + await Promise.resolve(); + yield message; + } + } + }; +} + +const RESUME_ID = '6f0c4551-1111-4222-8333-444455556666'; +const FORKED_ID = 'aaaa1111-2222-4333-8444-555566667777'; + +function initThenResult(sessionId: string): SDKMessage[] { + return [ + { + type: 'system', + subtype: 'init', + session_id: sessionId + } as unknown as SDKMessage, + { + type: 'result', + subtype: 'success', + num_turns: 1, + total_cost_usd: 0, + duration_ms: 1, + duration_api_ms: 1, + is_error: false, + session_id: sessionId + } as unknown as SDKMessage + ]; +} + +const ENV_KEYS_UNDER_TEST = ['CLAUDE_CONFIG_DIR', 'DISABLE_AUTOUPDATER'] as const; +let savedEnv: Record; + +beforeEach(() => { + vi.clearAllMocks(); + savedEnv = {}; + for (const key of ENV_KEYS_UNDER_TEST) { + savedEnv[key] = process.env[key]; + } +}); + +afterEach(() => { + // Restore any process.env keys we touched so cases don't leak into each other. + for (const key of ENV_KEYS_UNDER_TEST) { + const original = savedEnv[key]; + if (original === undefined) { + delete process.env[key]; + } else { + process.env[key] = original; + } + } +}); + +describe('claudeRemote fork-on-live-session decision', () => { + it('forks (forkSession=true) and records forkedFrom when the session is live', async () => { + const querySpy = vi.spyOn(claudeSdk, 'query').mockImplementation(queryMock as typeof claudeSdk.query); + getLiveAgentKindMock.mockReturnValue('background'); + queryMock.mockReturnValueOnce(createAsyncStream(initThenResult(FORKED_ID))); + + const { claudeRemote } = await import('./claudeRemote'); + const foundCalls: Array<{ id: string; extras?: Partial }> = []; + + let nextCallCount = 0; + await claudeRemote({ + sessionId: RESUME_ID, + path: process.cwd(), + mcpServers: {}, + claudeEnvVars: {}, + claudeArgs: [], + allowedTools: [], + hookSettingsPath: '/tmp/hook.json', + canCallTool: async () => ({ behavior: 'allow', updatedInput: {} }), + nextMessage: async () => { + nextCallCount += 1; + if (nextCallCount === 1) { + return { message: 'ping', mode: { permissionMode: 'default' } }; + } + return null; + }, + onReady: () => {}, + isAborted: () => false, + onSessionFound: (id, extras) => { + foundCalls.push({ id, extras }); + }, + onMessage: () => {} + }); + + try { + expect(getLiveAgentKindMock).toHaveBeenCalledWith(RESUME_ID); + const passedOptions = queryMock.mock.calls[0][0].options; + expect(passedOptions.resume).toBe(RESUME_ID); + expect(passedOptions.forkSession).toBe(true); + expect(foundCalls).toEqual([{ id: FORKED_ID, extras: { forkedFrom: RESUME_ID } }]); + } finally { + queryMock.mockReset(); + querySpy.mockRestore(); + } + }, 15_000); + + it('applies claudeEnvVars to process.env BEFORE probing liveness (regression)', async () => { + // getLiveAgentKind execs `claude agents --json` off process.env, so the + // probe must see the injected claude env (e.g. CLAUDE_CONFIG_DIR) the real + // launch uses; otherwise it queries the default config and misses live + // sessions. Capture process.env at the moment the probe runs. + const querySpy = vi.spyOn(claudeSdk, 'query').mockImplementation(queryMock as typeof claudeSdk.query); + let configDirAtProbe: string | undefined = 'NOT_CALLED'; + getLiveAgentKindMock.mockImplementation(() => { + configDirAtProbe = process.env.CLAUDE_CONFIG_DIR; + return null; + }); + queryMock.mockReturnValueOnce(createAsyncStream(initThenResult(RESUME_ID))); + + const { claudeRemote } = await import('./claudeRemote'); + + let nextCallCount = 0; + await claudeRemote({ + sessionId: RESUME_ID, + path: process.cwd(), + mcpServers: {}, + claudeEnvVars: { CLAUDE_CONFIG_DIR: '/tmp/x-cfg' }, + claudeArgs: [], + allowedTools: [], + hookSettingsPath: '/tmp/hook.json', + canCallTool: async () => ({ behavior: 'allow', updatedInput: {} }), + nextMessage: async () => { + nextCallCount += 1; + if (nextCallCount === 1) { + return { message: 'ping', mode: { permissionMode: 'default' } }; + } + return null; + }, + onReady: () => {}, + isAborted: () => false, + onSessionFound: () => {}, + onMessage: () => {} + }); + + try { + expect(getLiveAgentKindMock).toHaveBeenCalledWith(RESUME_ID); + // The injected env must already be live when the probe runs. + expect(configDirAtProbe).toBe('/tmp/x-cfg'); + } finally { + queryMock.mockReset(); + querySpy.mockRestore(); + } + }, 15_000); + + it('does NOT fork a dead session and records no forkedFrom (regression: AC7)', async () => { + const querySpy = vi.spyOn(claudeSdk, 'query').mockImplementation(queryMock as typeof claudeSdk.query); + getLiveAgentKindMock.mockReturnValue(null); + // A dead resume reuses the same session id. + queryMock.mockReturnValueOnce(createAsyncStream(initThenResult(RESUME_ID))); + + const { claudeRemote } = await import('./claudeRemote'); + const foundCalls: Array<{ id: string; extras?: Partial }> = []; + + let nextCallCount = 0; + await claudeRemote({ + sessionId: RESUME_ID, + path: process.cwd(), + mcpServers: {}, + claudeEnvVars: {}, + claudeArgs: [], + allowedTools: [], + hookSettingsPath: '/tmp/hook.json', + canCallTool: async () => ({ behavior: 'allow', updatedInput: {} }), + nextMessage: async () => { + nextCallCount += 1; + if (nextCallCount === 1) { + return { message: 'ping', mode: { permissionMode: 'default' } }; + } + return null; + }, + onReady: () => {}, + isAborted: () => false, + onSessionFound: (id, extras) => { + foundCalls.push({ id, extras }); + }, + onMessage: () => {} + }); + + try { + const passedOptions = queryMock.mock.calls[0][0].options; + expect(passedOptions.resume).toBe(RESUME_ID); + expect(passedOptions.forkSession).toBe(false); + expect(foundCalls).toEqual([{ id: RESUME_ID, extras: undefined }]); + } finally { + queryMock.mockReset(); + querySpy.mockRestore(); + } + }, 15_000); +}); diff --git a/cli/src/claude/claudeRemote.ts b/cli/src/claude/claudeRemote.ts index 038b037fd8..372b918b7c 100644 --- a/cli/src/claude/claudeRemote.ts +++ b/cli/src/claude/claudeRemote.ts @@ -1,6 +1,8 @@ import { EnhancedMode, PermissionMode } from "./loop"; import { query, type QueryOptions as Options, type SDKMessage, type SDKSystemMessage, AbortError, SDKUserMessage } from '@/claude/sdk' import { claudeCheckSession } from "./utils/claudeCheckSession"; +import { getLiveAgentKind } from "./utils/getLiveAgentKind"; +import type { Metadata } from "@/api/types"; import { join } from 'node:path'; import { parseSpecialCommand } from "@/parsers/specialCommands"; import { logger } from "@/lib"; @@ -31,7 +33,7 @@ export async function claudeRemote(opts: { isAborted: (toolCallId: string) => boolean, // Callbacks - onSessionFound: (id: string) => void, + onSessionFound: (id: string, extras?: Partial) => void, onThinkingChange?: (thinking: boolean) => void, onMessage: (message: SDKMessage) => void, onCompletionEvent?: (message: string) => void, @@ -71,7 +73,13 @@ export async function claudeRemote(opts: { } } - // Set environment variables for Claude Code SDK + // Set environment variables for Claude Code SDK. + // Apply these BEFORE the liveness probe below: `getLiveAgentKind` execs + // `claude agents --json` using `process.env` (e.g. CLAUDE_CONFIG_DIR, + // HAPI_CLAUDE_PATH), so the probe must see the same claude env the real + // launch will use. Otherwise it queries the default config/home, misses the + // live session, picks `forkSession=false`, and the resume hits the + // "currently running as a background agent" failure path. if (opts.claudeEnvVars) { Object.entries(opts.claudeEnvVars).forEach(([key, value]) => { process.env[key] = value; @@ -79,6 +87,22 @@ export async function claudeRemote(opts: { } process.env.DISABLE_AUTOUPDATER = '1'; + // Decide how to resume. A claude session can still be held open by a running + // agent (background/interactive); a plain `--resume` is then rejected with + // "currently running as a background agent". When that's the case, branch off + // a copy with `--fork-session` instead of taking over the live session. + // `getLiveAgentKind` degrades to null (treat as dead -> plain resume) when the + // daemon roster is unavailable, so this never blocks the resume path. + let forkSession = false; + if (startFrom) { + const liveKind = getLiveAgentKind(startFrom); + if (liveKind) { + forkSession = true; + logger.debug(`[claudeRemote] Session ${startFrom} is live as ${liveKind} agent; resuming with --fork-session`); + } + } + const forkedFrom = forkSession ? startFrom : null; + // Get initial message let initial; try { @@ -125,6 +149,7 @@ export async function claudeRemote(opts: { const sdkOptions: Options = { cwd: opts.path, resume: startFrom ?? undefined, + forkSession, mcpServers: opts.mcpServers, permissionMode: initial.mode.permissionMode, model: initial.mode.model, @@ -250,7 +275,13 @@ export async function claudeRemote(opts: { const projectDir = getProjectPath(opts.path); const found = await awaitFileExist(join(projectDir, `${systemInit.session_id}.jsonl`)); logger.debug(`[claudeRemote] Session file found: ${systemInit.session_id} ${found}`); - opts.onSessionFound(systemInit.session_id); + // When forked, the new session_id is a branched copy: record its + // origin so the web list can mark it as "forked from ..." instead + // of surfacing two unrelated-looking sessions (R5). + const extras = forkedFrom && forkedFrom !== systemInit.session_id + ? { forkedFrom } + : undefined; + opts.onSessionFound(systemInit.session_id, extras); } } diff --git a/cli/src/claude/claudeRemoteLauncher.retry.test.ts b/cli/src/claude/claudeRemoteLauncher.retry.test.ts new file mode 100644 index 0000000000..29c340fa00 --- /dev/null +++ b/cli/src/claude/claudeRemoteLauncher.retry.test.ts @@ -0,0 +1,189 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +/** + * Wiring test for the launch retry loop in claudeRemoteLauncher (design §4.3, + * AC4). The two predicates are unit-tested in utils/claudeResumeError.test.ts; + * this exercises how the launcher acts on them inside runMainLoop: + * - unrecoverable error -> stop immediately (no retry, exit) + * - recoverable error -> retry up to MAX_LAUNCH_RETRIES, then stop + * - clean run after fails -> retry budget resets + * This is the regression guard for the original "infinite respawn" bug. + */ + +const harness = vi.hoisted(() => ({ + // Each entry is a behavior for the Nth claudeRemote() call. + behaviors: [] as Array<() => Promise>, + callIndex: 0 +})); + +vi.mock('./claudeRemote', () => ({ + claudeRemote: async () => { + const behavior = harness.behaviors[harness.callIndex] ?? (async () => {}); + harness.callIndex += 1; + await behavior(); + } +})); + +// Inner collaborators of runMainLoop that we do not exercise here. Mock them +// to no-ops so the test stays focused on the catch/retry branch. +vi.mock('./utils/permissionHandler', () => ({ + PermissionHandler: class { + handleToolCall = async () => ({ behavior: 'allow', updatedInput: {} }); + isAborted = () => false; + setOnPermissionRequest = () => {}; + getResponses = () => new Map(); + onMessage = () => {}; + reset = () => {}; + handleModeChange = () => {}; + } +})); + +vi.mock('./utils/OutgoingMessageQueue', () => ({ + OutgoingMessageQueue: class { + constructor(_send: unknown) {} + enqueue = () => {}; + releaseToolCall = async () => {}; + flush = async () => {}; + destroy = () => {}; + } +})); + +vi.mock('./utils/sdkToLogConverter', () => ({ + SDKToLogConverter: class { + constructor(_meta: unknown, _responses: unknown) {} + updateSessionId = () => {}; + resetParentChain = () => {}; + convert = () => null; + convertSidechainUserMessage = () => null; + generateInterruptedToolResult = () => null; + } +})); + +vi.mock('@/ui/ink/RemoteModeDisplay', () => ({ + RemoteModeDisplay: () => null +})); + +import { claudeRemoteLauncher } from './claudeRemoteLauncher'; + +type SessionEvent = { type: string; message?: string }; + +function createSessionStub() { + const events: SessionEvent[] = []; + // waitForMessagesAndGetAsString is only reached on a *clean* claudeRemote + // return (the loop awaits the next user message). Returning null ends the + // session, so a clean run terminates the loop deterministically. + const session = { + sessionId: 'sess-1', + path: '/tmp/test', + allowedTools: [], + mcpServers: {}, + hookSettingsPath: '/tmp/hook.json', + claudeEnvVars: {}, + claudeArgs: [], + logPath: '/tmp/test.log', + onThinkingChange: () => {}, + queue: { + size: () => 0, + waitForMessagesAndGetAsString: async () => null + }, + client: { + rpcHandlerManager: { registerHandler: () => {} }, + sendClaudeSessionMessage: () => {}, + sendSessionEvent: (event: SessionEvent) => { events.push(event); } + }, + addSessionFoundCallback: () => {}, + removeSessionFoundCallback: () => {}, + consumeOneTimeFlags: () => {}, + onSessionFound: () => {}, + clearSessionId: () => {} + }; + return { session, events }; +} + +function messages(events: SessionEvent[]): string[] { + return events.filter(e => e.type === 'message').map(e => e.message ?? ''); +} + +afterEach(() => { + harness.behaviors = []; + harness.callIndex = 0; + vi.clearAllMocks(); +}); + +describe('claudeRemoteLauncher launch retry wiring', () => { + it('stops immediately on an unrecoverable resume error (no retry)', async () => { + harness.behaviors = [ + async () => { + throw new Error( + 'Session sess-1 is currently running as a background agent (bg). ' + + 'Use claude agents to find and attach to it, or add --fork-session to branch off a copy.' + ); + } + ]; + const { session, events } = createSessionStub(); + + const exitReason = await claudeRemoteLauncher(session as never); + + // Exactly one attempt: the loop must break instead of looping. + expect(harness.callIndex).toBe(1); + expect(exitReason).toBe('exit'); + const msgs = messages(events); + expect(msgs.some(m => m.startsWith('Cannot resume session:'))).toBe(true); + // The real underlying reason must be surfaced (AC2), not swallowed. + expect(msgs.some(m => m.includes('currently running as a background agent'))).toBe(true); + // A recoverable "Process exited unexpectedly" retry message must NOT appear. + expect(msgs.some(m => m.startsWith('Process exited unexpectedly'))).toBe(false); + }, 15_000); + + it('retries a recoverable error up to MAX_LAUNCH_RETRIES then stops', async () => { + // Always throw a transient (recoverable) error so the budget is the + // only thing that stops the loop. + const transient = async () => { throw new Error('Claude Code process exited with code 1'); }; + harness.behaviors = Array.from({ length: 10 }, () => transient); + const { session, events } = createSessionStub(); + + const exitReason = await claudeRemoteLauncher(session as never); + + // MAX_LAUNCH_RETRIES = 3: attempts are the initial try + 3 retries = 4, + // and the 4th is where budgetExhausted trips and the loop breaks. + expect(harness.callIndex).toBe(4); + expect(exitReason).toBe('exit'); + const msgs = messages(events); + const retries = msgs.filter(m => m.startsWith('Process exited unexpectedly')); + expect(retries).toHaveLength(3); + expect(msgs.some(m => m.includes('failed to start after 3 attempts'))).toBe(true); + }, 15_000); + + it('resets the retry budget after a clean run', async () => { + // The launch loop only ends when exitReason is set (the mocked + // claudeRemote returns synchronously, so a clean run alone re-loops). + // To prove the budget RESET, we burn 2 retries, do a clean run (reset), + // then need a *full* fresh budget (3 more retries) before exhaustion. + // If the reset were missing, exhaustion would trip far sooner. + const transient = () => { throw new Error('Claude Code process exited with code 1'); }; + let cleanRunHappened = false; + harness.behaviors = [ + async () => transient(), // attempt 1: retry 1 + async () => transient(), // attempt 2: retry 2 + async () => { cleanRunHappened = true; }, // attempt 3: clean -> budget reset + async () => transient(), // attempt 4: retry 1 (post-reset) + async () => transient(), // attempt 5: retry 2 (post-reset) + async () => transient(), // attempt 6: retry 3 (post-reset) + async () => transient() // attempt 7: budget exhausted -> break + ]; + const { session, events } = createSessionStub(); + + const exitReason = await claudeRemoteLauncher(session as never); + + expect(cleanRunHappened).toBe(true); + // Without the reset, MAX_LAUNCH_RETRIES(=3) would trip at attempt 4 + // (2 pre + 1 post). The reset lets the loop reach attempt 7. + expect(harness.callIndex).toBe(7); + expect(exitReason).toBe('exit'); + const msgs = messages(events); + // 2 retries before the clean run + 3 retries after = 5 transient messages. + expect(msgs.filter(m => m.startsWith('Process exited unexpectedly'))).toHaveLength(5); + // Exhaustion fires exactly once, on the post-reset budget. + expect(msgs.filter(m => m.includes('failed to start after 3 attempts'))).toHaveLength(1); + }, 15_000); +}); diff --git a/cli/src/claude/claudeRemoteLauncher.ts b/cli/src/claude/claudeRemoteLauncher.ts index c5f4a327f4..1e00ae3a8a 100644 --- a/cli/src/claude/claudeRemoteLauncher.ts +++ b/cli/src/claude/claudeRemoteLauncher.ts @@ -17,6 +17,12 @@ import { type RemoteLauncherDisplayContext, type RemoteLauncherExitReason } from "@/modules/common/remote/RemoteLauncherBase"; +import { isUnrecoverableClaudeResumeError } from "./utils/claudeResumeError"; + +// Bound on consecutive automatic relaunches after an unexpected claudeRemote +// failure. Mirrors codex's SAME_THREAD_MAX_RETRIES. Prevents an unforeseen +// persistent failure (e.g. a reject we did not classify) from spinning forever. +const MAX_LAUNCH_RETRIES = 3; interface PermissionsField { date: number; @@ -273,6 +279,7 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { } | null = null; let previousSessionId: string | null = null; + let launchRetryCount = 0; while (!this.exitReason) { logger.debug('[remote]: launch'); messageBuffer.addMessage('═'.repeat(40), 'status'); @@ -332,8 +339,8 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { return null; }, - onSessionFound: (sessionId) => { - session.onSessionFound(sessionId); + onSessionFound: (sessionId, extras) => { + session.onSessionFound(sessionId, extras); }, onThinkingChange: session.onThinkingChange, claudeEnvVars: session.claudeEnvVars, @@ -364,6 +371,10 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { session.consumeOneTimeFlags(); + // Clean run (the session ended normally and we loop to await + // the next message): forget any prior error-retry budget. + launchRetryCount = 0; + if (!this.exitReason && controller.signal.aborted) { session.client.sendSessionEvent({ type: 'message', message: 'Aborted by user' }); } @@ -371,6 +382,25 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { logger.debug('[remote]: launch error', e); if (!this.exitReason) { const detail = e instanceof Error ? e.message : String(e); + + // Unrecoverable failures (e.g. the resume target is held open + // by a live agent) can never be fixed by re-running identical + // args; the live-agent path normally forks instead, but this is + // the second line of defense. A retry budget bounds any + // unforeseen persistent failure so we never spin forever. + const unrecoverable = isUnrecoverableClaudeResumeError(e); + const budgetExhausted = launchRetryCount >= MAX_LAUNCH_RETRIES; + if (unrecoverable || budgetExhausted) { + const reason = unrecoverable + ? `Cannot resume session: ${detail}` + : `Session failed to start after ${MAX_LAUNCH_RETRIES} attempts: ${detail}`; + logger.debug(`[remote]: launch unrecoverable (unrecoverable=${unrecoverable}, retries=${launchRetryCount}); stopping`); + session.client.sendSessionEvent({ type: 'message', message: reason }); + this.exitReason = 'exit'; + break; + } + + launchRetryCount += 1; session.client.sendSessionEvent({ type: 'message', message: `Process exited unexpectedly: ${detail}` }); continue; } diff --git a/cli/src/claude/sdk/query.test.ts b/cli/src/claude/sdk/query.test.ts index 7286024959..50cb142875 100644 --- a/cli/src/claude/sdk/query.test.ts +++ b/cli/src/claude/sdk/query.test.ts @@ -79,6 +79,38 @@ describe('Query', () => { await expect(result.next()).rejects.toThrow('prompt failed') }) + it('passes --resume and --fork-session to the spawned claude process', async () => { + const child = createFakeChild() + spawnMock.mockReturnValueOnce(child) + process.env.HAPI_CLAUDE_PATH = 'claude' + + const { query } = await import('./query') + const sessionId = '6f0c4551-1111-4222-8333-444455556666' + query({ prompt: 'ping', options: { resume: sessionId, forkSession: true } }) + + expect(spawnMock).toHaveBeenCalledTimes(1) + const args = spawnMock.mock.calls[0][1] as string[] + expect(args).toContain('--resume') + expect(args[args.indexOf('--resume') + 1]).toBe(sessionId) + expect(args).toContain('--fork-session') + // --fork-session must come after --resume so claude treats it as a branch + expect(args.indexOf('--fork-session')).toBeGreaterThan(args.indexOf('--resume')) + }) + + it('omits --fork-session when forkSession is not set', async () => { + const child = createFakeChild() + spawnMock.mockReturnValueOnce(child) + process.env.HAPI_CLAUDE_PATH = 'claude' + + const { query } = await import('./query') + query({ prompt: 'ping', options: { resume: 'some-session-id' } }) + + expect(spawnMock).toHaveBeenCalledTimes(1) + const args = spawnMock.mock.calls[0][1] as string[] + expect(args).toContain('--resume') + expect(args).not.toContain('--fork-session') + }) + it('fails fast after cleanup timeout when prompt cleanup hangs', async () => { const child = createFakeChild() spawnMock.mockReturnValueOnce(child) diff --git a/cli/src/claude/sdk/query.ts b/cli/src/claude/sdk/query.ts index 246fb32afc..317f6d43a8 100644 --- a/cli/src/claude/sdk/query.ts +++ b/cli/src/claude/sdk/query.ts @@ -309,6 +309,7 @@ export function query(config: { permissionMode = 'default', continue: continueConversation, resume, + forkSession, model, effort, fallbackModel, @@ -341,6 +342,7 @@ export function query(config: { } if (continueConversation) args.push('--continue') if (resume) args.push('--resume', resume) + if (forkSession) args.push('--fork-session') if (settingsPath) args.push('--settings', settingsPath) if (allowedTools.length > 0) args.push('--allowedTools', allowedTools.join(',')) if (disallowedTools.length > 0) args.push('--disallowedTools', disallowedTools.join(',')) diff --git a/cli/src/claude/sdk/types.ts b/cli/src/claude/sdk/types.ts index 5af54833e0..564376485a 100644 --- a/cli/src/claude/sdk/types.ts +++ b/cli/src/claude/sdk/types.ts @@ -179,6 +179,13 @@ export interface QueryOptions { permissionMode?: ClaudePermissionMode continue?: boolean resume?: string + /** + * When resuming (`resume`/`continue`), branch off a copy with a new session + * ID instead of taking over the existing one. Required when the target + * session is still held open by a running Claude agent, where a plain + * `--resume` is rejected with "currently running as a background agent". + */ + forkSession?: boolean model?: string effort?: string fallbackModel?: string diff --git a/cli/src/claude/session.ts b/cli/src/claude/session.ts index 2106d312fa..2c057307a7 100644 --- a/cli/src/claude/session.ts +++ b/cli/src/claude/session.ts @@ -53,8 +53,9 @@ export class Session extends AgentSessionBase { mode: opts.mode, sessionLabel: 'Session', sessionIdLabel: 'Claude Code', - applySessionIdToMetadata: (metadata, sessionId) => ({ + applySessionIdToMetadata: (metadata, sessionId, extras) => ({ ...metadata, + ...extras, claudeSessionId: sessionId }), permissionMode: opts.permissionMode, diff --git a/cli/src/claude/utils/claudeResumeError.test.ts b/cli/src/claude/utils/claudeResumeError.test.ts new file mode 100644 index 0000000000..1bf287f2ff --- /dev/null +++ b/cli/src/claude/utils/claudeResumeError.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { isUnrecoverableClaudeResumeError } from './claudeResumeError' + +describe('isUnrecoverableClaudeResumeError', () => { + it('classifies the "running as a background agent" rejection as unrecoverable', () => { + const error = new Error( + 'Session 6f0c4551 is currently running as a background agent (bg). ' + + 'Use claude agents to find and attach to it, or add --fork-session to branch off a copy.' + ) + expect(isUnrecoverableClaudeResumeError(error)).toBe(true) + }) + + it('classifies the interactive variant as unrecoverable', () => { + const error = new Error('Session abc is currently running as an interactive agent.') + expect(isUnrecoverableClaudeResumeError(error)).toBe(true) + }) + + it('matches the --fork-session hint regardless of surrounding wording', () => { + const error = new Error('add --fork-session to branch off a copy') + expect(isUnrecoverableClaudeResumeError(error)).toBe(true) + }) + + it('is case-insensitive', () => { + const error = new Error('SESSION X IS CURRENTLY RUNNING AS A BACKGROUND AGENT') + expect(isUnrecoverableClaudeResumeError(error)).toBe(true) + }) + + it('treats a transient process-exit error as recoverable (retryable)', () => { + const error = new Error('Claude Code process exited with code 1') + expect(isUnrecoverableClaudeResumeError(error)).toBe(false) + }) + + it('treats a spawn failure as recoverable (retryable)', () => { + const error = new Error('Failed to spawn Claude Code process: ENOENT') + expect(isUnrecoverableClaudeResumeError(error)).toBe(false) + }) + + it('handles non-Error values without throwing', () => { + expect(isUnrecoverableClaudeResumeError('currently running as a background agent')).toBe(true) + expect(isUnrecoverableClaudeResumeError(undefined)).toBe(false) + expect(isUnrecoverableClaudeResumeError(null)).toBe(false) + }) +}) diff --git a/cli/src/claude/utils/claudeResumeError.ts b/cli/src/claude/utils/claudeResumeError.ts new file mode 100644 index 0000000000..ad5500919f --- /dev/null +++ b/cli/src/claude/utils/claudeResumeError.ts @@ -0,0 +1,33 @@ +/** + * Classify Claude launch failures so the remote launcher can tell apart + * transient errors (worth retrying) from unrecoverable ones (retrying can only + * loop forever). + * + * The motivating case: resuming a session that is still held open by a running + * agent makes claude exit 1 with + * "Session is currently running as a background agent (bg). Use claude + * agents to find and attach to it, or add --fork-session to branch off a + * copy." + * Retrying that verbatim never succeeds, so it must stop the retry loop. + * + * Matching is intentionally loose (substrings, not exact text) so it keeps + * working if claude's wording drifts across versions; the launcher also caps + * total retries as a second line of defense (see MAX_LAUNCH_RETRIES). + */ + +const UNRECOVERABLE_RESUME_MESSAGE_PATTERNS: string[] = [ + 'currently running as a background agent', + 'currently running as an interactive', + '--fork-session to branch off', + 'is currently running' +] + +/** + * True when `error`'s message indicates the resume target is occupied / the + * request was rejected in a way that re-running identical args cannot fix. + */ +export function isUnrecoverableClaudeResumeError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + const lower = message.toLowerCase() + return UNRECOVERABLE_RESUME_MESSAGE_PATTERNS.some((pattern) => lower.includes(pattern)) +} diff --git a/cli/src/claude/utils/getLiveAgentKind.test.ts b/cli/src/claude/utils/getLiveAgentKind.test.ts new file mode 100644 index 0000000000..549412b46e --- /dev/null +++ b/cli/src/claude/utils/getLiveAgentKind.test.ts @@ -0,0 +1,105 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { execFileSyncMock } = vi.hoisted(() => ({ + execFileSyncMock: vi.fn() +})) + +vi.mock('node:child_process', async () => { + const actual = await vi.importActual('node:child_process') + return { + ...actual, + execFileSync: execFileSyncMock + } +}) + +vi.mock('@/claude/sdk/utils', () => ({ + getDefaultClaudeCodePath: () => 'claude' +})) + +vi.mock('@/utils/bunRuntime', () => ({ + withBunRuntimeEnv: (env: NodeJS.ProcessEnv) => env +})) + +const SESSION_ID = '6f0c4551-1111-4222-8333-444455556666' + +function rosterJson(entries: Array>): string { + return JSON.stringify(entries) +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('getLiveAgentKind', () => { + it('returns "background" when the session is alive as a background agent', async () => { + execFileSyncMock.mockReturnValue(rosterJson([ + { pid: 1, kind: 'background', sessionId: SESSION_ID } + ])) + const { getLiveAgentKind } = await import('./getLiveAgentKind') + expect(getLiveAgentKind(SESSION_ID)).toBe('background') + }) + + it('returns "interactive" when the session is alive as an interactive agent', async () => { + execFileSyncMock.mockReturnValue(rosterJson([ + { pid: 2, kind: 'interactive', sessionId: SESSION_ID } + ])) + const { getLiveAgentKind } = await import('./getLiveAgentKind') + expect(getLiveAgentKind(SESSION_ID)).toBe('interactive') + }) + + it('returns null when the session is not in the roster (dead -> resume directly)', async () => { + execFileSyncMock.mockReturnValue(rosterJson([ + { pid: 3, kind: 'background', sessionId: 'some-other-session' } + ])) + const { getLiveAgentKind } = await import('./getLiveAgentKind') + expect(getLiveAgentKind(SESSION_ID)).toBeNull() + }) + + it('returns null when the roster is empty', async () => { + execFileSyncMock.mockReturnValue(rosterJson([])) + const { getLiveAgentKind } = await import('./getLiveAgentKind') + expect(getLiveAgentKind(SESSION_ID)).toBeNull() + }) + + it('returns null when "claude agents --json" fails (command unavailable / timeout)', async () => { + execFileSyncMock.mockImplementation(() => { + throw new Error('claude: command not found') + }) + const { getLiveAgentKind } = await import('./getLiveAgentKind') + expect(getLiveAgentKind(SESSION_ID)).toBeNull() + }) + + it('returns null when the output is not valid JSON', async () => { + execFileSyncMock.mockReturnValue('not json at all') + const { getLiveAgentKind } = await import('./getLiveAgentKind') + expect(getLiveAgentKind(SESSION_ID)).toBeNull() + }) + + it('returns null for an empty sessionId without querying the roster', async () => { + const { getLiveAgentKind } = await import('./getLiveAgentKind') + expect(getLiveAgentKind('')).toBeNull() + expect(execFileSyncMock).not.toHaveBeenCalled() + }) + + it('treats an in-roster session with an unknown kind as held open (fork)', async () => { + execFileSyncMock.mockReturnValue(rosterJson([ + { pid: 4, kind: 'something-new', sessionId: SESSION_ID } + ])) + const { getLiveAgentKind } = await import('./getLiveAgentKind') + expect(getLiveAgentKind(SESSION_ID)).toBe('background') + }) + + it('passes "agents --json" to the resolved claude executable', async () => { + execFileSyncMock.mockReturnValue(rosterJson([])) + const { getLiveAgentKind } = await import('./getLiveAgentKind') + getLiveAgentKind(SESSION_ID) + expect(execFileSyncMock).toHaveBeenCalledTimes(1) + const [command, args] = execFileSyncMock.mock.calls[0] + expect(command).toBe('claude') + expect(args).toEqual(['agents', '--json']) + }) +}) diff --git a/cli/src/claude/utils/getLiveAgentKind.ts b/cli/src/claude/utils/getLiveAgentKind.ts new file mode 100644 index 0000000000..32564c134b --- /dev/null +++ b/cli/src/claude/utils/getLiveAgentKind.ts @@ -0,0 +1,89 @@ +import { execFileSync } from 'node:child_process' +import { homedir } from 'node:os' +import { logger } from '@/ui/logger' +import { withBunRuntimeEnv } from '@/utils/bunRuntime' +import { getDefaultClaudeCodePath } from '@/claude/sdk/utils' + +/** + * Live agent kind reported by the local Claude daemon roster. + * + * A Claude session can be in one of three states from HAPI's point of view: + * - dead (only a `.jsonl` transcript on disk) -> can be resumed directly + * - alive as a background agent -> `--resume` is rejected; must `--fork-session` + * - alive as an interactive agent -> same, must `--fork-session` + * + * `claudeCheckSession` only answers "does a resumable transcript exist"; it + * cannot tell whether the session is currently held open by a running agent. + */ +export type LiveAgentKind = 'background' | 'interactive' + +const AGENTS_QUERY_TIMEOUT_MS = 5_000 + +interface ClaudeAgentEntry { + sessionId?: unknown + kind?: unknown +} + +/** + * Determine whether `sessionId` is currently held open by a running Claude + * agent (background or interactive), per the local daemon roster + * (`claude agents --json`). + * + * Returns the agent `kind` when the session is alive, or `null` when it is not + * in the roster, the command is unavailable / times out, or the output cannot + * be parsed. Returning `null` is the safe degradation: the caller treats the + * session as dead and resumes it directly (current behavior), so this never + * blocks the resume path. + */ +export function getLiveAgentKind(sessionId: string): LiveAgentKind | null { + if (!sessionId) { + return null + } + + let raw: string + try { + raw = execFileSync(getDefaultClaudeCodePath(), ['agents', '--json'], { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + cwd: homedir(), + timeout: AGENTS_QUERY_TIMEOUT_MS, + env: withBunRuntimeEnv(process.env, { allowBunBeBun: false }), + shell: process.platform === 'win32', + windowsHide: process.platform === 'win32' + }) + } catch (e) { + // Command missing, daemon down, timeout, non-zero exit, etc. + // Degrade to "treat as dead" so the resume path is never blocked. + logger.debug('[getLiveAgentKind] failed to query claude agents', e) + return null + } + + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch (e) { + logger.debug('[getLiveAgentKind] failed to parse claude agents --json output', e) + return null + } + + if (!Array.isArray(parsed)) { + return null + } + + for (const entry of parsed as ClaudeAgentEntry[]) { + if (!entry || typeof entry !== 'object') { + continue + } + if (entry.sessionId !== sessionId) { + continue + } + if (entry.kind === 'background' || entry.kind === 'interactive') { + return entry.kind + } + // Session is in the roster but with an unknown/foreign kind: it is still + // held open, so fork rather than risk an occupied-session resume. + return 'background' + } + + return null +} diff --git a/cli/src/ui/logger.test.ts b/cli/src/ui/logger.test.ts new file mode 100644 index 0000000000..48a7ef9205 --- /dev/null +++ b/cli/src/ui/logger.test.ts @@ -0,0 +1,65 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { readFileSync, rmSync, mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const tmpDirs: string[] = [] + +function freshLogPath(): string { + const dir = mkdtempSync(join(tmpdir(), 'hapi-logger-test-')) + tmpDirs.push(dir) + return join(dir, 'test.log') +} + +afterEach(() => { + while (tmpDirs.length) { + const dir = tmpDirs.pop()! + try { + rmSync(dir, { recursive: true, force: true }) + } catch { + // best effort + } + } +}) + +describe('logger Error serialization', () => { + it('logs an Error argument with its message and stack, not "{}"', async () => { + const { Logger } = await import('./logger') + const logPath = freshLogPath() + const logger = new Logger(logPath) + + const error = new Error('Session 6f0c4551 is currently running as a background agent (bg).') + logger.debug('[remote]: launch error', error) + + const contents = readFileSync(logPath, 'utf8') + expect(contents).toContain('is currently running as a background agent') + expect(contents).toContain('"name"') + expect(contents).toContain('"stack"') + // The whole point: the old `JSON.stringify(error)` collapsed to "{}". + expect(contents).not.toContain('[remote]: launch error {}') + }) + + it('surfaces Errors nested inside objects via debugLargeJson', async () => { + const prevDebug = process.env.DEBUG + process.env.DEBUG = '1' + try { + const { Logger } = await import('./logger') + const logPath = freshLogPath() + const logger = new Logger(logPath) + + logger.debugLargeJson('[remote]: payload', { + cause: new Error('boom failure detail') + }) + + const contents = readFileSync(logPath, 'utf8') + expect(contents).toContain('boom failure detail') + expect(contents).toContain('"message"') + } finally { + if (prevDebug === undefined) { + delete process.env.DEBUG + } else { + process.env.DEBUG = prevDebug + } + } + }) +}) diff --git a/cli/src/ui/logger.ts b/cli/src/ui/logger.ts index 3ebb34d802..b9c4bc7e2a 100644 --- a/cli/src/ui/logger.ts +++ b/cli/src/ui/logger.ts @@ -12,6 +12,25 @@ import { existsSync, readdirSync, statSync } from 'node:fs' import { join, basename } from 'node:path' import { readRunnerState } from '@/persistence' +/** + * Convert a value into something JSON-serializable for logs. + * + * `JSON.stringify(new Error(...))` returns `"{}"` because Error's own + * properties are non-enumerable, which silently swallowed real failures + * (e.g. "response stream error {}"). Surface name/message/stack instead so + * the true cause reaches the logs and, downstream, the web UI. + */ +function serializeLogArg(arg: unknown): unknown { + if (arg instanceof Error) { + return { + name: arg.name, + message: arg.message, + stack: arg.stack + } + } + return arg +} + /** * Consistent date/time formatting functions */ @@ -44,7 +63,7 @@ function getSessionLogPath(): string { return join(configuration.logsDir, filename) } -class Logger { +export class Logger { private dangerouslyUnencryptedServerLoggingUrl: string | undefined constructor( @@ -89,6 +108,10 @@ class Logger { // Some of our messages are huge, but we still want to show them in the logs const truncateStrings = (obj: unknown): unknown => { + if (obj instanceof Error) { + return truncateStrings(serializeLogArg(obj)) + } + if (typeof obj === 'string') { return obj.length > maxStringLength ? obj.substring(0, maxStringLength) + '... [truncated for logs]' @@ -187,8 +210,8 @@ class Logger { body: JSON.stringify({ timestamp: new Date().toISOString(), level, - message: `${message} ${args.map(a => - typeof a === 'object' ? JSON.stringify(a, null, 2) : String(a) + message: `${message} ${args.map(a => + typeof a === 'object' ? JSON.stringify(serializeLogArg(a), null, 2) : String(a) ).join(' ')}`, source: 'cli', platform: process.platform @@ -200,8 +223,8 @@ class Logger { } private logToFile(prefix: string, message: string, ...args: unknown[]): void { - const logLine = `${prefix} ${message} ${args.map(arg => - typeof arg === 'string' ? arg : JSON.stringify(arg) + const logLine = `${prefix} ${message} ${args.map(arg => + typeof arg === 'string' ? arg : JSON.stringify(serializeLogArg(arg)) ).join(' ')}\n` // Send to remote server if configured diff --git a/hub/src/cursor/cursorImporter.ts b/hub/src/cursor/cursorImporter.ts new file mode 100644 index 0000000000..d17557babb --- /dev/null +++ b/hub/src/cursor/cursorImporter.ts @@ -0,0 +1,701 @@ +/** + * Hub-side importer for cursor chats discovered on the local + * `~/.cursor/{chats,acp-sessions}` filesystem. + * + * Companion module to the cursor flavor of the multi-agent import picker + * (`hub/src/web/routes/cursorImport.ts`). The legacy → ACP transplant + * primitive shipped upstream in `tiann/hapi#844` + * (`hub/src/cursor/cursorLegacyMigrator.ts`), but that primitive operates on + * an existing HAPI session row that already references the cursor uuid. + * For the IMPORT flow there is no pre-existing HAPI row yet — and the + * spec's strict refusal contract forbids creating one until the + * verify-probe has passed. This module therefore reuses the verify-probe + * + transplant pattern in standalone form, mirroring the per-chat code + * path of `scripts/audit-cursor-acp-verify.ts` (which is committed at + * branch HEAD and ran 391/391 = 100% pass on the maintainer's + * real-world chat library before this code shipped). + * + * Refusal contract (strict ACP-only): + * - The cursor flavor is STRICTLY ACP-only. Verify must pass before + * any HAPI row is created. No fallback to stream-json, ever. + * - Refusal cases (mirrored in `CursorImportRefusalReason`): + * verify_load_failed, missing_on_disk_store, target_already_exists, + * already_imported, agent_binary_not_found, verify_timeout, + * corrupted_store, ambiguous_legacy_store, internal_error + * - On refusal: legacy `store.db` is untouched, no HAPI row is created, + * structured error returned to the caller. + * + * Discovery covers two on-disk shapes: + * - legacy: `~/.cursor/chats///store.db` + * - acp: `~/.cursor/acp-sessions//{store.db, meta.json}` + * + * Imports of `legacy` rows transplant to the ACP location via the same + * cp + meta.json + verify dance the migrator uses. Imports of `acp` rows + * are no-ops on disk — just a HAPI row pointing at the existing dir. + */ + +import { Database } from 'bun:sqlite' +import { randomUUID, createHash } from 'node:crypto' +import { + chmodSync, + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import { homedir, hostname, platform, tmpdir } from 'node:os' +import { join } from 'node:path' + +import { AcpVerifyProbe, type AcpProbeOptions } from './acpVerifyProbe' +import { listLegacyChatStoreCandidates, readLegacyMetaLastUsedModel } from './cursorLegacyMigrator' +import type { + CursorImportableSessionSummary, + CursorImportRefusalReason, + CursorImportRowOutcome, + CursorImportSourceFormat +} from '../web/routes/_agentImport/types' +import type { Store } from '../store' +import type { SyncEngine } from '../sync/syncEngine' + +// UUID-ish basename validation: same rule the migrator uses to refuse +// path-traversal in `//store.db`. Importer-facing +// uuids must pass this gate too. See `cursorLegacyMigrator` +// CURSOR_SESSION_ID_RE. +const CURSOR_SESSION_ID_RE = /^[A-Za-z0-9_.-]+$/ + +const AUTH_FILES = ['cli-config.json', 'agent-cli-state.json', 'acp-config.json'] +const DEFAULT_INIT_TIMEOUT_MS = 20_000 +const DEFAULT_LOAD_TIMEOUT_MS = 30_000 +const DEFAULT_REPLAY_DRAIN_MS = 1_500 +const DEFAULT_VERIFY_TIMEOUT_MS = 60_000 + +const DEFAULT_LIST_LIMIT = 500 + +export interface CursorImporterDeps { + /** Resolve the operator's HOME dir. Override in tests. */ + homeDir?: () => string + /** Recorded hostname (recorded into HAPI session metadata.host). */ + hostName?: () => string + /** Where to allocate the verify staging temp dir. Default: os.tmpdir(). */ + tmpDir?: () => string + /** Time source for telemetry. Default: Date.now. */ + now?: () => number + /** + * Spawn factory for the verify probe. Override in tests to inject a + * mock. The second arg is the operator's $HOME so the probe can + * resolve `agent` under `/.local/bin` even on service-account + * hub deployments. Mirrors the migrator's `createProbe` factory. + */ + createProbe?: (env: NodeJS.ProcessEnv, agentLookupHome: string) => AcpVerifyProbe + /** Override the verify per-RPC + total timeouts. */ + verifyTimeoutMs?: number + /** Logger sink. Default: silent. */ + logger?: { + debug: (msg: string, ctx?: unknown) => void + info: (msg: string, ctx?: unknown) => void + warn: (msg: string, ctx?: unknown) => void + error: (msg: string, ctx?: unknown) => void + } +} + +function noopLogger(): NonNullable { + return { debug() {}, info() {}, warn() {}, error() {} } +} + +function reverseLookupWorkspacePath(workspaceHash: string, candidatePaths: string[]): string | null { + // Cursor's drawer hash is `md5(workspacePath)`. We do not have a + // reverse map; the dialog accepts the operator-provided path on + // import for the canonical-drawer check. At discovery time we leave + // workspacePath null for legacy chats whose meta record does not + // carry one (older cursor-agent versions). + for (const path of candidatePaths) { + if (createHash('md5').update(path).digest('hex') === workspaceHash) { + return path + } + } + return null +} + +function readAcpMetaJson(metaPath: string): { schemaVersion?: number; cwd?: string; title?: string } | null { + try { + const raw = readFileSync(metaPath, 'utf-8') + const parsed = JSON.parse(raw) as Record + return { + schemaVersion: typeof parsed.schemaVersion === 'number' ? parsed.schemaVersion : undefined, + cwd: typeof parsed.cwd === 'string' ? parsed.cwd : undefined, + title: typeof parsed.title === 'string' ? parsed.title : undefined + } + } catch { + return null + } +} + +function sanityCheckStore(storeDbPath: string): { ok: true } | { ok: false; message: string } { + let db: Database | null = null + try { + db = new Database(storeDbPath, { readonly: true }) + db.query("SELECT name FROM sqlite_master WHERE type='table' LIMIT 1").get() + return { ok: true } + } catch (err) { + return { ok: false, message: err instanceof Error ? err.message : String(err) } + } finally { + try { db?.close() } catch {} + } +} + +function summarizeSession(args: { + uuid: string + storeDbPath: string + sourceFormat: CursorImportSourceFormat + workspacePath: string | null + title: string | null + sizeBytes: number + mtimeMs: number + alreadyImportedHapiSessionId: string | null +}): CursorImportableSessionSummary { + const fallbackTitle = args.title ?? `cursor:${args.uuid.slice(0, 8)}` + return { + id: args.uuid, + title: fallbackTitle, + firstUserMessage: null, + workspacePath: args.workspacePath, + storeDbPath: args.storeDbPath, + sourceFormat: args.sourceFormat, + modifiedAt: args.mtimeMs, + sizeBytes: args.sizeBytes, + alreadyImportedHapiSessionId: args.alreadyImportedHapiSessionId + } +} + +function readMetaTitleSafe(storeDbPath: string): string | null { + const meta = readLegacyMetaLastUsedModel(storeDbPath) + return meta?.name?.trim() ? meta.name.trim() : null +} + +function buildAlreadyImportedIndex(store: Store, namespace: string): Map { + // Map cursorSessionId -> hapiSessionId for every existing cursor-flavored + // session row in this namespace. Used to flag rows the dialog should + // render as "already imported" (read-only chip). + const map = new Map() + for (const session of store.sessions.getSessionsByNamespace(namespace)) { + const metadata = session.metadata as Record | null + if (!metadata) continue + if (metadata.flavor !== 'cursor') continue + const csid = metadata.cursorSessionId + if (typeof csid === 'string' && csid.length > 0) { + map.set(csid, session.id) + } + } + return map +} + +/** + * Discover importable cursor sessions from both the legacy and ACP + * on-disk locations. Returns a deduped, mtime-sorted list capped at + * `limit` entries. ACP entries take precedence over legacy entries for + * the same uuid (a successful prior migration should not surface the + * legacy store as a separate import candidate). + */ +export function listImportableCursorSessions(options: { + store: Store + namespace: string + home: string + limit?: number + candidateWorkspacePaths?: string[] +}): CursorImportableSessionSummary[] { + const home = options.home + const limit = options.limit ?? DEFAULT_LIST_LIMIT + const alreadyImportedById = buildAlreadyImportedIndex(options.store, options.namespace) + const byUuid = new Map() + + // ACP entries first. + const acpRoot = join(home, '.cursor', 'acp-sessions') + if (existsSync(acpRoot)) { + let entries: string[] = [] + try { + entries = readdirSync(acpRoot) + } catch { + entries = [] + } + for (const uuid of entries) { + if (!CURSOR_SESSION_ID_RE.test(uuid) || uuid === '.' || uuid === '..') continue + const sessionDir = join(acpRoot, uuid) + const storeDbPath = join(sessionDir, 'store.db') + const metaPath = join(sessionDir, 'meta.json') + let stStore + try { + stStore = statSync(storeDbPath) + if (!stStore.isFile()) continue + } catch { + continue + } + const meta = readAcpMetaJson(metaPath) + const title = meta?.title ?? readMetaTitleSafe(storeDbPath) + const workspacePath = meta?.cwd ?? null + byUuid.set(uuid, summarizeSession({ + uuid, + storeDbPath, + sourceFormat: 'acp', + workspacePath, + title, + sizeBytes: stStore.size, + mtimeMs: stStore.mtimeMs, + alreadyImportedHapiSessionId: alreadyImportedById.get(uuid) ?? null + })) + } + } + + // Legacy entries — only when an ACP entry for the same uuid is absent. + const chatsRoot = join(home, '.cursor', 'chats') + if (existsSync(chatsRoot)) { + let wshEntries: string[] = [] + try { + wshEntries = readdirSync(chatsRoot) + } catch { + wshEntries = [] + } + for (const wsh of wshEntries) { + const wshDir = join(chatsRoot, wsh) + let wshStat + try { + wshStat = statSync(wshDir) + } catch { + continue + } + if (!wshStat.isDirectory()) continue + let uuidEntries: string[] = [] + try { + uuidEntries = readdirSync(wshDir) + } catch { + continue + } + for (const uuid of uuidEntries) { + if (!CURSOR_SESSION_ID_RE.test(uuid) || uuid === '.' || uuid === '..') continue + if (byUuid.has(uuid)) continue // ACP entry already covers this uuid + const storeDbPath = join(wshDir, uuid, 'store.db') + let st + try { + st = statSync(storeDbPath) + if (!st.isFile()) continue + } catch { + continue + } + const title = readMetaTitleSafe(storeDbPath) + const workspacePath = reverseLookupWorkspacePath(wsh, options.candidateWorkspacePaths ?? []) + byUuid.set(uuid, summarizeSession({ + uuid, + storeDbPath, + sourceFormat: 'legacy', + workspacePath, + title, + sizeBytes: st.size, + mtimeMs: st.mtimeMs, + alreadyImportedHapiSessionId: alreadyImportedById.get(uuid) ?? null + })) + } + } + } + + return Array.from(byUuid.values()) + .sort((a, b) => b.modifiedAt - a.modifiedAt) + .slice(0, limit) +} + +function buildImportedSessionMetadata(args: { + uuid: string + workspacePath: string | null + title: string + hostName: string +}): Record { + const now = Date.now() + return { + // MetadataSchema (shared/src/schemas.ts) requires path + host. + path: args.workspacePath ?? '', + host: args.hostName, + os: platform(), + name: args.title, + summary: { text: args.title, updatedAt: now }, + flavor: 'cursor', + cursorSessionId: args.uuid, + // STRICT REFUSAL CONTRACT: any HAPI row created by this path is ACP + // from birth. Verify-probe must have passed before we reach this + // code; the cursorAcpRemoteLauncher (already shipped upstream) reads + // this protocol value to decide which backend to spawn on resume. + cursorSessionProtocol: 'acp', + lifecycleState: 'imported', + lifecycleStateSince: now + } +} + +function rmtreeSafe(path: string): void { + try { + rmSync(path, { recursive: true, force: true }) + } catch { + // best-effort + } +} + +/** + * Verify a cursor `store.db` is loadable by `agent acp` in an isolated + * `$HOME`. Mirrors the audit harness shape (scripts/audit-cursor-acp-verify.ts) + * and the migrator's `verifyInTempHome`. Returns a structured outcome + * — never throws unless the probe spawn fails in a non-recoverable way. + */ +async function verifyCursorStore(args: { + uuid: string + storeDbPath: string + cwd: string + sourceHome: string + deps: CursorImporterDeps +}): Promise<{ kind: 'ok' } | { kind: 'init_failed'; message: string } | { kind: 'load_failed'; message: string } | { kind: 'timeout'; message: string } | { kind: 'spawn_failed'; message: string }> { + const tmpDir = args.deps.tmpDir ?? (() => tmpdir()) + const verifyTimeoutMs = args.deps.verifyTimeoutMs ?? DEFAULT_VERIFY_TIMEOUT_MS + const tmpRoot = mkdtempSync(join(tmpDir(), `hapi-cursor-import-verify-${args.uuid.slice(0, 8)}-`)) + const fakeAcpSessionDir = join(tmpRoot, '.cursor', 'acp-sessions', args.uuid) + try { + mkdirSync(fakeAcpSessionDir, { recursive: true }) + copyFileSync(args.storeDbPath, join(fakeAcpSessionDir, 'store.db')) + writeFileSync( + join(fakeAcpSessionDir, 'meta.json'), + JSON.stringify({ schemaVersion: 1, cwd: tmpRoot }) + ) + // Best-effort copy auth files so session/load has credentials to + // resolve any prior `session/set_model` echo; session/load itself + // does not need auth, but stderr is quieter when present. + const realCursor = join(args.sourceHome, '.cursor') + const fakeCursor = join(tmpRoot, '.cursor') + for (const f of AUTH_FILES) { + const src = join(realCursor, f) + if (existsSync(src)) { + try { copyFileSync(src, join(fakeCursor, f)) } catch {} + } + } + + const env: NodeJS.ProcessEnv = { + ...process.env, + HOME: tmpRoot, + HAPI_HOME: tmpRoot, + NO_COLOR: '1' + } + if (process.platform === 'win32') { + env.USERPROFILE = tmpRoot + const driveMatch = /^[A-Za-z]:/.exec(tmpRoot) + if (driveMatch) { + env.HOMEDRIVE = driveMatch[0] + env.HOMEPATH = tmpRoot.slice(2) + } else { + env.HOMEDRIVE = '' + env.HOMEPATH = tmpRoot + } + } + + const probeFactory = args.deps.createProbe ?? ((env: NodeJS.ProcessEnv, agentLookupHome: string): AcpVerifyProbe => { + const opts: AcpProbeOptions = { + env, + hapiHome: tmpRoot, + agentLookupHome, + timeoutMs: DEFAULT_INIT_TIMEOUT_MS + } + return new AcpVerifyProbe(opts) + }) + const probe = probeFactory(env, args.sourceHome) + + try { + try { + probe.start() + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + return { kind: 'spawn_failed', message: msg } + } + const deadline = Date.now() + verifyTimeoutMs + const initTimeout = Math.max(1_000, Math.min(DEFAULT_INIT_TIMEOUT_MS, deadline - Date.now())) + const initResp = await probe.initialize(initTimeout) + if (!initResp.ok) { + const msg = initResp.error.message + if (/^timeout /.test(msg)) return { kind: 'timeout', message: msg } + return { kind: 'init_failed', message: msg } + } + const loadTimeout = Math.max(1_000, Math.min(DEFAULT_LOAD_TIMEOUT_MS, deadline - Date.now())) + const loadOut = await probe.loadSession( + { sessionId: args.uuid, cwd: args.cwd, mcpServers: [] }, + DEFAULT_REPLAY_DRAIN_MS, + loadTimeout + ) + if (!loadOut.response.ok) { + const msg = loadOut.response.error.message + if (/^timeout /.test(msg)) return { kind: 'timeout', message: msg } + return { kind: 'load_failed', message: msg } + } + return { kind: 'ok' } + } finally { + await probe.stop() + } + } finally { + rmtreeSafe(tmpRoot) + } +} + +function findAgentBinary(home: string): string | null { + const candidates = [ + join(home, '.local', 'bin', 'agent'), + join(home, '.npm-global', 'bin', 'agent') + ] + for (const candidate of candidates) { + if (existsSync(candidate)) return candidate + } + // Fallback: PATH lookup is handled inside AcpVerifyProbe.start; we + // only refuse here if both common install dirs miss AND the PATH + // also lacks a hit. The probe's own ENOENT becomes spawn_failed and + // we translate that to agent_binary_not_found at the call site. + const pathEnv = process.env.PATH ?? '' + const dirs = pathEnv.split(process.platform === 'win32' ? ';' : ':') + for (const dir of dirs) { + if (!dir) continue + const candidate = join(dir, process.platform === 'win32' ? 'agent.exe' : 'agent') + if (existsSync(candidate)) return candidate + } + return null +} + +/** + * Import a single cursor session. Strict ACP-only refusal: any failure + * before the HAPI row is written returns a structured outcome with no + * mutation of disk state outside the per-verify temp dir. + */ +export async function importCursorSession(options: { + uuid: string + workspacePath?: string | null + store: Store + namespace: string + home: string + getSyncEngine?: () => SyncEngine | null + deps?: CursorImporterDeps +}): Promise { + const deps = options.deps ?? {} + const now = deps.now ?? (() => Date.now()) + const hostNameFn = deps.hostName ?? (() => process.env.HAPI_HOSTNAME?.trim() || hostname()) + const log = deps.logger ?? noopLogger() + const start = now() + + const failure = (reason: CursorImportRefusalReason, message: string): CursorImportRowOutcome => ({ + ok: false, + uuid: options.uuid, + reason, + message, + durationMs: now() - start + }) + + if (!CURSOR_SESSION_ID_RE.test(options.uuid) || options.uuid === '.' || options.uuid === '..') { + return failure('missing_on_disk_store', `cursor uuid '${options.uuid}' fails basename validation`) + } + + // Pre-flight: refuse if a HAPI row in this namespace already references this uuid. + const existing = buildAlreadyImportedIndex(options.store, options.namespace) + const alreadyHapi = existing.get(options.uuid) + if (alreadyHapi) { + return failure('already_imported', `cursor session ${options.uuid} is already imported as Hapi session ${alreadyHapi}`) + } + + // Probe disk for source format. Prefer ACP over legacy when both exist + // (a prior successful migration removes the legacy source, but a + // --keep-source migration leaves both — treat the ACP entry as canonical). + const acpSessionDir = join(options.home, '.cursor', 'acp-sessions', options.uuid) + const acpStorePath = join(acpSessionDir, 'store.db') + const acpMetaPath = join(acpSessionDir, 'meta.json') + let sourceFormat: CursorImportSourceFormat + let sourceStorePath: string + let resolvedWorkspacePath: string | null = options.workspacePath ?? null + if (existsSync(acpStorePath)) { + sourceFormat = 'acp' + sourceStorePath = acpStorePath + if (!resolvedWorkspacePath) { + const meta = readAcpMetaJson(acpMetaPath) + resolvedWorkspacePath = meta?.cwd ?? null + } + } else { + const legacy = listLegacyChatStoreCandidates(options.uuid, options.home) + if (legacy.length === 0) { + return failure('missing_on_disk_store', `~/.cursor/{chats,acp-sessions} contains no store.db for uuid ${options.uuid} (looked under ${options.home})`) + } + if (legacy.length > 1 && !resolvedWorkspacePath) { + const summary = legacy.map((c) => `${c.workspaceHash} (size=${c.sizeBytes}, mtimeMs=${c.mtimeMs})`).join('; ') + return failure('ambiguous_legacy_store', `cursor session ${options.uuid} exists in ${legacy.length} workspace-hash drawers; resolve by providing workspacePath. Candidates: ${summary}`) + } + if (legacy.length === 1) { + sourceFormat = 'legacy' + sourceStorePath = legacy[0].storeDbPath + } else { + const canonicalHash = createHash('md5').update(resolvedWorkspacePath!).digest('hex') + const picked = legacy.find((c) => c.workspaceHash === canonicalHash) + if (!picked) { + const summary = legacy.map((c) => `${c.workspaceHash} (size=${c.sizeBytes}, mtimeMs=${c.mtimeMs})`).join('; ') + return failure('ambiguous_legacy_store', `cursor session ${options.uuid}: provided workspacePath did not resolve to any of the on-disk drawers. Candidates: ${summary}`) + } + sourceFormat = 'legacy' + sourceStorePath = picked.storeDbPath + } + } + + // Cheap sanity: store.db opens as SQLite + has at least one table. + // Avoids spending a verify spawn on a corrupted/truncated file. + const sanity = sanityCheckStore(sourceStorePath) + if (!sanity.ok) { + return failure('corrupted_store', `cursor session ${options.uuid}: ${sanity.message}`) + } + + // For legacy: refuse if the ACP target dir already exists (race or partial prior import). + if (sourceFormat === 'legacy' && existsSync(acpSessionDir)) { + return failure('target_already_exists', `~/.cursor/acp-sessions/${options.uuid}/ already exists; refusing to overwrite`) + } + + // Pre-flight: refuse early if the `agent` binary is not findable. The + // probe would otherwise spawn_failed with ENOENT; this hint is + // cleaner for the operator's "fix your PATH" toast. + if (!findAgentBinary(options.home)) { + const pathHint = process.env.PATH ?? '' + return failure('agent_binary_not_found', `\`agent\` binary not found under ${options.home}/.local/bin, ${options.home}/.npm-global/bin, or PATH (${pathHint.length > 0 ? pathHint : ''})`) + } + + // Verify-probe against an isolated $HOME. STRICT REFUSAL CONTRACT: + // any non-ok outcome aborts before creating a HAPI row. + const verifyCwd = resolvedWorkspacePath && resolvedWorkspacePath.length > 0 + ? resolvedWorkspacePath + : options.home + const verifyOut = await verifyCursorStore({ + uuid: options.uuid, + storeDbPath: sourceStorePath, + cwd: verifyCwd, + sourceHome: options.home, + deps + }) + if (verifyOut.kind === 'spawn_failed') { + // ENOENT here is the agent_binary_not_found case; non-ENOENT is + // internal_error because the binary existed in pre-flight but + // could not be spawned. + if (/ENOENT|not found|could not be spawned/i.test(verifyOut.message)) { + return failure('agent_binary_not_found', verifyOut.message) + } + return failure('internal_error', `verify-probe spawn failed: ${verifyOut.message}`) + } + if (verifyOut.kind === 'init_failed') { + return failure('verify_load_failed', `agent acp initialize failed: ${verifyOut.message}`) + } + if (verifyOut.kind === 'load_failed') { + return failure('verify_load_failed', `agent acp session/load failed: ${verifyOut.message}`) + } + if (verifyOut.kind === 'timeout') { + return failure('verify_timeout', verifyOut.message) + } + + // Verify passed. For legacy sessions, transplant store.db → ACP dir. + // Mirrors the migrator's atomic-mkdir + 0o700 mode + 0o600 store.db + // mode (see cursorLegacyMigrator.migrateOneWithLock) — these + // permissions matter on multi-user hosts where ~/.cursor is not + // owner-private. + if (sourceFormat === 'legacy') { + try { + mkdirSync(join(options.home, '.cursor', 'acp-sessions'), { recursive: true }) + try { + mkdirSync(acpSessionDir, { recursive: false, mode: 0o700 }) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + if (/EEXIST/.test(msg)) { + return failure('target_already_exists', `~/.cursor/acp-sessions/${options.uuid}/ already exists (race with concurrent import); refusing to overwrite`) + } + throw err + } + copyFileSync(sourceStorePath, join(acpSessionDir, 'store.db')) + try { chmodSync(join(acpSessionDir, 'store.db'), 0o600) } catch {} + const titleFromMeta = readMetaTitleSafe(sourceStorePath) + const sidecar: Record = { + schemaVersion: 1, + cwd: resolvedWorkspacePath ?? options.home + } + if (titleFromMeta) sidecar.title = titleFromMeta + writeFileSync(join(acpSessionDir, 'meta.json'), JSON.stringify(sidecar), { mode: 0o600 }) + log.info('[cursor-import] transplanted legacy store to ACP location', { + uuid: options.uuid, + acpStorePath: join(acpSessionDir, 'store.db') + }) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + // Rollback our partial dir. + rmtreeSafe(acpSessionDir) + return failure('internal_error', `failed to place ACP session dir: ${msg}`) + } + } + + // Create the HAPI session row. The strict-ACP contract is now satisfied + // (verify passed AND, for legacy, transplant succeeded), so this row + // is ACP from birth — no stream-json HAPI row was ever a possibility. + const title = readMetaTitleSafe(join(acpSessionDir, 'store.db')) ?? readMetaTitleSafe(sourceStorePath) ?? `cursor:${options.uuid.slice(0, 8)}` + const metadata = buildImportedSessionMetadata({ + uuid: options.uuid, + workspacePath: resolvedWorkspacePath, + title, + hostName: hostNameFn() + }) + let hapiSessionId: string + try { + const engine = options.getSyncEngine?.() ?? null + const created = engine?.getOrCreateSession(randomUUID(), metadata, {}, options.namespace) + ?? options.store.sessions.getOrCreateSession(randomUUID(), metadata, {}, options.namespace) + hapiSessionId = created.id + log.info('[cursor-import] created Hapi session for cursor uuid', { + uuid: options.uuid, + hapiSessionId, + sourceFormat + }) + } catch (err) { + // Roll back the transplant if we did one but the HAPI row write failed. + if (sourceFormat === 'legacy') { + rmtreeSafe(acpSessionDir) + } + return failure('internal_error', `failed to create Hapi session row: ${err instanceof Error ? err.message : String(err)}`) + } + + return { + ok: true, + uuid: options.uuid, + hapiSessionId, + sourceFormat, + durationMs: now() - start + } +} + +/** + * Batch-import wrapper: each row's outcome is independent — one failing + * does not abort the batch. Mirrors the codex importer's + * `importSelectedCodexSessions` shape so the dialog can render per-row + * results uniformly. + */ +export async function importSelectedCursorSessions(options: { + uuids: string[] + workspacePath?: string | null + store: Store + namespace: string + home: string + getSyncEngine?: () => SyncEngine | null + deps?: CursorImporterDeps +}): Promise<{ results: CursorImportRowOutcome[]; importedCount: number }> { + const results: CursorImportRowOutcome[] = [] + for (const uuid of options.uuids) { + const outcome = await importCursorSession({ + uuid, + workspacePath: options.workspacePath, + store: options.store, + namespace: options.namespace, + home: options.home, + getSyncEngine: options.getSyncEngine, + deps: options.deps + }) + results.push(outcome) + } + const importedCount = results.filter((r) => r.ok).length + return { results, importedCount } +} diff --git a/hub/src/store/sessionStore.ts b/hub/src/store/sessionStore.ts index 0e18a859dd..469c47eaa8 100644 --- a/hub/src/store/sessionStore.ts +++ b/hub/src/store/sessionStore.ts @@ -11,6 +11,7 @@ import { setSessionEffort, setSessionModel, setSessionModelReasoningEffort, + setImportedSessionActivity, setSessionServiceTier, setSessionTeamState, setSessionTodos, @@ -90,6 +91,10 @@ export class SessionStore { return touchSessionUpdatedAt(this.db, id, updatedAt, namespace) } + setImportedSessionActivity(id: string, updatedAt: number, namespace: string): boolean { + return setImportedSessionActivity(this.db, id, updatedAt, namespace) + } + getSession(id: string): StoredSession | null { return getSession(this.db, id) } diff --git a/hub/src/store/sessions.ts b/hub/src/store/sessions.ts index 537216c0ca..2a362ba626 100644 --- a/hub/src/store/sessions.ts +++ b/hub/src/store/sessions.ts @@ -540,6 +540,38 @@ export function touchSessionUpdatedAt( } } +// 中文注释:transcript 导入新建会话时,会话出生时间是 Date.now()(今天),而真实最后活动在历史里。 +// touchSessionUpdatedAt 是“只前进不后退”的活跃刷新,保护在线会话不被陈旧事件拉回过去,因此无法把 +// 导入会话的 updated_at 调回历史。这里提供一个仅供导入路径使用的无条件 setter,把刚建好的导入会话 +// 的 updated_at 设成真实最后活动时间,避免历史会话在列表里被排成“今天刚活跃”。 +export function setImportedSessionActivity( + db: Database, + id: string, + updatedAt: number, + namespace: string +): boolean { + if (!Number.isFinite(updatedAt)) { + return false + } + try { + const result = db.prepare(` + UPDATE sessions + SET updated_at = @updated_at, + seq = seq + 1 + WHERE id = @id + AND namespace = @namespace + `).run({ + id, + namespace, + updated_at: updatedAt + }) + + return result.changes === 1 + } catch { + return false + } +} + export function getSession(db: Database, id: string): StoredSession | null { const row = db.prepare('SELECT * FROM sessions WHERE id = ?').get(id) as DbSessionRow | undefined return row ? toStoredSession(row) : null diff --git a/hub/src/web/routes/_agentImport/types.ts b/hub/src/web/routes/_agentImport/types.ts new file mode 100644 index 0000000000..bf2deb7ff6 --- /dev/null +++ b/hub/src/web/routes/_agentImport/types.ts @@ -0,0 +1,118 @@ +/** + * Shared types for the multi-flavor agent-session import surface. + * + * The codex flavor (web/src/components/CodexSessionSyncDialog.tsx + + * hub/src/web/routes/codexDesktop.ts) already shipped upstream in + * `tiann/hapi#796`. This module factors out the small surface the dialog + * needs to render a per-flavor row generically so the cursor flavor (and + * future claude / gemini / opencode flavors) can reuse the same UI shape + * without one-off type drift. + * + * Hub-side parallel routes (`/codex/*` and `/cursor/*`) remain alongside + * each other rather than being collapsed into a generic + * `/api/agent-sessions/...` so each flavor keeps its own refusal vocabulary + * and row shape. + */ + +/** Agent flavors supported by the import dialog. */ +export type AgentImportFlavor = 'codex' | 'cursor' + +/** + * Source format of an importable cursor session as discovered on disk. + * + * The strict refusal contract (see `cursorImporter`) only ships ACP-mode + * HAPI rows. `legacy` sessions are transplanted via the `agent acp` + * verify-probe before being given a HAPI row; if the probe refuses, the + * row is never created and the legacy `store.db` is not touched. + * + * `acp` sessions are imported by reading the existing + * `~/.cursor/acp-sessions//` directory directly (no transplant). + */ +export type CursorImportSourceFormat = 'legacy' | 'acp' + +/** + * Mirrors `tiann/hapi#824`'s `CursorMigrateRefusalReason` (defined in + * shared/src/apiTypes.ts) plus the few import-only cases that the + * migrator does not produce because it always operates on a pre-existing + * HAPI session. + */ +export type CursorImportRefusalReason = + | 'verify_load_failed' + | 'missing_on_disk_store' + | 'target_already_exists' + | 'already_imported' + | 'agent_binary_not_found' + | 'verify_timeout' + | 'corrupted_store' + | 'ambiguous_legacy_store' + | 'internal_error' + +/** Per-row metadata returned by `GET /api/cursor/importable-sessions`. */ +export interface CursorImportableSessionSummary { + /** The cursor sessionId (UUID-ish basename of the on-disk dir). */ + id: string + /** Best-effort display title — chat name from meta record, or "Untitled". */ + title: string + /** First user message from the store, when readable. */ + firstUserMessage?: string | null + /** Absolute workspace path the chat was opened against. */ + workspacePath?: string | null + /** Absolute path of the on-disk `store.db` we read this row from. */ + storeDbPath: string + /** Source format: legacy needs verify+transplant; acp imports as-is. */ + sourceFormat: CursorImportSourceFormat + /** mtime of the on-disk `store.db`. */ + modifiedAt: number + /** Size of the on-disk `store.db` in bytes. */ + sizeBytes: number + /** + * Set to the HAPI sessionId when a HAPI session row in this namespace + * already references this cursor uuid. Dialog renders such rows as + * read-only chips ("already imported") and refuses to re-import. + */ + alreadyImportedHapiSessionId?: string | null +} + +export interface CursorImportableSessionsResponse { + success: true + sessions: CursorImportableSessionSummary[] +} + +/** Per-row import result. The `import` endpoint returns one of these per uuid. */ +export type CursorImportRowOutcome = + | { + ok: true + uuid: string + hapiSessionId: string + sourceFormat: CursorImportSourceFormat + durationMs: number + } + | { + ok: false + uuid: string + reason: CursorImportRefusalReason + message: string + durationMs: number + } + +export interface CursorImportRequest { + /** + * One or more cursor uuids to import. Multi-select mirrors codex but + * each row's outcome is independent (one failing does not abort the + * batch). + */ + uuids: string[] + /** + * Optional explicit workspace path. Used by the ambiguity check + * (`workspaceHashFromPath` in `cursorLegacyMigrator`) when the same + * uuid exists in multiple `` drawers. If omitted, the row's own + * `workspacePath` (from discovery) is used. + */ + workspacePath?: string | null +} + +export interface CursorImportResponse { + success: true + results: CursorImportRowOutcome[] + importedCount: number +} diff --git a/hub/src/web/routes/claudeDesktop.test.ts b/hub/src/web/routes/claudeDesktop.test.ts new file mode 100644 index 0000000000..6f86b166e9 --- /dev/null +++ b/hub/src/web/routes/claudeDesktop.test.ts @@ -0,0 +1,342 @@ +import { afterEach, describe, expect, it } from 'bun:test' +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Hono } from 'hono' +import { AGENT_MESSAGE_PAYLOAD_TYPE } from '@hapi/protocol' +import { Store } from '../../store' +import type { SyncEngine } from '../../sync/syncEngine' +import type { WebAppEnv } from '../middleware/auth' +import { createClaudeDesktopRoutes, importSelectedClaudeSessions, listLocalClaudeSessions } from './claudeDesktop' + +const originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR + +// 中文注释:写一条“内容丰富”的 Claude transcript,覆盖 user 字符串 / assistant text+thinking+tool_use / user tool_result,并夹杂若干 sidecar。 +function createRichTranscript(claudeHome: string, sessionId: string, encodedCwd = '-home-user-project', cwd: string | null = '/home/user/project'): void { + const projectDir = join(claudeHome, 'projects', encodedCwd) + mkdirSync(projectDir, { recursive: true }) + const transcriptPath = join(projectDir, `${sessionId}.jsonl`) + const lines: unknown[] = [ + // sidecar lines: must be skipped + { type: 'last-prompt', prompt: 'ignored' }, + { type: 'mode', mode: 'default' }, + { type: 'attachment', sessionId, content: 'ignored' }, + // isMeta user line: skipped + { type: 'user', isMeta: true, sessionId, cwd, message: { role: 'user', content: 'ignored' } }, + // real user message (string content) + { type: 'user', sessionId, cwd, message: { role: 'user', content: 'hello claude' } }, + // assistant with thinking + text + tool_use + { + type: 'assistant', + sessionId, + cwd, + message: { + role: 'assistant', + content: [ + { type: 'thinking', thinking: 'let me think' }, + { type: 'text', text: 'working on it' }, + { type: 'tool_use', id: 'toolu_1', name: 'Read', input: { file_path: '/tmp/x' } } + ] + } + }, + // user line carrying a tool_result block + { + type: 'user', + sessionId, + cwd, + message: { + role: 'user', + content: [ + { tool_use_id: 'toolu_1', type: 'tool_result', content: [{ type: 'text', text: 'file contents' }] } + ] + } + }, + // assistant final text + { type: 'assistant', sessionId, cwd, message: { role: 'assistant', content: [{ type: 'text', text: 'done' }] } }, + // more sidecar + { type: 'ai-title', title: 'some title' } + ] + writeFileSync(transcriptPath, `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`, 'utf-8') +} + +// 中文注释:写一条带逐行 `timestamp` 的最小 Claude transcript,用于断言导入后保留原始时间戳而不是被盖成 now。 +function createTimestampedTranscript( + claudeHome: string, + sessionId: string, + timestamps: { user: string; assistant: string }, + encodedCwd = '-home-user-ts', + cwd: string | null = '/home/user/ts' +): void { + const projectDir = join(claudeHome, 'projects', encodedCwd) + mkdirSync(projectDir, { recursive: true }) + const transcriptPath = join(projectDir, `${sessionId}.jsonl`) + const lines: unknown[] = [ + { type: 'user', sessionId, cwd, timestamp: timestamps.user, message: { role: 'user', content: 'hello from the past' } }, + { type: 'assistant', sessionId, cwd, timestamp: timestamps.assistant, message: { role: 'assistant', content: [{ type: 'text', text: 'replying from the past' }] } } + ] + writeFileSync(transcriptPath, `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`, 'utf-8') +} + +function createSidecarOnlyTranscript(claudeHome: string, sessionId: string, encodedCwd = '-home-user-empty'): void { + const projectDir = join(claudeHome, 'projects', encodedCwd) + mkdirSync(projectDir, { recursive: true }) + const transcriptPath = join(projectDir, `${sessionId}.jsonl`) + const lines: unknown[] = [ + { type: 'last-prompt', prompt: 'ignored' }, + { type: 'mode', mode: 'default' }, + { type: 'ai-title', title: 'no real conversation' }, + { type: 'user', isMeta: true, sessionId, message: { role: 'user', content: 'ignored' } } + ] + writeFileSync(transcriptPath, `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`, 'utf-8') +} + +function createRoutesApp(namespace: string, store: Store): Hono { + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', namespace) + await next() + }) + app.route('/api', createClaudeDesktopRoutes({ + store, + getSyncEngine: () => null + })) + return app +} + +describe('Claude Desktop import routes', () => { + afterEach(() => { + if (originalClaudeConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR + } else { + process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir + } + }) + + it('maps user/assistant text, thinking, tool_use and tool_result, skipping sidecar lines', async () => { + const claudeHome = mkdtempSync(join(tmpdir(), 'hapi-claude-home-test-')) + const store = new Store(':memory:') + const sessionId = '11111111-1111-4111-8111-111111111111' + process.env.CLAUDE_CONFIG_DIR = claudeHome + + try { + createRichTranscript(claudeHome, sessionId) + + const result = await importSelectedClaudeSessions({ + claudeSessionIds: [sessionId], + store, + namespace: 'default', + getSyncEngine: () => null + }) + + expect(result.success).toBe(true) + const session = store.sessions.getSessionsByNamespace('default')[0] + expect(session).toBeDefined() + expect(session.metadata).toMatchObject({ + flavor: 'claude', + claudeSessionId: sessionId, + path: '/home/user/project', + lifecycleState: 'imported' + }) + + const messages = store.messages.getAllMessages(session.id) + // user(string), thinking, text, tool_use, tool_result, final text => 6 + expect(messages).toHaveLength(6) + + expect(messages[0].content).toEqual({ + role: 'user', + content: { type: 'text', text: 'hello claude' }, + meta: { sentFrom: 'cli' } + }) + expect((messages[1].content as { content: { data: unknown } }).content.data).toMatchObject({ + type: 'reasoning', + message: 'let me think' + }) + expect((messages[2].content as { content: { data: unknown } }).content.data).toMatchObject({ + type: 'message', + message: 'working on it' + }) + expect((messages[3].content as { content: { data: unknown } }).content.data).toMatchObject({ + type: 'tool-call', + name: 'Read', + callId: 'toolu_1', + input: { file_path: '/tmp/x' } + }) + expect((messages[4].content as { content: { data: { type: string; callId: string } } }).content.data).toMatchObject({ + type: 'tool-call-result', + callId: 'toolu_1' + }) + expect((messages[5].content as { content: { data: unknown } }).content.data).toMatchObject({ + type: 'message', + message: 'done' + }) + expect(messages[2].content).toMatchObject({ + content: { type: AGENT_MESSAGE_PAYLOAD_TYPE } + }) + } finally { + store.close() + rmSync(claudeHome, { recursive: true, force: true }) + } + }) + + it('is idempotent: re-importing the same session adds no duplicate session or messages', async () => { + const claudeHome = mkdtempSync(join(tmpdir(), 'hapi-claude-home-idem-test-')) + const store = new Store(':memory:') + const sessionId = '22222222-2222-4222-8222-222222222222' + process.env.CLAUDE_CONFIG_DIR = claudeHome + + try { + createRichTranscript(claudeHome, sessionId) + + const first = await importSelectedClaudeSessions({ + claudeSessionIds: [sessionId], + store, + namespace: 'default', + getSyncEngine: () => null + }) + expect(first.success).toBe(true) + + const sessionsAfterFirst = store.sessions.getSessionsByNamespace('default') + expect(sessionsAfterFirst).toHaveLength(1) + const messagesAfterFirst = store.messages.getAllMessages(sessionsAfterFirst[0].id).length + + const second = await importSelectedClaudeSessions({ + claudeSessionIds: [sessionId], + store, + namespace: 'default', + getSyncEngine: () => null + }) + expect(second.success).toBe(true) + + const sessionsAfterSecond = store.sessions.getSessionsByNamespace('default') + expect(sessionsAfterSecond).toHaveLength(1) + expect(sessionsAfterSecond[0].id).toBe(sessionsAfterFirst[0].id) + const messagesAfterSecond = store.messages.getAllMessages(sessionsAfterSecond[0].id).length + expect(messagesAfterSecond).toBe(messagesAfterFirst) + } finally { + store.close() + rmSync(claudeHome, { recursive: true, force: true }) + } + }) + + it('preserves the original record timestamps as message createdAt/invokedAt and session updatedAt', async () => { + const claudeHome = mkdtempSync(join(tmpdir(), 'hapi-claude-home-ts-test-')) + const store = new Store(':memory:') + const sessionId = '55555555-5555-4555-8555-555555555555' + process.env.CLAUDE_CONFIG_DIR = claudeHome + + const userTs = '2026-01-02T03:04:05.000Z' + const assistantTs = '2026-01-02T03:05:06.000Z' + const userMs = Date.parse(userTs) + const assistantMs = Date.parse(assistantTs) + + try { + createTimestampedTranscript(claudeHome, sessionId, { user: userTs, assistant: assistantTs }) + + const before = Date.now() + const result = await importSelectedClaudeSessions({ + claudeSessionIds: [sessionId], + store, + namespace: 'default', + getSyncEngine: () => null + }) + expect(result.success).toBe(true) + + const session = store.sessions.getSessionsByNamespace('default')[0] + const messages = store.messages.getAllMessages(session.id) + expect(messages).toHaveLength(2) + + // 中文注释:核心断言——落库时间是 transcript 原始时间戳,而不是导入瞬间的 Date.now()。 + expect(messages[0].createdAt).toBe(userMs) + expect(messages[0].invokedAt).toBe(userMs) + expect(messages[1].createdAt).toBe(assistantMs) + expect(messages[1].invokedAt).toBe(assistantMs) + expect(messages[0].createdAt).toBeLessThan(before) + + // 中文注释:会话最后活跃时间应反映最后一条消息的原始时间,而不是“今天刚活跃”。 + expect(session.updatedAt).toBe(assistantMs) + } finally { + store.close() + rmSync(claudeHome, { recursive: true, force: true }) + } + }) + + it('falls back to the transcript file mtime when records carry no per-line timestamp', async () => { + const claudeHome = mkdtempSync(join(tmpdir(), 'hapi-claude-home-nots-test-')) + const store = new Store(':memory:') + const sessionId = '66666666-6666-4666-8666-666666666666' + process.env.CLAUDE_CONFIG_DIR = claudeHome + + try { + // createRichTranscript 的记录没有逐行 timestamp,应回退到文件 mtime。 + createRichTranscript(claudeHome, sessionId) + const summaries = listLocalClaudeSessions() + const summary = summaries.find((s) => s.id === sessionId) + expect(summary).toBeDefined() + const fileModifiedAt = summary!.modifiedAt + + const result = await importSelectedClaudeSessions({ + claudeSessionIds: [sessionId], + store, + namespace: 'default', + getSyncEngine: () => null + }) + expect(result.success).toBe(true) + + const session = store.sessions.getSessionsByNamespace('default')[0] + const messages = store.messages.getAllMessages(session.id) + for (const message of messages) { + expect(message.createdAt).toBe(fileModifiedAt) + } + } finally { + store.close() + rmSync(claudeHome, { recursive: true, force: true }) + } + }) + + it('filters out empty / sidecar-only sessions from listing', () => { + const claudeHome = mkdtempSync(join(tmpdir(), 'hapi-claude-home-empty-test-')) + const sessionId = '33333333-3333-4333-8333-333333333333' + process.env.CLAUDE_CONFIG_DIR = claudeHome + + try { + createSidecarOnlyTranscript(claudeHome, sessionId) + const sessions = listLocalClaudeSessions() + expect(sessions).toHaveLength(0) + } finally { + rmSync(claudeHome, { recursive: true, force: true }) + } + }) + + it('lists real sessions and rejects non-default namespace', async () => { + const claudeHome = mkdtempSync(join(tmpdir(), 'hapi-claude-home-route-test-')) + const sessionId = '44444444-4444-4444-8444-444444444444' + process.env.CLAUDE_CONFIG_DIR = claudeHome + + try { + createRichTranscript(claudeHome, sessionId) + + const defaultStore = new Store(':memory:') + try { + const defaultApp = createRoutesApp('default', defaultStore) + const response = await defaultApp.request('/api/claude/sessions') + expect(response.status).toBe(200) + const body = await response.json() as { success: boolean; sessions: { id: string }[] } + expect(body.success).toBe(true) + expect(body.sessions.map((s) => s.id)).toContain(sessionId) + } finally { + defaultStore.close() + } + + const teamStore = new Store(':memory:') + try { + const teamApp = createRoutesApp('team-a', teamStore) + const denied = await teamApp.request('/api/claude/sessions') + expect(denied.status).toBe(403) + } finally { + teamStore.close() + } + } finally { + rmSync(claudeHome, { recursive: true, force: true }) + } + }) +}) diff --git a/hub/src/web/routes/claudeDesktop.ts b/hub/src/web/routes/claudeDesktop.ts new file mode 100644 index 0000000000..c4cfbe02f6 --- /dev/null +++ b/hub/src/web/routes/claudeDesktop.ts @@ -0,0 +1,434 @@ +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' +import { isAbsolute, join, resolve } from 'node:path' +import { homedir } from 'node:os' +import { Hono } from 'hono' +import type { SyncEngine } from '../../sync/syncEngine' +import type { Store } from '../../store' +import type { WebAppEnv } from '../middleware/auth' +import { + type ImportedMessage, + type ImportedMessageContent, + type ImporterAdapter, + type LocalSessionSummary, + type ScriptLaunchResponse, + type TranscriptImportData, + DEFAULT_SESSION_SCAN_LIMIT, + appendScriptLog, + asRecord, + asString, + buildImportedAgentMessage, + buildImportedUserMessage, + expandHomePath, + getDirectImportRouteContext, + importSelectedSessions, + parseImportedTimestamp, + parseSyncSessionRequest, + truncateText +} from './transcriptImport' + +type ClaudeStatusResponse = { + success: true + claudeProjectsAvailable: boolean +} + +type ClaudeLocalSessionsResponse = { + success: true + sessions: LocalSessionSummary[] +} + +const CLAUDE_SESSION_ID_KEY = 'claudeSessionId' +const CLAUDE_TRANSCRIPT_IMPORT_NAMESPACE_ERROR = 'Claude transcript import is not available outside the default namespace' + +function resolveLocalPath(pathValue: string): string { + return isAbsolute(pathValue) ? pathValue : resolve(process.cwd(), pathValue) +} + +function getClaudeHome(): string { + // 中文注释:与 codex 的 getCodexHome 对称;优先 CLAUDE_CONFIG_DIR,否则回退 ~/.claude。 + const configured = process.env.CLAUDE_CONFIG_DIR?.trim() + return configured ? resolveLocalPath(expandHomePath(configured)) : join(homedir(), '.claude') +} + +function getClaudeProjectRoots(): string[] { + return [join(getClaudeHome(), 'projects')] +} + +function decodeProjectDirName(dirName: string): string | null { + // 中文注释:Claude 把 cwd 里的路径分隔符编码成 '-'(如 /home/ubuntu → -home-ubuntu)。 + // 由于含 '-' 的真实路径无法可靠还原,cwd 仍以 transcript 行内的 cwd 字段为准,这里只作回退用途。 + if (!dirName) return null + const decoded = dirName.replace(/-/g, '/') + return decoded.startsWith('/') ? decoded : `/${decoded}` +} + +function extractClaudeBlockText(value: unknown): string { + if (typeof value === 'string') { + return value.trim() + } + if (Array.isArray(value)) { + return value + .map((item) => { + const record = asRecord(item) + if (record?.type === 'text' && typeof record.text === 'string') return record.text + return null + }) + .filter((part): part is string => Boolean(part)) + .join(' ') + .trim() + } + return '' +} + +function isMetaUserRecord(record: Record): boolean { + // 中文注释:Claude 会写入本地命令/环境提示等 isMeta 用户行,这些不是真实对话内容,跳过。 + return record.isMeta === true +} + +function getClaudeFirstUserMessage(lines: string[]): string | null { + for (const line of lines) { + let parsed: unknown + try { + parsed = JSON.parse(line) + } catch { + continue + } + const record = asRecord(parsed) + if (!record || record.type !== 'user' || isMetaUserRecord(record)) continue + const message = asRecord(record.message) + const text = extractClaudeBlockText(message?.content) + if (text) { + return text + } + } + return null +} + +function readClaudeFields(lines: string[]): { sessionId: string | null; cwd: string | null; cliVersion: string | null } { + let sessionId: string | null = null + let cwd: string | null = null + let cliVersion: string | null = null + for (const line of lines) { + if (sessionId && cwd && cliVersion) break + let parsed: unknown + try { + parsed = JSON.parse(line) + } catch { + continue + } + const record = asRecord(parsed) + if (!record) continue + if (!sessionId && typeof record.sessionId === 'string') sessionId = record.sessionId + if (!cwd && typeof record.cwd === 'string') cwd = record.cwd + if (!cliVersion && typeof record.version === 'string') cliVersion = record.version + } + return { sessionId, cwd, cliVersion } +} + +function getClaudeSessionTitle(cwd: string | null, sessionId: string, firstUserMessage: string | null): string { + if (firstUserMessage) { + return truncateText(firstUserMessage, 80) + } + if (cwd) { + const parts = cwd.split(/[\\/]+/).filter(Boolean) + if (parts.length > 0) { + return parts[parts.length - 1] + } + } + return sessionId.slice(0, 8) +} + +function parseClaudeLocalSession(filePath: string, dirName: string): LocalSessionSummary | null { + let content: string + try { + content = readFileSync(filePath, 'utf-8') + } catch { + return null + } + + const lines = content.split(/\r?\n/).filter(Boolean) + if (lines.length === 0) { + return null + } + + const { sessionId: inlineSessionId, cwd: inlineCwd, cliVersion } = readClaudeFields(lines) + const fileSessionId = filePath.replace(/\\/g, '/').split('/').pop()?.replace(/\.jsonl$/i, '') ?? null + const sessionId = fileSessionId || inlineSessionId + if (!sessionId) { + return null + } + + const cwd = inlineCwd ?? decodeProjectDirName(dirName) + const firstUserMessage = getClaudeFirstUserMessage(lines) + + let modifiedAt = Date.now() + try { + modifiedAt = statSync(filePath).mtimeMs + } catch { + // Fall back to current time if stat fails during a concurrent file change. + } + + return { + id: sessionId, + title: getClaudeSessionTitle(cwd, sessionId, firstUserMessage), + lastUserMessage: firstUserMessage ? truncateText(firstUserMessage, 140) : null, + cwd, + file: filePath, + modifiedAt, + originator: 'claude_code', + cliVersion + } +} + +function listLocalClaudeSessions(limit = DEFAULT_SESSION_SCAN_LIMIT): LocalSessionSummary[] { + const deduped = new Map() + + for (const root of getClaudeProjectRoots()) { + if (!existsSync(root)) continue + let projectDirs + try { + projectDirs = readdirSync(root, { withFileTypes: true }) + } catch { + continue + } + for (const projectDir of projectDirs) { + if (!projectDir.isDirectory()) continue + const projectPath = join(root, projectDir.name) + let entries + try { + entries = readdirSync(projectPath, { withFileTypes: true }) + } catch { + continue + } + for (const entry of entries) { + if (!entry.isFile() || !entry.name.toLowerCase().endsWith('.jsonl')) continue + const filePath = join(projectPath, entry.name) + const session = parseClaudeLocalSession(filePath, projectDir.name) + if (!session) continue + // 中文注释:仅含 sidecar、没有任何可导入对话的会话不进入列表,避免给用户展示空壳会话。 + if (!parseClaudeTranscriptImportData(session)) continue + const previous = deduped.get(session.id) + if (!previous || previous.modifiedAt < session.modifiedAt) { + deduped.set(session.id, session) + } + } + } + } + + const sorted = Array.from(deduped.values()).sort((a, b) => b.modifiedAt - a.modifiedAt) + if (sorted.length > limit) { + // 中文注释:不静默截断;超过扫描上限时记录被截断的数量,方便排查“为什么少了会话”。 + console.warn(`[claude-import] listLocalClaudeSessions truncated ${sorted.length - limit} session(s) beyond limit=${limit}`) + } + return sorted.slice(0, limit) +} + +function convertClaudeRecordToImportedMessage(record: Record): ImportedMessageContent[] { + // 中文注释:一行 Claude 记录可能含多块(text/thinking/tool_use/tool_result),故返回数组而非单条。 + const type = asString(record.type) + const message = asRecord(record.message) + if (!type || !message) { + return [] + } + + const content = message.content + const results: ImportedMessageContent[] = [] + + if (type === 'user') { + if (isMetaUserRecord(record)) { + return [] + } + if (typeof content === 'string') { + const text = content.trim() + return text ? [buildImportedUserMessage(text)] : [] + } + if (Array.isArray(content)) { + const userTextParts: string[] = [] + for (const item of content) { + const block = asRecord(item) + if (!block) continue + const blockType = asString(block.type) + if (blockType === 'text' && typeof block.text === 'string') { + const text = block.text.trim() + if (text) userTextParts.push(text) + } else if (blockType === 'tool_result') { + const callId = asString(block.tool_use_id) + if (callId) { + results.push(buildImportedAgentMessage({ + type: 'tool-call-result', + callId, + output: block.content + })) + } + } + } + if (userTextParts.length > 0) { + // 中文注释:把用户文本拼成一条 user message,置于 tool_result 之前以保留视觉顺序。 + results.unshift(buildImportedUserMessage(userTextParts.join('\n'))) + } + return results + } + return [] + } + + if (type === 'assistant') { + if (!Array.isArray(content)) { + return [] + } + for (const item of content) { + const block = asRecord(item) + if (!block) continue + const blockType = asString(block.type) + if (blockType === 'text' && typeof block.text === 'string') { + const text = block.text.trim() + if (text) { + results.push(buildImportedAgentMessage({ type: 'message', message: text })) + } + } else if (blockType === 'thinking' && typeof block.thinking === 'string') { + const thinking = block.thinking.trim() + if (thinking) { + results.push(buildImportedAgentMessage({ type: 'reasoning', message: thinking })) + } + } else if (blockType === 'tool_use') { + const name = asString(block.name) + const callId = asString(block.id) + if (name && callId) { + results.push(buildImportedAgentMessage({ + type: 'tool-call', + name, + callId, + input: block.input + })) + } + } + } + return results + } + + // 中文注释:其余 sidecar 类型(last-prompt/mode/agent-setting/permission-mode/attachment/system/ + // file-history-snapshot/ai-title/agent-name/queue-operation 等)一律安全跳过。 + return [] +} + +function parseClaudeTranscriptImportData(summary: LocalSessionSummary): TranscriptImportData | null { + let content: string + try { + content = readFileSync(summary.file, 'utf-8') + } catch { + return null + } + + const lines = content.split(/\r?\n/).filter(Boolean) + const messages: ImportedMessage[] = [] + + for (const line of lines) { + let parsed: unknown + try { + parsed = JSON.parse(line) + } catch { + continue + } + const record = asRecord(parsed) + if (!record) continue + // 中文注释:Claude 记录在顶层 `timestamp` 带 ISO 时间串,解析出来随消息一起落库; + // 一行可拆出多块消息(text/tool_use/...),它们共用该行的时间戳。 + const createdAt = parseImportedTimestamp(record.timestamp) + for (const content of convertClaudeRecordToImportedMessage(record)) { + messages.push({ content, createdAt }) + } + } + + if (messages.length === 0) { + return null + } + + return { + ...summary, + messages + } +} + +// 中文注释:claudeAdapter 把 Claude 专属扫描/解析封装成通用 ImporterAdapter,落库/同步/去重复用 transcriptImport。 +const claudeAdapter: ImporterAdapter = { + flavor: 'claude', + sessionIdKey: CLAUDE_SESSION_ID_KEY, + listLocalSessions: (limit) => listLocalClaudeSessions(limit), + parseTranscript: (summary) => parseClaudeTranscriptImportData(summary) +} + +export async function importSelectedClaudeSessions(options: { + claudeSessionIds: string[] + store: Store + namespace: string + getSyncEngine?: () => SyncEngine | null +}): Promise { + return importSelectedSessions({ + adapter: claudeAdapter, + sessionIds: options.claudeSessionIds, + store: options.store, + namespace: options.namespace, + getSyncEngine: options.getSyncEngine + }) +} + +export { + listLocalClaudeSessions, + parseClaudeTranscriptImportData, + convertClaudeRecordToImportedMessage +} + +export function createClaudeDesktopRoutes(options: { + store: Store + getSyncEngine: () => SyncEngine | null +}): Hono { + const app = new Hono() + + app.use('/claude/*', async (c, next) => { + if (c.get('namespace') !== 'default') { + return c.json({ + success: false, + error: CLAUDE_TRANSCRIPT_IMPORT_NAMESPACE_ERROR + }, 403) + } + return next() + }) + + app.get('/claude/status', (c) => { + const available = getClaudeProjectRoots().some((root) => existsSync(root)) + return c.json({ + success: true, + claudeProjectsAvailable: available + } satisfies ClaudeStatusResponse) + }) + + app.get('/claude/sessions', (c) => { + return c.json({ + success: true, + sessions: listLocalClaudeSessions() + } satisfies ClaudeLocalSessionsResponse) + }) + + app.post('/claude/sync-session', async (c) => { + const body = await c.req.json().catch(() => null) + const parsed = parseSyncSessionRequest(body) + if (parsed.error) { + const { workspace } = getDirectImportRouteContext() + appendScriptLog(workspace, 'sync', `FAILED: ${parsed.error}`) + return c.json({ + success: false, + error: parsed.error, + cwd: workspace + }) + } + + // 中文注释:直接读取本地 Claude transcript 写入 Hapi store,复用与 Codex 相同的落库/同步/去重引擎。 + const result = await importSelectedClaudeSessions({ + claudeSessionIds: parsed.sessionIds, + store: options.store, + namespace: c.get('namespace'), + getSyncEngine: options.getSyncEngine + }) + return c.json(result) + }) + + return app +} diff --git a/hub/src/web/routes/codexDesktop.test.ts b/hub/src/web/routes/codexDesktop.test.ts index 9b4324ea3d..885572c0d2 100644 --- a/hub/src/web/routes/codexDesktop.test.ts +++ b/hub/src/web/routes/codexDesktop.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from 'bun:test' import { randomUUID } from 'node:crypto' -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtempSync, mkdirSync, rmSync, statSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Hono } from 'hono' @@ -46,6 +46,36 @@ function createTranscript(codexHome: string, sessionId: string, cwd = 'C:\\work\ writeFileSync(transcriptPath, `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`, 'utf-8') } +// 中文注释:写一条带顶层 `timestamp` 的最小 Codex rollout,用于断言导入后保留原始时间戳而不是被盖成 now。 +function createTimestampedTranscript( + codexHome: string, + sessionId: string, + timestamps: { user: string; assistant: string }, + cwd = '/home/user/ts' +): void { + const sessionDir = join(codexHome, 'sessions', '2026', '06', '04') + mkdirSync(sessionDir, { recursive: true }) + const transcriptPath = join(sessionDir, `rollout-${sessionId}.jsonl`) + const lines = [ + { + timestamp: timestamps.user, + type: 'session_meta', + payload: { id: sessionId, cwd, originator: 'codex_cli_rs', cli_version: '0.0.0-test' } + }, + { + timestamp: timestamps.user, + type: 'response_item', + payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'hello from the past' }] } + }, + { + timestamp: timestamps.assistant, + type: 'response_item', + payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'replying from the past' }] } + } + ] + writeFileSync(transcriptPath, `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`, 'utf-8') +} + function createMachine(id: string, workspaceRoots: string[], namespace = 'default'): Machine { return { id, @@ -163,6 +193,80 @@ describe('Codex Desktop import routes', () => { } }) + it('preserves the original record timestamps as message createdAt/invokedAt and session updatedAt', async () => { + const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-ts-test-')) + const store = new Store(':memory:') + const codexSessionId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + process.env.CODEX_HOME = codexHome + + const userTs = '2026-01-02T03:04:05.000Z' + const assistantTs = '2026-01-02T03:05:06.000Z' + const userMs = Date.parse(userTs) + const assistantMs = Date.parse(assistantTs) + + try { + createTimestampedTranscript(codexHome, codexSessionId, { user: userTs, assistant: assistantTs }) + + const before = Date.now() + const result = await importSelectedCodexSessions({ + codexSessionIds: [codexSessionId], + store, + namespace: 'default', + getSyncEngine: () => null + }) + expect(result.success).toBe(true) + + const session = store.sessions.getSessionsByNamespace('default')[0] + const messages = store.messages.getAllMessages(session.id) + expect(messages).toHaveLength(2) + + // 中文注释:核心断言——落库时间是 rollout 记录的原始时间戳,而不是导入瞬间的 Date.now()。 + expect(messages[0].createdAt).toBe(userMs) + expect(messages[0].invokedAt).toBe(userMs) + expect(messages[1].createdAt).toBe(assistantMs) + expect(messages[1].invokedAt).toBe(assistantMs) + expect(messages[0].createdAt).toBeLessThan(before) + + // 中文注释:会话最后活跃时间应反映最后一条消息的原始时间,而不是“今天刚活跃”。 + expect(session.updatedAt).toBe(assistantMs) + } finally { + store.close() + rmSync(codexHome, { recursive: true, force: true }) + } + }) + + it('falls back to the transcript file mtime when records carry no per-line timestamp', async () => { + const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-nots-test-')) + const store = new Store(':memory:') + const codexSessionId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' + process.env.CODEX_HOME = codexHome + + try { + // createTranscript 的记录没有顶层 timestamp,应回退到文件 mtime。 + createTranscript(codexHome, codexSessionId) + const transcriptPath = join(codexHome, 'sessions', '2026', '06', '04', `rollout-${codexSessionId}.jsonl`) + const fileModifiedAt = statSync(transcriptPath).mtimeMs + + const result = await importSelectedCodexSessions({ + codexSessionIds: [codexSessionId], + store, + namespace: 'default', + getSyncEngine: () => null + }) + expect(result.success).toBe(true) + + const session = store.sessions.getSessionsByNamespace('default')[0] + const messages = store.messages.getAllMessages(session.id) + expect(messages.length).toBeGreaterThan(0) + for (const message of messages) { + expect(message.createdAt).toBe(fileModifiedAt) + } + } finally { + store.close() + rmSync(codexHome, { recursive: true, force: true }) + } + }) + it('binds imported transcripts to the unique online machine that owns the cwd', async () => { const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-machine-test-')) const store = new Store(':memory:') diff --git a/hub/src/web/routes/codexDesktop.ts b/hub/src/web/routes/codexDesktop.ts index d5723f4025..d6afc5ebe0 100644 --- a/hub/src/web/routes/codexDesktop.ts +++ b/hub/src/web/routes/codexDesktop.ts @@ -1,46 +1,42 @@ -import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync } from 'node:fs' +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' import { spawn, spawnSync } from 'node:child_process' import { randomUUID } from 'node:crypto' import { dirname, isAbsolute, join, resolve } from 'node:path' -import { homedir, hostname, platform } from 'node:os' -import { AGENT_MESSAGE_PAYLOAD_TYPE } from '@hapi/protocol' +import { homedir } from 'node:os' import { Hono } from 'hono' -import type { Machine, SyncEngine } from '../../sync/syncEngine' -import type { Store, StoredMessage } from '../../store' +import type { SyncEngine } from '../../sync/syncEngine' +import type { Store } from '../../store' import type { WebAppEnv } from '../middleware/auth' +import { + type ImportedMessage, + type ImportedMessageContent, + type ImporterAdapter, + type LocalSessionSummary, + type ScriptLaunchResponse, + type TranscriptImportData, + DEFAULT_SESSION_SCAN_LIMIT, + NO_SYNC_SESSION_SELECTED_ERROR, + appendScriptLog, + asRecord, + asString, + buildImportedAgentMessage, + buildImportedUserMessage, + getDirectImportRouteContext, + importSelectedSessions, + listDuplicateSessionGroups, + mergeDuplicateSessionGroups, + parseImportedTimestamp, + parseSyncSessionRequest, + truncateText +} from './transcriptImport' type ScriptLogKind = 'sync' | 'restart' -const DIRECT_IMPORT_COMMAND = 'direct-import' const RESTART_SCRIPT_ENV_NAME = 'HAPI_CODEX_RESTART_SCRIPT' const RESTART_SCRIPT_DEFAULT_FILE = 'Restart-CodexDesktop.ps1' const RESTART_SCRIPT_ARGS = ['-Apply'] const RESTART_SCRIPT_MESSAGE = 'Codex Desktop restart script started' -type ScriptLaunchResponse = { - success: true - message: string - pid: number - command: string - script?: string - cwd: string - output?: string - codexDesktopRunning?: boolean - codexClientAvailable?: boolean - syncedCount?: number - sessionIds?: string[] -} | { - success: false - error: string - script?: string - cwd: string - output?: string - codexDesktopRunning?: boolean - codexClientAvailable?: boolean - syncedCount?: number - sessionIds?: string[] -} - type CodexDesktopStatus = { running: boolean clientAvailable: boolean @@ -52,68 +48,14 @@ type CodexDesktopStatusResponse = { codexClientAvailable: boolean } -type CodexLocalSessionSummary = { - id: string - title: string - lastUserMessage?: string | null - cwd?: string | null - file: string - modifiedAt: number - originator?: string | null - cliVersion?: string | null -} - type CodexLocalSessionsResponse = { success: true - sessions: CodexLocalSessionSummary[] -} - -type CodexImportedMessageContent = { - role: 'user' - content: { - type: 'text' - text: string - } - meta: { - sentFrom: 'cli' - } -} | { - role: 'agent' - content: { - type: typeof AGENT_MESSAGE_PAYLOAD_TYPE - data: unknown - } - meta: { - sentFrom: 'cli' - } -} - -type CodexTranscriptImportData = CodexLocalSessionSummary & { - messages: CodexImportedMessageContent[] -} - -type ImportCandidate = { - sessionId: string - active: boolean - updatedAt: number - metadata: Record | null -} - -type ImportTargetSelection = { - sessionId: string | null - comparablePrefixCount: number -} - -type SyncSessionRequestParseResult = { - sessionIds: string[] - error?: string + sessions: LocalSessionSummary[] } type CodexDuplicateSessionGroup = { codexSessionId: string hapiSessionIds: string[] - canonicalSessionId?: string - removedSessionIds?: string[] } type CodexDuplicateSessionsResponse = { @@ -126,24 +68,18 @@ type CodexDuplicateSessionsResponse = { type CodexMergeDuplicateSessionsResponse = { success: true - merged: CodexDuplicateSessionGroup[] + merged: { codexSessionId: string; hapiSessionIds: string[]; canonicalSessionId?: string; removedSessionIds?: string[] }[] mergedCount: number } | { success: false error: string } -type DuplicateSessionGroupCandidate = { - codexSessionId: string - sessions: ImportCandidate[] -} - +const CODEX_SESSION_ID_KEY = 'codexSessionId' const CODEX_DESKTOP_NOT_FOUND_ERROR = '尝试重启codex客户端失败,未安装/找不到codex客户端' const SCRIPT_TIMEOUT_ERROR = '执行超时' -const NO_SYNC_SESSION_SELECTED_ERROR = '未选择需要导入的 Codex 会话' const CODEX_TRANSCRIPT_IMPORT_NAMESPACE_ERROR = 'Codex transcript import is not available outside the default namespace' const DEFAULT_SCRIPT_TIMEOUT_MS = 60_000 -const DEFAULT_CODEX_SESSION_SCAN_LIMIT = 500 function resolveLocalPath(pathValue: string): string { return isAbsolute(pathValue) ? pathValue : resolve(process.cwd(), pathValue) @@ -187,11 +123,6 @@ function getWorkspace(scriptPath: string): string { return configured ? resolveLocalPath(configured) : dirname(scriptPath) } -function getDirectImportWorkspace(): string { - const configured = process.env.HAPI_CODEX_WORKSPACE?.trim() - return configured ? resolveLocalPath(configured) : process.cwd() -} - function expandHomePath(pathValue: string): string { return pathValue.replace(/^~(?=$|[\\/])/, homedir()) } @@ -228,16 +159,6 @@ function collectJsonlFiles(root: string, files: string[]): void { } } -function asRecord(value: unknown): Record | null { - return value !== null && typeof value === 'object' && !Array.isArray(value) - ? value as Record - : null -} - -function asString(value: unknown): string | null { - return typeof value === 'string' && value.length > 0 ? value : null -} - function extractCodexText(value: unknown): string { if (typeof value === 'string') { return value.trim() @@ -268,10 +189,6 @@ function extractCodexText(value: unknown): string { return '' } -function truncateText(value: string, maxLength: number): string { - return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value -} - function shouldIgnoreSyntheticUserMessage(text: string): boolean { const normalized = text.trim() return normalized.startsWith('# AGENTS.md instructions') @@ -410,7 +327,7 @@ function isSubagentSource(value: unknown): boolean { return record ? Object.prototype.hasOwnProperty.call(record, 'subagent') : false } -function parseCodexLocalSession(filePath: string): CodexLocalSessionSummary | null { +function parseCodexLocalSession(filePath: string): LocalSessionSummary | null { let content: string try { content = readFileSync(filePath, 'utf-8') @@ -493,13 +410,13 @@ function parseCodexLocalSession(filePath: string): CodexLocalSessionSummary | nu } } -function listLocalCodexSessions(limit = DEFAULT_CODEX_SESSION_SCAN_LIMIT): CodexLocalSessionSummary[] { +function listLocalCodexSessions(limit = DEFAULT_SESSION_SCAN_LIMIT): LocalSessionSummary[] { const files: string[] = [] for (const root of getCodexSessionRoots()) { collectJsonlFiles(root, files) } - const deduped = new Map() + const deduped = new Map() for (const filePath of files) { const session = parseCodexLocalSession(filePath) if (!session) continue @@ -514,33 +431,7 @@ function listLocalCodexSessions(limit = DEFAULT_CODEX_SESSION_SCAN_LIMIT): Codex .slice(0, limit) } -function buildImportedUserMessage(text: string): CodexImportedMessageContent { - return { - role: 'user', - content: { - type: 'text', - text - }, - meta: { - sentFrom: 'cli' - } - } -} - -function buildImportedAgentMessage(data: unknown): CodexImportedMessageContent { - return { - role: 'agent', - content: { - type: AGENT_MESSAGE_PAYLOAD_TYPE, - data - }, - meta: { - sentFrom: 'cli' - } - } -} - -function convertCodexRecordToImportedMessage(record: Record): CodexImportedMessageContent | null { +function convertCodexRecordToImportedMessage(record: Record): ImportedMessageContent | null { const type = asString(record.type) const payload = asRecord(record.payload) if (!type || !payload) { @@ -639,7 +530,7 @@ function convertCodexRecordToImportedMessage(record: Record): C return null } -function parseCodexTranscriptImportData(summary: CodexLocalSessionSummary): CodexTranscriptImportData | null { +function parseCodexTranscriptImportData(summary: LocalSessionSummary): TranscriptImportData | null { let content: string try { content = readFileSync(summary.file, 'utf-8') @@ -648,7 +539,7 @@ function parseCodexTranscriptImportData(summary: CodexLocalSessionSummary): Code } const lines = content.split(/\r?\n/).filter(Boolean) - const messages: CodexImportedMessageContent[] = [] + const messages: ImportedMessage[] = [] for (const line of lines) { let parsed: unknown @@ -660,9 +551,11 @@ function parseCodexTranscriptImportData(summary: CodexLocalSessionSummary): Code const record = asRecord(parsed) if (!record) continue - const message = convertCodexRecordToImportedMessage(record) - if (message) { - messages.push(message) + const content = convertCodexRecordToImportedMessage(record) + if (content) { + // 中文注释:Codex rollout 记录在顶层 `timestamp` 带 ISO 时间串(payload 内还有一份,取顶层即可), + // 随消息一起落库以保留原始时间线。 + messages.push({ content, createdAt: parseImportedTimestamp(record.timestamp) }) } } @@ -672,431 +565,27 @@ function parseCodexTranscriptImportData(summary: CodexLocalSessionSummary): Code } } -function normalizeComparablePath(pathValue: string, options?: { caseInsensitive?: boolean }): string { - let normalized = pathValue.trim().replace(/\\/g, '/').replace(/\/+/g, '/') - if (normalized.length > 1) { - normalized = normalized.replace(/\/+$/, '') - } - return options?.caseInsensitive ? normalized.toLowerCase() : normalized +// 中文注释:codexAdapter 把 Codex 专属的扫描/解析封装成通用 ImporterAdapter,落库/同步/去重交给 transcriptImport 共享引擎。 +const codexAdapter: ImporterAdapter = { + flavor: 'codex', + sessionIdKey: CODEX_SESSION_ID_KEY, + listLocalSessions: (limit) => listLocalCodexSessions(limit), + parseTranscript: (summary) => parseCodexTranscriptImportData(summary) } -function shouldCompareCaseInsensitive(...pathValues: string[]): boolean { - return pathValues.some((pathValue) => /^[a-z]:[\\/]/i.test(pathValue) || pathValue.includes('\\')) -} - -function isPathInsideWorkspaceRoot(pathValue: string, rootValue: string): boolean { - if (!pathValue.trim() || !rootValue.trim()) { - return false - } - - const caseInsensitive = shouldCompareCaseInsensitive(pathValue, rootValue) - const path = normalizeComparablePath(pathValue, { caseInsensitive }) - const root = normalizeComparablePath(rootValue, { caseInsensitive }) - if (!path || !root) { - return false - } - if (path === root) { - return true - } - if (root === '/') { - return path.startsWith('/') - } - return path.startsWith(`${root}/`) -} - -function machineOwnsCodexCwd(machine: Machine, cwd: string): boolean { - const workspaceRoots = machine.metadata?.workspaceRoots ?? [] - return workspaceRoots.some((workspaceRoot) => isPathInsideWorkspaceRoot(cwd, workspaceRoot)) -} - -function resolveImportMachineId( - cwd: string | null | undefined, - namespace: string, - engine: SyncEngine | null -): string | undefined { - if (!cwd || !engine) { - return undefined - } - - const matches = engine.getOnlineMachinesByNamespace(namespace) - .filter((machine) => machineOwnsCodexCwd(machine, cwd)) - const machineIds = Array.from(new Set(matches.map((machine) => machine.id))) - return machineIds.length === 1 ? machineIds[0] : undefined -} - -function buildImportedSessionMetadata( - data: CodexTranscriptImportData, - existingMetadata?: Record | null, - resolvedMachineId?: string -): Record { - const now = Date.now() - const path = data.cwd ?? (typeof existingMetadata?.path === 'string' ? existingMetadata.path : dirname(data.file)) - const host = typeof existingMetadata?.host === 'string' ? existingMetadata.host : (process.env.HAPI_HOSTNAME || hostname()) - const osValue = typeof existingMetadata?.os === 'string' ? existingMetadata.os : platform() - const summaryText = data.lastUserMessage ?? data.title - const machineId = typeof existingMetadata?.machineId === 'string' - ? existingMetadata.machineId - : resolvedMachineId - - return { - ...(existingMetadata ?? {}), - path, - host, - os: osValue, - name: data.title, - summary: summaryText - ? { - text: summaryText, - updatedAt: now - } - : existingMetadata?.summary, - flavor: 'codex', - codexSessionId: data.id, - ...(machineId ? { machineId } : {}), - lifecycleState: typeof existingMetadata?.lifecycleState === 'string' - ? existingMetadata.lifecycleState - : 'imported', - lifecycleStateSince: typeof existingMetadata?.lifecycleStateSince === 'number' - ? existingMetadata.lifecycleStateSince - : now - } -} - -function stableSerialize(value: unknown): string { - if (value === null || value === undefined) { - return String(value) - } - if (typeof value === 'string') { - return JSON.stringify(value) - } - if (typeof value === 'number' || typeof value === 'boolean') { - return JSON.stringify(value) - } - if (Array.isArray(value)) { - return `[${value.map((item) => stableSerialize(item)).join(',')}]` - } - if (typeof value === 'object') { - const record = value as Record - const keys = Object.keys(record).sort() - return `{${keys.map((key) => `${JSON.stringify(key)}:${stableSerialize(record[key])}`).join(',')}}` - } - return JSON.stringify(value) -} - -function normalizeComparableAgentData(value: unknown): unknown { - const record = asRecord(value) - if (!record) { - return value - } - - const normalized = { ...record } - if ('id' in normalized) { - delete normalized.id - } - return normalized -} - -function normalizeComparableContent(content: unknown): string | null { - const record = asRecord(content) - if (!record) { - return null - } - - if (record.role === 'user') { - const body = asRecord(record.content) - if (body?.type !== 'text' || typeof body.text !== 'string') { - return null - } - return stableSerialize({ - role: 'user', - text: body.text - }) - } - - if (record.role === 'agent') { - const body = asRecord(record.content) - if (!body || body.type !== AGENT_MESSAGE_PAYLOAD_TYPE) { - return null - } - return stableSerialize({ - role: 'agent', - data: normalizeComparableAgentData(body.data) - }) - } - - return null -} - -function getComparableStoredMessageKey(message: StoredMessage): string { - // 中文注释:重复会话合并时优先按标准 user/agent 结构去重;遇到非标准消息再回退到稳定序列化,确保不会遗漏相同内容。 - return normalizeComparableContent(message.content) ?? stableSerialize(message.content) -} - -function collectImportCandidates( - store: Store, - namespace: string, - getSyncEngine?: () => SyncEngine | null -): ImportCandidate[] { - const engineSessions = getSyncEngine?.()?.getSessionsByNamespace(namespace) ?? [] - if (engineSessions.length > 0) { - return engineSessions.map((session) => ({ - sessionId: session.id, - active: session.active, - updatedAt: session.updatedAt, - metadata: asRecord(session.metadata) - })) - } - - return store.sessions.getSessionsByNamespace(namespace).map((session) => ({ - sessionId: session.id, - active: session.active, - updatedAt: session.updatedAt, - metadata: asRecord(session.metadata) - })) -} - -function selectImportTargetSession( - store: Store, - candidates: ImportCandidate[], - codexSessionId: string, - importedComparableMessages: string[] -): ImportTargetSelection { - const relatedCandidates = candidates - .filter((candidate) => candidate.metadata?.codexSessionId === codexSessionId) - .sort((a, b) => b.updatedAt - a.updatedAt) - - if (relatedCandidates.some((candidate) => candidate.active)) { - throw new Error('当前会话仍处于活跃状态,请等待会话结束后重试') - } - - let bestSessionId: string | null = null - let bestPrefixCount = -1 - - for (const candidate of relatedCandidates) { - const comparableMessages = store.messages.getAllMessages(candidate.sessionId) - .map((message) => normalizeComparableContent(message.content)) - .filter((value): value is string => value !== null) - - if (comparableMessages.length > importedComparableMessages.length) { - continue - } - - let prefixMatches = true - for (let index = 0; index < comparableMessages.length; index += 1) { - if (comparableMessages[index] !== importedComparableMessages[index]) { - prefixMatches = false - break - } - } - - if (!prefixMatches) { - continue - } - - if (comparableMessages.length > bestPrefixCount) { - bestPrefixCount = comparableMessages.length - bestSessionId = candidate.sessionId - } - } - - return { - sessionId: bestSessionId, - comparablePrefixCount: Math.max(0, bestPrefixCount) - } -} - -function listDuplicateCodexSessionGroups( - store: Store, - namespace: string, - codexSessionIds: string[], - getSyncEngine?: () => SyncEngine | null -): DuplicateSessionGroupCandidate[] { - const requestedSessionIds = new Set(codexSessionIds) - if (requestedSessionIds.size === 0) { - return [] - } - - const groups = new Map() - for (const candidate of collectImportCandidates(store, namespace, getSyncEngine)) { - const codexSessionId = typeof candidate.metadata?.codexSessionId === 'string' - ? candidate.metadata.codexSessionId - : null - if (!codexSessionId || !requestedSessionIds.has(codexSessionId)) { - continue - } - - const existing = groups.get(codexSessionId) - if (existing) { - existing.push(candidate) - } else { - groups.set(codexSessionId, [candidate]) - } - } - - return Array.from(groups.entries()) - .map(([codexSessionId, sessions]) => ({ - codexSessionId, - sessions: sessions.sort((a, b) => b.updatedAt - a.updatedAt) - })) - .filter((group) => group.sessions.length > 1) -} - -async function mergeDuplicateCodexSessionGroups(options: { - store: Store - namespace: string +export async function importSelectedCodexSessions(options: { codexSessionIds: string[] - getSyncEngine?: () => SyncEngine | null -}): Promise { - const groups = listDuplicateCodexSessionGroups( - options.store, - options.namespace, - options.codexSessionIds, - options.getSyncEngine - ) - if (groups.length === 0) { - return { - success: true, - merged: [], - mergedCount: 0 - } - } - - const merged: CodexDuplicateSessionGroup[] = [] - for (const group of groups) { - const result = await mergeSingleDuplicateCodexSessionGroup({ - group, - store: options.store, - namespace: options.namespace, - getSyncEngine: options.getSyncEngine - }) - merged.push(result) - } - - return { - success: true, - merged, - mergedCount: merged.length - } -} - -async function mergeSingleDuplicateCodexSessionGroup(options: { - group: DuplicateSessionGroupCandidate store: Store namespace: string getSyncEngine?: () => SyncEngine | null -}): Promise { - const engine = options.getSyncEngine?.() ?? null - const sessionStates = options.group.sessions - .map((candidate) => ({ - ...candidate, - storedMessages: options.store.messages.getAllMessages(candidate.sessionId), - })) - .map((candidate) => ({ - ...candidate, - comparableKeys: candidate.storedMessages.map((message) => getComparableStoredMessageKey(message)) - })) - .sort((a, b) => { - if (b.comparableKeys.length !== a.comparableKeys.length) { - return b.comparableKeys.length - a.comparableKeys.length - } - if (b.updatedAt !== a.updatedAt) { - return b.updatedAt - a.updatedAt - } - return a.sessionId.localeCompare(b.sessionId) - }) - - if (sessionStates.some((candidate) => candidate.active)) { - throw new Error('当前会话仍处于活跃状态,请等待会话结束后重试') - } - - const canonical = sessionStates[0] - if (!canonical) { - throw new Error(`No duplicate Hapi session found for Codex thread: ${options.group.codexSessionId}`) - } - - const knownKeys = new Set(canonical.comparableKeys) - const removedSessionIds: string[] = [] - const appendedMessages: StoredMessage[] = [] - let latestActivity = canonical.updatedAt - - for (const source of sessionStates.slice(1)) { - latestActivity = Math.max(latestActivity, source.updatedAt) - for (const message of source.storedMessages) { - const comparableKey = getComparableStoredMessageKey(message) - if (knownKeys.has(comparableKey)) { - continue - } - - const copied = options.store.messages.copyMessageToSession(canonical.sessionId, { - content: message.content, - createdAt: message.createdAt, - localId: message.localId, - invokedAt: message.invokedAt, - scheduledAt: message.scheduledAt - }) - knownKeys.add(comparableKey) - appendedMessages.push(copied) - latestActivity = Math.max(latestActivity, copied.invokedAt ?? copied.createdAt) - } - - if (engine) { - await engine.deleteSession(source.sessionId) - } else { - const deleted = options.store.sessions.deleteSession(source.sessionId, options.namespace) - if (!deleted) { - throw new Error(`Failed to delete duplicate Hapi session: ${source.sessionId}`) - } - } - removedSessionIds.push(source.sessionId) - } - - if (appendedMessages.length > 0) { - emitImportedMessageEvents(engine, canonical.sessionId, appendedMessages) - } - - if (engine) { - engine.recordSessionActivity(canonical.sessionId, latestActivity) - // 中文注释:即使这次只是删除重复分身、没有新增消息,也主动刷新 canonical 会话,确保左侧列表立刻收敛到合并后的状态。 - engine.handleRealtimeEvent({ - type: 'session-updated', - sessionId: canonical.sessionId - }) - } else { - options.store.sessions.touchSessionUpdatedAt(canonical.sessionId, latestActivity, options.namespace) - } - - return { - codexSessionId: options.group.codexSessionId, - hapiSessionIds: sessionStates.map((candidate) => candidate.sessionId), - canonicalSessionId: canonical.sessionId, - removedSessionIds - } -} - -function emitImportedMessageEvents( - engine: SyncEngine | null, - sessionId: string, - appendedMessages: StoredMessage[] -): void { - if (!engine) { - return - } - - // 中文注释:只有追加到已有 Hapi 会话时才逐条广播新增消息,确保当前打开的会话右侧消息区能立即刷新到最新 transcript。 - for (const message of appendedMessages) { - engine.handleRealtimeEvent({ - type: 'message-received', - sessionId, - message: { - id: message.id, - seq: message.seq, - localId: message.localId ?? null, - content: message.content, - createdAt: message.createdAt, - invokedAt: message.invokedAt - } - }) - } +}): Promise { + return importSelectedSessions({ + adapter: codexAdapter, + sessionIds: options.codexSessionIds, + store: options.store, + namespace: options.namespace, + getSyncEngine: options.getSyncEngine + }) } function getPathExts(): string[] { @@ -1262,15 +751,8 @@ function createLaunchArgs(scriptPath: string, workspace: string, scriptArgs: str ] } -function appendScriptLog(workspace: string, kind: ScriptLogKind, message: string): void { - try { - const logDir = join(workspace, 'logs') - mkdirSync(logDir, { recursive: true }) - const line = `[${new Date().toISOString()}] [${kind}] ${message}\n` - appendFileSync(join(logDir, 'CodexDesktopScript.log'), line, 'utf-8') - } catch { - // Best-effort logging only; API response still carries the error. - } +function appendRestartScriptLog(workspace: string, kind: ScriptLogKind, message: string): void { + appendScriptLog(workspace, kind, message) } async function runPowerShellScript(scriptPath: string, workspace: string, scriptArgs: string[]): Promise<{ pid: number; command: string; output: string }> { @@ -1366,7 +848,7 @@ async function launchRestartScript(): Promise { const workspace = getWorkspace(scriptPath) if (!existsSync(scriptPath)) { - appendScriptLog(workspace, 'restart', `FAILED: Script not found: ${scriptPath}`) + appendRestartScriptLog(workspace, 'restart', `FAILED: Script not found: ${scriptPath}`) return { success: false, error: `Script not found: ${scriptPath}`, @@ -1376,7 +858,7 @@ async function launchRestartScript(): Promise { } if (!existsSync(workspace)) { - appendScriptLog(workspace, 'restart', `FAILED: Workspace not found: ${workspace}`) + appendRestartScriptLog(workspace, 'restart', `FAILED: Workspace not found: ${workspace}`) return { success: false, error: `Workspace not found: ${workspace}`, @@ -1388,7 +870,7 @@ async function launchRestartScript(): Promise { try { const launched = await runPowerShellScript(scriptPath, workspace, RESTART_SCRIPT_ARGS) const output = launched.output - appendScriptLog( + appendRestartScriptLog( workspace, 'restart', `SUCCESS: ${RESTART_SCRIPT_MESSAGE}; pid=${launched.pid}; command=${launched.command}; script=${scriptPath}${output ? `; output=${output}` : ''}` @@ -1404,7 +886,7 @@ async function launchRestartScript(): Promise { } } catch (error) { const message = error instanceof Error ? error.message : String(error) - appendScriptLog(workspace, 'restart', `FAILED: ${message}; script=${scriptPath}`) + appendRestartScriptLog(workspace, 'restart', `FAILED: ${message}; script=${scriptPath}`) return { success: false, error: message, @@ -1414,251 +896,6 @@ async function launchRestartScript(): Promise { } } -function parseSyncSessionRequest(body: unknown): SyncSessionRequestParseResult { - // 中文注释:导入弹窗现在直接提交 Codex thread ID;未传 body 时按“未选择会话”处理,避免再回退到旧的默认最新会话逻辑。 - if (body === null || typeof body !== 'object' || Array.isArray(body) || !('sessionIds' in body)) { - return { sessionIds: [] } - } - - const rawSessionIds = (body as { sessionIds?: unknown }).sessionIds - if (!Array.isArray(rawSessionIds)) { - return { sessionIds: [], error: 'Invalid sessionIds' } - } - - const sessionIds: string[] = [] - for (const value of rawSessionIds) { - if (typeof value !== 'string') { - return { sessionIds: [], error: 'Invalid sessionIds' } - } - const trimmed = value.trim() - if (trimmed) { - sessionIds.push(trimmed) - } - } - - // 中文注释:前端允许多选,这里按 Codex thread 去重,避免重复导入同一条本地 transcript。 - return { sessionIds: Array.from(new Set(sessionIds)) } -} - -function combineSyncOutputs(results: ScriptLaunchResponse[]): string | undefined { - const output = results - .map((result, index) => { - // 中文注释:direct import 不再依赖隐藏脚本;这里把每个会话的导入摘要拼成一段文本,便于前端或日志统一查看。 - const detail = result.success ? (result.output ?? '') : (result.output ?? result.error) - return detail ? `[${index + 1}] ${detail}` : '' - }) - .filter(Boolean) - .join('\n\n') - .trim() - return output || undefined -} - -function getDirectImportRouteContext(): { workspace: string } { - return { - workspace: getDirectImportWorkspace() - } -} - -function createImportErrorResponse( - codexSessionIds: string[], - error: string, - syncedCount = 0 -): ScriptLaunchResponse { - const { workspace } = getDirectImportRouteContext() - appendScriptLog(workspace, 'sync', `FAILED: ${error}; sessionIds=${codexSessionIds.join(',') || '(none)'}`) - return { - success: false, - error, - cwd: workspace, - sessionIds: codexSessionIds, - syncedCount - } -} - -function createImportSuccessResponse( - codexSessionIds: string[], - results: ScriptLaunchResponse[] -): ScriptLaunchResponse { - const { workspace } = getDirectImportRouteContext() - appendScriptLog( - workspace, - 'sync', - `SUCCESS: imported ${results.length} Codex session(s); sessionIds=${codexSessionIds.join(',')}` - ) - return { - success: true, - message: `Imported ${results.length} Codex session(s) into Hapi`, - pid: 0, - command: DIRECT_IMPORT_COMMAND, - cwd: workspace, - output: combineSyncOutputs(results), - sessionIds: codexSessionIds, - syncedCount: results.length - } -} - -function importSingleCodexSession(options: { - codexSessionId: string - localSessionsById: Map - store: Store - namespace: string - getSyncEngine?: () => SyncEngine | null -}): ScriptLaunchResponse { - const summary = options.localSessionsById.get(options.codexSessionId) - if (!summary) { - return { - ...createImportErrorResponse([options.codexSessionId], `Transcript not found for Codex session: ${options.codexSessionId}`), - output: `未找到对应的本地 transcript:${options.codexSessionId}` - } - } - - const transcript = parseCodexTranscriptImportData(summary) - if (!transcript) { - return { - ...createImportErrorResponse([options.codexSessionId], `Failed to parse Codex transcript: ${summary.file}`), - output: `解析 transcript 失败:${summary.file}` - } - } - - if (transcript.messages.length === 0) { - return { - ...createImportErrorResponse([options.codexSessionId], `No importable conversation content found in transcript: ${summary.file}`), - output: `transcript 中没有可导入的会话内容:${summary.file}` - } - } - - const importedComparableMessages = transcript.messages - .map((message) => normalizeComparableContent(message)) - .filter((value): value is string => value !== null) - - try { - const candidates = collectImportCandidates(options.store, options.namespace, options.getSyncEngine) - const target = selectImportTargetSession( - options.store, - candidates, - options.codexSessionId, - importedComparableMessages - ) - const engine = options.getSyncEngine?.() ?? null - const existingStored = target.sessionId ? options.store.sessions.getSessionByNamespace(target.sessionId, options.namespace) : null - const metadata = buildImportedSessionMetadata( - transcript, - asRecord(existingStored?.metadata), - resolveImportMachineId(transcript.cwd, options.namespace, engine) - ) - - let sessionId = existingStored?.id ?? null - let created = false - if (!sessionId) { - // 中文注释:找不到可安全续写的历史会话时,直接新建一个 Hapi 会话,避免把已分叉的数据硬写进旧会话。 - const createdSession = engine?.getOrCreateSession( - randomUUID(), - metadata, - {}, - options.namespace - ) ?? options.store.sessions.getOrCreateSession(randomUUID(), metadata, {}, options.namespace) - sessionId = createdSession.id - created = true - } else if (existingStored) { - const updatedMetadata = options.store.sessions.updateSessionMetadata( - existingStored.id, - metadata, - existingStored.metadataVersion, - options.namespace - ) - if (updatedMetadata.result !== 'success') { - throw new Error(`Failed to update metadata for Hapi session: ${existingStored.id}`) - } - engine?.handleRealtimeEvent({ type: 'session-updated', sessionId: existingStored.id }) - } - - if (!sessionId) { - throw new Error(`Failed to determine target Hapi session for Codex thread: ${options.codexSessionId}`) - } - - const comparablePrefixCount = sessionId ? target.comparablePrefixCount : 0 - const messagesToAppend = transcript.messages.slice(comparablePrefixCount) - const appendedMessages = messagesToAppend.map((message) => options.store.messages.addMessage(sessionId!, message)) - - // 中文注释:更新 Hapi 会话的 updatedAt,并在已有会话追加时广播新增消息,让当前打开的聊天页立刻显示客户端新增内容。 - const latestMessageCreatedAt = appendedMessages[appendedMessages.length - 1]?.createdAt ?? Date.now() - if (engine) { - engine.recordSessionActivity(sessionId, latestMessageCreatedAt) - } else { - options.store.sessions.touchSessionUpdatedAt(sessionId, latestMessageCreatedAt, options.namespace) - } - if (!created) { - emitImportedMessageEvents(engine, sessionId, appendedMessages) - } - - const output = [ - `Codex thread: ${options.codexSessionId}`, - `Hapi session: ${sessionId}`, - `Action: ${created ? 'created' : 'updated'}`, - `Appended messages: ${appendedMessages.length}` - ].join('\n') - - appendScriptLog( - getDirectImportRouteContext().workspace, - 'sync', - `SUCCESS: codexSessionId=${options.codexSessionId}; hapiSessionId=${sessionId}; created=${created}; appended=${appendedMessages.length}` - ) - - return { - success: true, - message: created ? 'Codex session imported into a new Hapi session' : 'Codex session appended to existing Hapi session', - pid: 0, - command: DIRECT_IMPORT_COMMAND, - cwd: getDirectImportRouteContext().workspace, - output, - sessionIds: [options.codexSessionId], - syncedCount: 1 - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - return { - ...createImportErrorResponse([options.codexSessionId], message), - output: `Codex thread: ${options.codexSessionId}\n${message}` - } - } -} - -export async function importSelectedCodexSessions(options: { - codexSessionIds: string[] - store: Store - namespace: string - getSyncEngine?: () => SyncEngine | null -}): Promise { - const codexSessionIds = options.codexSessionIds - if (codexSessionIds.length === 0) { - return createImportErrorResponse(codexSessionIds, NO_SYNC_SESSION_SELECTED_ERROR) - } - - const localSessionsById = new Map(listLocalCodexSessions().map((session) => [session.id, session])) - const results: ScriptLaunchResponse[] = [] - for (const codexSessionId of codexSessionIds) { - const result = importSingleCodexSession({ - codexSessionId, - localSessionsById, - store: options.store, - namespace: options.namespace, - getSyncEngine: options.getSyncEngine - }) - results.push(result) - - if (!result.success) { - return { - ...result, - sessionIds: codexSessionIds, - syncedCount: Math.max(0, results.length - 1), - output: combineSyncOutputs(results) ?? result.output - } - } - } - - return createImportSuccessResponse(codexSessionIds, results) -} - export function createCodexDesktopRoutes(options: { store: Store getSyncEngine: () => SyncEngine | null @@ -1739,13 +976,14 @@ export function createCodexDesktopRoutes(options: { } // 中文注释:这里只检查本次导入弹窗里勾选过的 codexSessionId;未选中的会话即使也有重复,也不参与本轮提示。 - const duplicates = listDuplicateCodexSessionGroups( + const duplicates = listDuplicateSessionGroups( options.store, c.get('namespace'), + CODEX_SESSION_ID_KEY, parsed.sessionIds, options.getSyncEngine ).map((group) => ({ - codexSessionId: group.codexSessionId, + codexSessionId: group.flavorSessionId, hapiSessionIds: group.sessions.map((session) => session.sessionId) })) @@ -1775,10 +1013,11 @@ export function createCodexDesktopRoutes(options: { const { workspace } = getDirectImportRouteContext() try { // 中文注释:真正执行合并时仍然只按这次选中的 codexSessionId 收口,防止顺手把别的会话历史也改掉。 - const result = await mergeDuplicateCodexSessionGroups({ + const result = await mergeDuplicateSessionGroups({ store: options.store, namespace: c.get('namespace'), - codexSessionIds: parsed.sessionIds, + sessionIdKey: CODEX_SESSION_ID_KEY, + flavorSessionIds: parsed.sessionIds, getSyncEngine: options.getSyncEngine }) appendScriptLog( @@ -1786,7 +1025,20 @@ export function createCodexDesktopRoutes(options: { 'sync', `SUCCESS: merged duplicate Hapi sessions for selected codexSessionIds=${parsed.sessionIds.join(',')}` ) - return c.json(result satisfies CodexMergeDuplicateSessionsResponse) + if (!result.success) { + return c.json(result satisfies CodexMergeDuplicateSessionsResponse) + } + // 中文注释:对外字段保持 codexSessionId 命名不变,避免破坏既有前端契约。 + return c.json({ + success: true, + merged: result.merged.map((group) => ({ + codexSessionId: group.sessionId, + hapiSessionIds: group.hapiSessionIds, + canonicalSessionId: group.canonicalSessionId, + removedSessionIds: group.removedSessionIds + })), + mergedCount: result.mergedCount + } satisfies CodexMergeDuplicateSessionsResponse) } catch (error) { const message = error instanceof Error ? error.message : String(error) appendScriptLog( @@ -1807,7 +1059,7 @@ export function createCodexDesktopRoutes(options: { const scriptPath = getRestartScriptPath() const workspace = getWorkspace(scriptPath) const error = CODEX_DESKTOP_NOT_FOUND_ERROR - appendScriptLog(workspace, 'restart', `FAILED: ${error}; script=${scriptPath}`) + appendRestartScriptLog(workspace, 'restart', `FAILED: ${error}; script=${scriptPath}`) return c.json({ success: false, error, diff --git a/hub/src/web/routes/cursorImport.test.ts b/hub/src/web/routes/cursorImport.test.ts new file mode 100644 index 0000000000..49259d5ccf --- /dev/null +++ b/hub/src/web/routes/cursorImport.test.ts @@ -0,0 +1,628 @@ +/** + * Unit tests for the cursor flavor of the multi-agent import surface. + * + * Mirrors the codex import route test shape + * (`hub/src/web/routes/codexDesktop.test.ts`) so reviewers can read the + * two test files side-by-side. + * + * Strategy: + * - Real filesystem in a per-test tmpdir (mirrors the migrator unit tests). + * - Real bun:sqlite synthetic legacy store fixture + * (`hub/src/cursor/fixtures/buildSyntheticLegacyStore.ts`). + * - MOCK `agent acp` via the `createProbe` dependency injection point + * on `CursorImporterDeps`. The verify-probe is not spawned; tests + * script the initialize / loadSession responses to exercise every + * refusal branch without depending on a real `cursor-agent` install. + * - MOCK `findAgentBinary` by placing a stub `agent` shim under + * `/.local/bin/agent` so the pre-flight binary check passes + * even in CI where cursor-agent is absent. + * + * Covers (one row per refusal reason + the happy path): + * - listImportableCursorSessions: legacy + acp discovery + dedup + + * alreadyImported flagging + * - importCursorSession happy path: legacy → transplant + Hapi row created + * - refusal: missing_on_disk_store + * - refusal: already_imported + * - refusal: ambiguous_legacy_store (multi-drawer) + * - refusal: corrupted_store + * - refusal: verify_load_failed (initialize) + * - refusal: verify_load_failed (session/load) + * - refusal: verify_timeout + * - refusal: target_already_exists + * - route shape: GET /api/cursor/importable-sessions + * - route shape: POST /api/cursor/import (multi-row batch, mixed outcomes) + */ + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { Hono } from 'hono' +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { Store } from '../../store' +import type { WebAppEnv } from '../middleware/auth' +import type { AcpRpcResponse } from '../../cursor/acpVerifyProbe' +import { buildSyntheticLegacyStore } from '../../cursor/fixtures/buildSyntheticLegacyStore' +import { + importCursorSession, + importSelectedCursorSessions, + listImportableCursorSessions +} from '../../cursor/cursorImporter' +import { createCursorImportRoutes } from './cursorImport' + +/* ---------- mock probe ---------- */ + +interface ScriptedProbe { + initializeResponse: AcpRpcResponse + loadResponse: AcpRpcResponse + started: boolean + stopped: boolean +} + +function ok(result: Record = {}): AcpRpcResponse { + return { ok: true, result } +} + +function err(message: string, code: number = -32602): AcpRpcResponse { + return { ok: false, error: { code, message } } +} + +interface MockProbeHandle { + state: ScriptedProbe + start: () => void + stop: () => Promise + initialize: () => Promise + loadSession: () => Promise<{ response: AcpRpcResponse; notificationCount: number; notificationKinds: Record; durationMs: number }> +} + +function makeMockProbe(overrides: Partial = {}): MockProbeHandle { + const state: ScriptedProbe = { + initializeResponse: ok({ protocolVersion: 1 }), + loadResponse: ok({ models: { availableModels: [], currentModelId: 'default[]' }, modes: { availableModes: [], currentModeId: 'agent' } }), + started: false, + stopped: false, + ...overrides + } + return { + state, + start() { state.started = true }, + async stop() { state.stopped = true }, + async initialize() { + return state.initializeResponse + }, + async loadSession() { + return { + response: state.loadResponse, + notificationCount: state.loadResponse.ok ? 5 : 0, + notificationKinds: {}, + durationMs: 25 + } + } + } +} + +/* ---------- test harness ---------- */ + +interface Harness { + home: string + chatsDir: string + acpSessionsDir: string + store: Store + placeLegacyStore: (uuid: string, opts?: { workspaceHash?: string; lastUsedModel?: string; name?: string }) => string + placeAcpStore: (uuid: string, opts?: { name?: string; cwd?: string; title?: string }) => string + /** Plant a fake `agent` binary so findAgentBinary succeeds. */ + placeFakeAgentBinary: () => void +} + +function makeHarness(): Harness { + const home = mkdtempSync(join(tmpdir(), 'hapi-cursor-import-test-home-')) + const chatsDir = join(home, '.cursor', 'chats') + const acpSessionsDir = join(home, '.cursor', 'acp-sessions') + mkdirSync(chatsDir, { recursive: true }) + mkdirSync(acpSessionsDir, { recursive: true }) + const store = new Store(':memory:') + + return { + home, + chatsDir, + acpSessionsDir, + store, + placeLegacyStore(uuid, opts = {}) { + const wsh = opts.workspaceHash ?? `wsh-${Math.random().toString(36).slice(2, 10)}` + const dir = join(chatsDir, wsh, uuid) + mkdirSync(dir, { recursive: true }) + const path = join(dir, 'store.db') + buildSyntheticLegacyStore({ + path, + name: opts.name, + lastUsedModel: opts.lastUsedModel + }) + return path + }, + placeAcpStore(uuid, opts = {}) { + const dir = join(acpSessionsDir, uuid) + mkdirSync(dir, { recursive: true }) + const storePath = join(dir, 'store.db') + buildSyntheticLegacyStore({ + path: storePath, + name: opts.name + }) + writeFileSync( + join(dir, 'meta.json'), + JSON.stringify({ + schemaVersion: 1, + cwd: opts.cwd ?? '/workspace/example', + title: opts.title ?? opts.name ?? 'acp chat' + }) + ) + return storePath + }, + placeFakeAgentBinary() { + const binDir = join(home, '.local', 'bin') + mkdirSync(binDir, { recursive: true }) + const path = join(binDir, 'agent') + writeFileSync(path, '#!/bin/sh\necho "fake-agent"\n', { mode: 0o755 }) + try { chmodSync(path, 0o755) } catch {} + } + } +} + +function cleanupHarness(h: Harness): void { + try { h.store.close() } catch {} + try { rmSync(h.home, { recursive: true, force: true }) } catch {} +} + +function makeDeps(h: Harness, probe?: MockProbeHandle) { + const handle = probe ?? makeMockProbe() + return { + homeDir: () => h.home, + hostName: () => 'test-host', + tmpDir: () => h.home, + now: () => Date.now(), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + createProbe: (() => handle) as any, + verifyTimeoutMs: 5_000, + logger: { debug() {}, info() {}, warn() {}, error() {} } + } +} + +/* ---------- listImportableCursorSessions ---------- */ + +describe('listImportableCursorSessions', () => { + let h: Harness + beforeEach(() => { h = makeHarness() }) + afterEach(() => cleanupHarness(h)) + + it('returns an empty list when no chats exist on disk', () => { + const out = listImportableCursorSessions({ store: h.store, namespace: 'default', home: h.home }) + expect(out).toEqual([]) + }) + + it('discovers legacy + acp stores and dedups by uuid (acp wins)', () => { + // Legacy-only chat. + h.placeLegacyStore('11111111-1111-1111-1111-111111111111', { name: 'legacy chat' }) + // ACP-only chat. + h.placeAcpStore('22222222-2222-2222-2222-222222222222', { name: 'acp chat', title: 'acp display title' }) + // Same uuid in both — acp wins. + h.placeLegacyStore('33333333-3333-3333-3333-333333333333', { name: 'legacy version' }) + h.placeAcpStore('33333333-3333-3333-3333-333333333333', { name: 'acp version', title: 'acp version' }) + + const out = listImportableCursorSessions({ store: h.store, namespace: 'default', home: h.home }) + expect(out).toHaveLength(3) + const byId = new Map(out.map((r) => [r.id, r])) + expect(byId.get('11111111-1111-1111-1111-111111111111')?.sourceFormat).toBe('legacy') + expect(byId.get('22222222-2222-2222-2222-222222222222')?.sourceFormat).toBe('acp') + expect(byId.get('33333333-3333-3333-3333-333333333333')?.sourceFormat).toBe('acp') + // Title fallthrough from legacy meta record. + expect(byId.get('11111111-1111-1111-1111-111111111111')?.title).toBe('legacy chat') + // ACP meta.json title preferred. + expect(byId.get('22222222-2222-2222-2222-222222222222')?.title).toBe('acp display title') + }) + + it('flags already-imported uuids with the existing Hapi session id', () => { + h.placeLegacyStore('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', { name: 'already imported' }) + // Plant a HAPI session that references this cursor uuid. + const created = h.store.sessions.getOrCreateSession('hapi-existing-tag', { + path: '/workspace/x', + host: 'test-host', + flavor: 'cursor', + cursorSessionId: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' + } as Record, {}, 'default') + expect(created.id).toBeTruthy() + + const out = listImportableCursorSessions({ store: h.store, namespace: 'default', home: h.home }) + const row = out.find((r) => r.id === 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa') + expect(row).toBeDefined() + expect(row?.alreadyImportedHapiSessionId).toBe(created.id) + }) + + it('ignores non-uuid-ish basenames (path-traversal guard)', () => { + // Manually create a directory with a bogus name; should not appear. + const evil = join(h.acpSessionsDir, '..') + // Don't actually create traversal entries — just verify the regex + // rejection by planting an entry named '.evil/' which has '/' (not + // legal on disk but readdirSync would expose '.evil'). + const allowed = '11111111-1111-1111-1111-111111111111' + h.placeAcpStore(allowed, { name: 'allowed' }) + // Plant a non-matching entry. + const bogusDir = join(h.acpSessionsDir, 'bogus name with spaces') + mkdirSync(bogusDir, { recursive: true }) + writeFileSync(join(bogusDir, 'store.db'), '') + + const out = listImportableCursorSessions({ store: h.store, namespace: 'default', home: h.home }) + const ids = out.map((r) => r.id) + expect(ids).toContain(allowed) + expect(ids).not.toContain('bogus name with spaces') + // Defensive evil-path check: never returns '..'. + expect(ids).not.toContain('..') + // unused var lint pacifier + expect(evil.length).toBeGreaterThan(0) + }) +}) + +/* ---------- importCursorSession ---------- */ + +describe('importCursorSession refusals', () => { + let h: Harness + beforeEach(() => { + h = makeHarness() + h.placeFakeAgentBinary() + }) + afterEach(() => cleanupHarness(h)) + + it('refuses missing_on_disk_store when neither legacy nor acp store exists', async () => { + const out = await importCursorSession({ + uuid: '11111111-2222-3333-4444-555555555555', + store: h.store, + namespace: 'default', + home: h.home, + deps: makeDeps(h) + }) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.reason).toBe('missing_on_disk_store') + }) + + it('refuses already_imported when a Hapi row already references the uuid', async () => { + const uuid = '11111111-2222-3333-4444-666666666666' + h.placeAcpStore(uuid, { name: 'already-imported chat' }) + const planted = h.store.sessions.getOrCreateSession('hapi-prev-tag', { + path: '/workspace/y', + host: 'test-host', + flavor: 'cursor', + cursorSessionId: uuid + } as Record, {}, 'default') + + const out = await importCursorSession({ + uuid, + store: h.store, + namespace: 'default', + home: h.home, + deps: makeDeps(h) + }) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.reason).toBe('already_imported') + expect(out.message).toContain(planted.id) + }) + + it('refuses ambiguous_legacy_store when the same uuid exists in 2+ drawers without workspacePath', async () => { + const uuid = '11111111-2222-3333-4444-777777777777' + h.placeLegacyStore(uuid, { workspaceHash: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', name: 'd1' }) + h.placeLegacyStore(uuid, { workspaceHash: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', name: 'd2' }) + + const out = await importCursorSession({ + uuid, + store: h.store, + namespace: 'default', + home: h.home, + deps: makeDeps(h) + }) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.reason).toBe('ambiguous_legacy_store') + }) + + it('refuses corrupted_store when store.db is not valid sqlite', async () => { + const uuid = '11111111-2222-3333-4444-888888888888' + const dir = join(h.acpSessionsDir, uuid) + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, 'store.db'), 'not a sqlite database at all') + writeFileSync(join(dir, 'meta.json'), JSON.stringify({ schemaVersion: 1, cwd: '/x' })) + + const out = await importCursorSession({ + uuid, + store: h.store, + namespace: 'default', + home: h.home, + deps: makeDeps(h) + }) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.reason).toBe('corrupted_store') + }) + + it('refuses verify_load_failed when agent acp initialize fails', async () => { + const uuid = '11111111-2222-3333-4444-999999999999' + h.placeAcpStore(uuid, { name: 'will fail init' }) + const probe = makeMockProbe({ initializeResponse: err('initialize bombed') }) + + const out = await importCursorSession({ + uuid, + store: h.store, + namespace: 'default', + home: h.home, + deps: makeDeps(h, probe) + }) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.reason).toBe('verify_load_failed') + expect(out.message).toContain('initialize') + // STRICT contract: refusal must NOT have created a Hapi session row. + expect(h.store.sessions.getSessionsByNamespace('default')).toHaveLength(0) + }) + + it('refuses verify_load_failed when agent acp session/load fails', async () => { + const uuid = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + h.placeAcpStore(uuid, { name: 'will fail load' }) + const probe = makeMockProbe({ loadResponse: err('session/load failed: bad blob graph') }) + + const out = await importCursorSession({ + uuid, + store: h.store, + namespace: 'default', + home: h.home, + deps: makeDeps(h, probe) + }) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.reason).toBe('verify_load_failed') + expect(out.message).toContain('session/load') + expect(h.store.sessions.getSessionsByNamespace('default')).toHaveLength(0) + }) + + it('refuses verify_timeout when probe reports a timeout', async () => { + const uuid = 'bbbbbbbb-cccc-dddd-eeee-ffffffffffff' + h.placeAcpStore(uuid, { name: 'will time out' }) + const probe = makeMockProbe({ loadResponse: err('timeout after 30000ms', -32001) }) + + const out = await importCursorSession({ + uuid, + store: h.store, + namespace: 'default', + home: h.home, + deps: makeDeps(h, probe) + }) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.reason).toBe('verify_timeout') + expect(h.store.sessions.getSessionsByNamespace('default')).toHaveLength(0) + }) + + it('refuses target_already_exists when the ACP target dir is already populated for a legacy import', async () => { + const uuid = 'cccccccc-dddd-eeee-ffff-000000000000' + h.placeLegacyStore(uuid, { workspaceHash: 'wsh-only', name: 'legacy with stale acp twin' }) + // Plant an unrelated file in the ACP target so the existence check fires. + const acpDir = join(h.acpSessionsDir, uuid) + mkdirSync(acpDir, { recursive: true }) + writeFileSync(join(acpDir, 'meta.json'), JSON.stringify({ schemaVersion: 1 })) + + const out = await importCursorSession({ + uuid, + store: h.store, + namespace: 'default', + home: h.home, + deps: makeDeps(h) + }) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.reason).toBe('target_already_exists') + }) + + it('refuses agent_binary_not_found when no `agent` binary is reachable', async () => { + // Drop the fake binary placed by beforeEach. + rmSync(join(h.home, '.local', 'bin', 'agent'), { force: true }) + const originalPath = process.env.PATH + // Sanitize PATH so the real `agent` (if present on developer + // machines) cannot be found either. + process.env.PATH = '/__hapi_test_dummy__/bin' + try { + const uuid = 'dddddddd-eeee-ffff-0000-111111111111' + h.placeAcpStore(uuid, { name: 'no agent binary' }) + const out = await importCursorSession({ + uuid, + store: h.store, + namespace: 'default', + home: h.home, + deps: makeDeps(h) + }) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.reason).toBe('agent_binary_not_found') + } finally { + process.env.PATH = originalPath + } + }) +}) + +describe('importCursorSession happy paths', () => { + let h: Harness + beforeEach(() => { + h = makeHarness() + h.placeFakeAgentBinary() + }) + afterEach(() => cleanupHarness(h)) + + it('imports an ACP-format chat without touching disk and creates a Hapi row', async () => { + const uuid = 'eeeeeeee-ffff-0000-1111-222222222222' + h.placeAcpStore(uuid, { name: 'acp happy', cwd: '/workspace/happy-acp' }) + + const out = await importCursorSession({ + uuid, + store: h.store, + namespace: 'default', + home: h.home, + deps: makeDeps(h) + }) + expect(out.ok).toBe(true) + if (!out.ok) return + expect(out.sourceFormat).toBe('acp') + expect(out.hapiSessionId).toBeTruthy() + + const session = h.store.sessions.getSessionsByNamespace('default')[0] + expect(session).toBeDefined() + const metadata = session.metadata as Record + expect(metadata.flavor).toBe('cursor') + expect(metadata.cursorSessionId).toBe(uuid) + // STRICT contract: any HAPI row produced by this import path is + // ACP from birth. + expect(metadata.cursorSessionProtocol).toBe('acp') + expect(metadata.lifecycleState).toBe('imported') + }) + + it('imports a legacy chat by transplanting to the ACP location', async () => { + const uuid = 'ffffffff-0000-1111-2222-333333333333' + const sourceStorePath = h.placeLegacyStore(uuid, { + workspaceHash: 'wsh-only-source', + name: 'legacy happy' + }) + // Sanity: the legacy store exists where we expect. + expect(sourceStorePath).toContain('chats') + + const out = await importCursorSession({ + uuid, + workspacePath: '/workspace/legacy-happy', + store: h.store, + namespace: 'default', + home: h.home, + deps: makeDeps(h) + }) + expect(out.ok).toBe(true) + if (!out.ok) return + expect(out.sourceFormat).toBe('legacy') + + // After import the ACP target dir should exist with store.db + meta.json. + const acpDir = join(h.acpSessionsDir, uuid) + const acpStore = join(acpDir, 'store.db') + const acpMeta = join(acpDir, 'meta.json') + const { existsSync, readFileSync } = await import('node:fs') + expect(existsSync(acpStore)).toBe(true) + expect(existsSync(acpMeta)).toBe(true) + const meta = JSON.parse(readFileSync(acpMeta, 'utf-8')) as Record + expect(meta.schemaVersion).toBe(1) + expect(meta.cwd).toBe('/workspace/legacy-happy') + + const session = h.store.sessions.getSessionsByNamespace('default')[0] + const metadata = session.metadata as Record + expect(metadata.cursorSessionProtocol).toBe('acp') + expect(metadata.path).toBe('/workspace/legacy-happy') + }) + + it('importSelectedCursorSessions returns per-row outcomes (mixed batch)', async () => { + const goodUuid = '00000000-1111-2222-3333-444444444444' + const badUuid = '00000000-1111-2222-3333-555555555555' + h.placeAcpStore(goodUuid, { name: 'will succeed' }) + // badUuid intentionally has no on-disk store. + + const out = await importSelectedCursorSessions({ + uuids: [goodUuid, badUuid], + store: h.store, + namespace: 'default', + home: h.home, + deps: makeDeps(h) + }) + expect(out.results).toHaveLength(2) + expect(out.importedCount).toBe(1) + const byUuid = new Map(out.results.map((r) => [r.uuid, r])) + expect(byUuid.get(goodUuid)?.ok).toBe(true) + const badRow = byUuid.get(badUuid) + expect(badRow?.ok).toBe(false) + if (badRow && !badRow.ok) { + expect(badRow.reason).toBe('missing_on_disk_store') + } + }) +}) + +/* ---------- route shape ---------- */ + +function createRoutesApp(opts: { namespace: string; store: Store }): Hono { + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', opts.namespace) + await next() + }) + app.route('/api', createCursorImportRoutes({ + store: opts.store, + getSyncEngine: () => null + })) + return app +} + +describe('Cursor import HTTP routes', () => { + let h: Harness + const originalHomeOverride = process.env.HAPI_CURSOR_HOME_OVERRIDE + const originalLogRoot = process.env.HAPI_CURSOR_LOG_ROOT + beforeEach(() => { + h = makeHarness() + h.placeFakeAgentBinary() + process.env.HAPI_CURSOR_HOME_OVERRIDE = h.home + process.env.HAPI_CURSOR_LOG_ROOT = h.home + }) + afterEach(() => { + if (originalHomeOverride === undefined) { + delete process.env.HAPI_CURSOR_HOME_OVERRIDE + } else { + process.env.HAPI_CURSOR_HOME_OVERRIDE = originalHomeOverride + } + if (originalLogRoot === undefined) { + delete process.env.HAPI_CURSOR_LOG_ROOT + } else { + process.env.HAPI_CURSOR_LOG_ROOT = originalLogRoot + } + cleanupHarness(h) + }) + + it('rejects non-default namespaces', async () => { + const app = createRoutesApp({ namespace: 'tenant-a', store: h.store }) + const res = await app.request('/api/cursor/importable-sessions') + expect(res.status).toBe(403) + const body = await res.json() as Record + expect(body.success).toBe(false) + }) + + it('GET /api/cursor/importable-sessions returns the discovery list', async () => { + const app = createRoutesApp({ namespace: 'default', store: h.store }) + h.placeAcpStore('77777777-7777-7777-7777-777777777777', { name: 'route test' }) + const res = await app.request('/api/cursor/importable-sessions') + expect(res.status).toBe(200) + const body = await res.json() as { success: true; sessions: Array<{ id: string }> } + expect(body.success).toBe(true) + expect(body.sessions.some((s) => s.id === '77777777-7777-7777-7777-777777777777')).toBe(true) + }) + + it('POST /api/cursor/import rejects empty uuid arrays', async () => { + const app = createRoutesApp({ namespace: 'default', store: h.store }) + const res = await app.request('/api/cursor/import', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ uuids: [] }) + }) + expect(res.status).toBe(400) + }) + + it('POST /api/cursor/import returns a per-row outcome for each requested uuid', async () => { + const app = createRoutesApp({ namespace: 'default', store: h.store }) + const res = await app.request('/api/cursor/import', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ uuids: ['not-on-disk-uuid'] }) + }) + // Pre-flight refuses on missing_on_disk_store before any probe spawn. + expect(res.status).toBe(200) + const body = await res.json() as { success: true; results: Array<{ ok: boolean; reason?: string }>; importedCount: number } + expect(body.success).toBe(true) + expect(body.results).toHaveLength(1) + expect(body.results[0].ok).toBe(false) + expect(body.importedCount).toBe(0) + }) +}) diff --git a/hub/src/web/routes/cursorImport.ts b/hub/src/web/routes/cursorImport.ts new file mode 100644 index 0000000000..55422f0a96 --- /dev/null +++ b/hub/src/web/routes/cursorImport.ts @@ -0,0 +1,179 @@ +/** + * Cursor flavor of the multi-agent session import surface. + * + * Mirrors the codex import route shape (`hub/src/web/routes/codexDesktop.ts`, + * shipped upstream in `tiann/hapi#796`) so the diff parallel between the + * two routes minimizes review friction. The cursor endpoints live + * alongside the codex endpoints rather than under a generalized + * `/api/agent-sessions/...` umbrella; only the shared types live in + * `_agentImport/types.ts`. + * + * Endpoints: + * GET /api/cursor/importable-sessions → list local cursor chats + * POST /api/cursor/import { uuids[], workspacePath? } → import N rows + * + * The strict ACP-only refusal contract lives in `cursorImporter.ts`. + * This module is namespace-gated to `default` (matching codex), proxies + * the importer's structured outcomes back to the dialog, and writes a + * lightweight audit log line per import. + */ + +import { Hono } from 'hono' +import { appendFileSync, existsSync, mkdirSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' + +import type { Store } from '../../store' +import type { SyncEngine } from '../../sync/syncEngine' +import type { WebAppEnv } from '../middleware/auth' +import { + importSelectedCursorSessions, + listImportableCursorSessions +} from '../../cursor/cursorImporter' +import type { + CursorImportResponse, + CursorImportRowOutcome, + CursorImportableSessionsResponse +} from './_agentImport/types' + +const CURSOR_IMPORT_NAMESPACE_ERROR = 'Cursor session import is not available outside the default namespace' +const NO_CURSOR_SESSION_SELECTED_ERROR = 'No cursor sessions selected for import' + +function getHome(): string { + return process.env.HAPI_CURSOR_HOME_OVERRIDE?.trim() || homedir() +} + +function getLogRoot(): string { + const configured = process.env.HAPI_CURSOR_LOG_ROOT?.trim() + return configured || process.cwd() +} + +function appendImportLog(message: string): void { + try { + const logDir = join(getLogRoot(), 'logs') + mkdirSync(logDir, { recursive: true }) + const line = `[${new Date().toISOString()}] [cursor-import] ${message}\n` + appendFileSync(join(logDir, 'CursorImport.log'), line, 'utf-8') + } catch { + // best-effort + } +} + +interface CursorImportRequestParseResult { + uuids: string[] + workspacePath: string | null + error?: string +} + +function parseImportRequest(body: unknown): CursorImportRequestParseResult { + if (body === null || typeof body !== 'object' || Array.isArray(body)) { + return { uuids: [], workspacePath: null } + } + const record = body as Record + const rawUuids = record.uuids + let uuids: string[] = [] + if (Array.isArray(rawUuids)) { + for (const value of rawUuids) { + if (typeof value !== 'string') { + return { uuids: [], workspacePath: null, error: 'Invalid uuids' } + } + const trimmed = value.trim() + if (trimmed) uuids.push(trimmed) + } + } else if (rawUuids !== undefined) { + return { uuids: [], workspacePath: null, error: 'Invalid uuids' } + } + uuids = Array.from(new Set(uuids)) + + let workspacePath: string | null = null + if (typeof record.workspacePath === 'string') { + const trimmed = record.workspacePath.trim() + workspacePath = trimmed.length > 0 ? trimmed : null + } else if (record.workspacePath != null && record.workspacePath !== undefined) { + return { uuids: [], workspacePath: null, error: 'Invalid workspacePath' } + } + + return { uuids, workspacePath } +} + +export function createCursorImportRoutes(options: { + store: Store + getSyncEngine: () => SyncEngine | null +}): Hono { + const app = new Hono() + + app.use('/cursor/*', async (c, next) => { + if (c.get('namespace') !== 'default') { + return c.json({ + success: false, + error: CURSOR_IMPORT_NAMESPACE_ERROR + }, 403) + } + return next() + }) + + app.get('/cursor/importable-sessions', (c) => { + const home = getHome() + const sessions = listImportableCursorSessions({ + store: options.store, + namespace: c.get('namespace'), + home + }) + return c.json({ + success: true, + sessions + } satisfies CursorImportableSessionsResponse) + }) + + app.post('/cursor/import', async (c) => { + const body = await c.req.json().catch(() => null) + const parsed = parseImportRequest(body) + if (parsed.error) { + appendImportLog(`FAILED: ${parsed.error}`) + return c.json({ + success: false, + error: parsed.error + }, 400) + } + if (parsed.uuids.length === 0) { + appendImportLog(`FAILED: ${NO_CURSOR_SESSION_SELECTED_ERROR}`) + return c.json({ + success: false, + error: NO_CURSOR_SESSION_SELECTED_ERROR + }, 400) + } + + const home = getHome() + const result = await importSelectedCursorSessions({ + uuids: parsed.uuids, + workspacePath: parsed.workspacePath, + store: options.store, + namespace: c.get('namespace'), + home, + getSyncEngine: options.getSyncEngine + }) + + appendImportLog( + `imported=${result.importedCount}/${parsed.uuids.length}; uuids=${parsed.uuids.join(',')}; outcomes=${result.results.map(rowToLog).join('|')}` + ) + + const response: CursorImportResponse = { + success: true, + results: result.results, + importedCount: result.importedCount + } + return c.json(response) + }) + + return app +} + +function rowToLog(row: CursorImportRowOutcome): string { + if (row.ok) { + return `ok(${row.uuid}->${row.hapiSessionId} ${row.sourceFormat} ${row.durationMs}ms)` + } + return `fail(${row.uuid} ${row.reason} ${row.durationMs}ms)` +} + +// Re-export for direct programmatic use from tests / future CLI subcommand. +export { listImportableCursorSessions, importSelectedCursorSessions } from '../../cursor/cursorImporter' diff --git a/hub/src/web/routes/transcriptImport.ts b/hub/src/web/routes/transcriptImport.ts new file mode 100644 index 0000000000..f4a5f9a9d7 --- /dev/null +++ b/hub/src/web/routes/transcriptImport.ts @@ -0,0 +1,931 @@ +import { appendFileSync, mkdirSync } from 'node:fs' +import { randomUUID } from 'node:crypto' +import { dirname, isAbsolute, join, resolve } from 'node:path' +import { homedir, hostname, platform } from 'node:os' +import { AGENT_MESSAGE_PAYLOAD_TYPE } from '@hapi/protocol' +import type { Machine, SyncEngine } from '../../sync/syncEngine' +import type { Store, StoredMessage } from '../../store' + +// 中文注释:本文件收敛 Codex / Claude transcript 导入共用的“落库 / 同步 / 去重 / 响应”逻辑。 +// 各 flavor 只需提供 ImporterAdapter(扫描路径 + 解析器 + flavor/metadata),不再复制粘贴第二份并行逻辑。 + +export type ScriptLogKind = 'sync' | 'restart' + +export type TranscriptFlavor = 'codex' | 'claude' + +export type ImportedMessageContent = { + role: 'user' + content: { + type: 'text' + text: string + } + meta: { + sentFrom: 'cli' + } +} | { + role: 'agent' + content: { + type: typeof AGENT_MESSAGE_PAYLOAD_TYPE + data: unknown + } + meta: { + sentFrom: 'cli' + } +} + +// 中文注释:导入消息在落库前包一层 createdAt,保留 transcript 记录里的原始时间戳。 +// content 仍是会被 JSON.stringify 持久化的 payload,createdAt 只参与排序/活跃时间,不写进 content。 +export type ImportedMessage = { + content: ImportedMessageContent + createdAt?: number +} + +export type LocalSessionSummary = { + id: string + title: string + lastUserMessage?: string | null + cwd?: string | null + file: string + modifiedAt: number + originator?: string | null + cliVersion?: string | null +} + +export type TranscriptImportData = LocalSessionSummary & { + messages: ImportedMessage[] +} + +export type ScriptLaunchResponse = { + success: true + message: string + pid: number + command: string + script?: string + cwd: string + output?: string + codexDesktopRunning?: boolean + codexClientAvailable?: boolean + syncedCount?: number + sessionIds?: string[] +} | { + success: false + error: string + script?: string + cwd: string + output?: string + codexDesktopRunning?: boolean + codexClientAvailable?: boolean + syncedCount?: number + sessionIds?: string[] +} + +export type ImportCandidate = { + sessionId: string + active: boolean + updatedAt: number + metadata: Record | null +} + +export type ImportTargetSelection = { + sessionId: string | null + comparablePrefixCount: number +} + +export type SyncSessionRequestParseResult = { + sessionIds: string[] + error?: string +} + +export type DuplicateSessionGroup = { + sessionId: string + hapiSessionIds: string[] + canonicalSessionId?: string + removedSessionIds?: string[] +} + +export type DuplicateSessionsResponse = { + success: true + duplicates: DuplicateSessionGroup[] +} | { + success: false + error: string +} + +export type MergeDuplicateSessionsResponse = { + success: true + merged: DuplicateSessionGroup[] + mergedCount: number +} | { + success: false + error: string +} + +type DuplicateSessionGroupCandidate = { + flavorSessionId: string + sessions: ImportCandidate[] +} + +// 中文注释:ImporterAdapter 是各 flavor 与通用引擎之间的唯一契约。 +// - sessionIdKey 决定去重/绑定时在 metadata 上读哪个键(codexSessionId / claudeSessionId)。 +// - listLocalSessions / parseTranscript 是各 flavor 专属的扫描与解析。 +export interface ImporterAdapter { + flavor: TranscriptFlavor + sessionIdKey: string + listLocalSessions(limit?: number): LocalSessionSummary[] + parseTranscript(summary: LocalSessionSummary): TranscriptImportData | null +} + +export const DIRECT_IMPORT_COMMAND = 'direct-import' +export const NO_SYNC_SESSION_SELECTED_ERROR = '未选择需要导入的会话' +export const DEFAULT_SESSION_SCAN_LIMIT = 500 + +function resolveLocalPath(pathValue: string): string { + return isAbsolute(pathValue) ? pathValue : resolve(process.cwd(), pathValue) +} + +export function expandHomePath(pathValue: string): string { + return pathValue.replace(/^~(?=$|[\\/])/, homedir()) +} + +export function asRecord(value: unknown): Record | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : null +} + +export function asString(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null +} + +// 中文注释:把 transcript 记录里的 ISO 时间戳(如 "2026-06-15T09:12:00.706Z")解析成毫秒; +// 缺失或非法时返回 undefined,让调用方回退到文件 mtime。Claude / Codex 共用此入口避免各写一份。 +export function parseImportedTimestamp(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) { + return value + } + if (typeof value === 'string' && value.length > 0) { + const parsed = Date.parse(value) + if (Number.isFinite(parsed)) { + return parsed + } + } + return undefined +} + +export function truncateText(value: string, maxLength: number): string { + return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value +} + +export function buildImportedUserMessage(text: string): ImportedMessageContent { + return { + role: 'user', + content: { + type: 'text', + text + }, + meta: { + sentFrom: 'cli' + } + } +} + +export function buildImportedAgentMessage(data: unknown): ImportedMessageContent { + return { + role: 'agent', + content: { + type: AGENT_MESSAGE_PAYLOAD_TYPE, + data + }, + meta: { + sentFrom: 'cli' + } + } +} + +function normalizeComparablePath(pathValue: string, options?: { caseInsensitive?: boolean }): string { + let normalized = pathValue.trim().replace(/\\/g, '/').replace(/\/+/g, '/') + if (normalized.length > 1) { + normalized = normalized.replace(/\/+$/, '') + } + return options?.caseInsensitive ? normalized.toLowerCase() : normalized +} + +function shouldCompareCaseInsensitive(...pathValues: string[]): boolean { + return pathValues.some((pathValue) => /^[a-z]:[\\/]/i.test(pathValue) || pathValue.includes('\\')) +} + +function isPathInsideWorkspaceRoot(pathValue: string, rootValue: string): boolean { + if (!pathValue.trim() || !rootValue.trim()) { + return false + } + + const caseInsensitive = shouldCompareCaseInsensitive(pathValue, rootValue) + const path = normalizeComparablePath(pathValue, { caseInsensitive }) + const root = normalizeComparablePath(rootValue, { caseInsensitive }) + if (!path || !root) { + return false + } + if (path === root) { + return true + } + if (root === '/') { + return path.startsWith('/') + } + return path.startsWith(`${root}/`) +} + +function machineOwnsCwd(machine: Machine, cwd: string): boolean { + const workspaceRoots = machine.metadata?.workspaceRoots ?? [] + return workspaceRoots.some((workspaceRoot) => isPathInsideWorkspaceRoot(cwd, workspaceRoot)) +} + +export function resolveImportMachineId( + cwd: string | null | undefined, + namespace: string, + engine: SyncEngine | null +): string | undefined { + if (!cwd || !engine) { + return undefined + } + + const matches = engine.getOnlineMachinesByNamespace(namespace) + .filter((machine) => machineOwnsCwd(machine, cwd)) + const machineIds = Array.from(new Set(matches.map((machine) => machine.id))) + return machineIds.length === 1 ? machineIds[0] : undefined +} + +export function buildImportedSessionMetadata( + data: TranscriptImportData, + flavor: TranscriptFlavor, + sessionIdKey: string, + existingMetadata?: Record | null, + resolvedMachineId?: string +): Record { + const now = Date.now() + const path = data.cwd ?? (typeof existingMetadata?.path === 'string' ? existingMetadata.path : dirname(data.file)) + const host = typeof existingMetadata?.host === 'string' ? existingMetadata.host : (process.env.HAPI_HOSTNAME || hostname()) + const osValue = typeof existingMetadata?.os === 'string' ? existingMetadata.os : platform() + const summaryText = data.lastUserMessage ?? data.title + const machineId = typeof existingMetadata?.machineId === 'string' + ? existingMetadata.machineId + : resolvedMachineId + + return { + ...(existingMetadata ?? {}), + path, + host, + os: osValue, + name: data.title, + summary: summaryText + ? { + text: summaryText, + updatedAt: now + } + : existingMetadata?.summary, + flavor, + [sessionIdKey]: data.id, + ...(machineId ? { machineId } : {}), + lifecycleState: typeof existingMetadata?.lifecycleState === 'string' + ? existingMetadata.lifecycleState + : 'imported', + lifecycleStateSince: typeof existingMetadata?.lifecycleStateSince === 'number' + ? existingMetadata.lifecycleStateSince + : now + } +} + +export function stableSerialize(value: unknown): string { + if (value === null || value === undefined) { + return String(value) + } + if (typeof value === 'string') { + return JSON.stringify(value) + } + if (typeof value === 'number' || typeof value === 'boolean') { + return JSON.stringify(value) + } + if (Array.isArray(value)) { + return `[${value.map((item) => stableSerialize(item)).join(',')}]` + } + if (typeof value === 'object') { + const record = value as Record + const keys = Object.keys(record).sort() + return `{${keys.map((key) => `${JSON.stringify(key)}:${stableSerialize(record[key])}`).join(',')}}` + } + return JSON.stringify(value) +} + +function normalizeComparableAgentData(value: unknown): unknown { + const record = asRecord(value) + if (!record) { + return value + } + + const normalized = { ...record } + if ('id' in normalized) { + delete normalized.id + } + return normalized +} + +export function normalizeComparableContent(content: unknown): string | null { + const record = asRecord(content) + if (!record) { + return null + } + + if (record.role === 'user') { + const body = asRecord(record.content) + if (body?.type !== 'text' || typeof body.text !== 'string') { + return null + } + return stableSerialize({ + role: 'user', + text: body.text + }) + } + + if (record.role === 'agent') { + const body = asRecord(record.content) + if (!body || body.type !== AGENT_MESSAGE_PAYLOAD_TYPE) { + return null + } + return stableSerialize({ + role: 'agent', + data: normalizeComparableAgentData(body.data) + }) + } + + return null +} + +export function getComparableStoredMessageKey(message: StoredMessage): string { + // 中文注释:重复会话合并时优先按标准 user/agent 结构去重;遇到非标准消息再回退到稳定序列化,确保不会遗漏相同内容。 + return normalizeComparableContent(message.content) ?? stableSerialize(message.content) +} + +export function collectImportCandidates( + store: Store, + namespace: string, + getSyncEngine?: () => SyncEngine | null +): ImportCandidate[] { + const engineSessions = getSyncEngine?.()?.getSessionsByNamespace(namespace) ?? [] + if (engineSessions.length > 0) { + return engineSessions.map((session) => ({ + sessionId: session.id, + active: session.active, + updatedAt: session.updatedAt, + metadata: asRecord(session.metadata) + })) + } + + return store.sessions.getSessionsByNamespace(namespace).map((session) => ({ + sessionId: session.id, + active: session.active, + updatedAt: session.updatedAt, + metadata: asRecord(session.metadata) + })) +} + +export function selectImportTargetSession( + store: Store, + candidates: ImportCandidate[], + sessionIdKey: string, + flavorSessionId: string, + importedComparableMessages: string[] +): ImportTargetSelection { + const relatedCandidates = candidates + .filter((candidate) => candidate.metadata?.[sessionIdKey] === flavorSessionId) + .sort((a, b) => b.updatedAt - a.updatedAt) + + if (relatedCandidates.some((candidate) => candidate.active)) { + throw new Error('当前会话仍处于活跃状态,请等待会话结束后重试') + } + + let bestSessionId: string | null = null + let bestPrefixCount = -1 + + for (const candidate of relatedCandidates) { + const comparableMessages = store.messages.getAllMessages(candidate.sessionId) + .map((message) => normalizeComparableContent(message.content)) + .filter((value): value is string => value !== null) + + if (comparableMessages.length > importedComparableMessages.length) { + continue + } + + let prefixMatches = true + for (let index = 0; index < comparableMessages.length; index += 1) { + if (comparableMessages[index] !== importedComparableMessages[index]) { + prefixMatches = false + break + } + } + + if (!prefixMatches) { + continue + } + + if (comparableMessages.length > bestPrefixCount) { + bestPrefixCount = comparableMessages.length + bestSessionId = candidate.sessionId + } + } + + return { + sessionId: bestSessionId, + comparablePrefixCount: Math.max(0, bestPrefixCount) + } +} + +export function listDuplicateSessionGroups( + store: Store, + namespace: string, + sessionIdKey: string, + flavorSessionIds: string[], + getSyncEngine?: () => SyncEngine | null +): DuplicateSessionGroupCandidate[] { + const requestedSessionIds = new Set(flavorSessionIds) + if (requestedSessionIds.size === 0) { + return [] + } + + const groups = new Map() + for (const candidate of collectImportCandidates(store, namespace, getSyncEngine)) { + const flavorSessionId = typeof candidate.metadata?.[sessionIdKey] === 'string' + ? candidate.metadata[sessionIdKey] as string + : null + if (!flavorSessionId || !requestedSessionIds.has(flavorSessionId)) { + continue + } + + const existing = groups.get(flavorSessionId) + if (existing) { + existing.push(candidate) + } else { + groups.set(flavorSessionId, [candidate]) + } + } + + return Array.from(groups.entries()) + .map(([flavorSessionId, sessions]) => ({ + flavorSessionId, + sessions: sessions.sort((a, b) => b.updatedAt - a.updatedAt) + })) + .filter((group) => group.sessions.length > 1) +} + +export async function mergeDuplicateSessionGroups(options: { + store: Store + namespace: string + sessionIdKey: string + flavorSessionIds: string[] + getSyncEngine?: () => SyncEngine | null +}): Promise { + const groups = listDuplicateSessionGroups( + options.store, + options.namespace, + options.sessionIdKey, + options.flavorSessionIds, + options.getSyncEngine + ) + if (groups.length === 0) { + return { + success: true, + merged: [], + mergedCount: 0 + } + } + + const merged: DuplicateSessionGroup[] = [] + for (const group of groups) { + const result = await mergeSingleDuplicateSessionGroup({ + group, + store: options.store, + namespace: options.namespace, + getSyncEngine: options.getSyncEngine + }) + merged.push(result) + } + + return { + success: true, + merged, + mergedCount: merged.length + } +} + +async function mergeSingleDuplicateSessionGroup(options: { + group: DuplicateSessionGroupCandidate + store: Store + namespace: string + getSyncEngine?: () => SyncEngine | null +}): Promise { + const engine = options.getSyncEngine?.() ?? null + const sessionStates = options.group.sessions + .map((candidate) => ({ + ...candidate, + storedMessages: options.store.messages.getAllMessages(candidate.sessionId), + })) + .map((candidate) => ({ + ...candidate, + comparableKeys: candidate.storedMessages.map((message) => getComparableStoredMessageKey(message)) + })) + .sort((a, b) => { + if (b.comparableKeys.length !== a.comparableKeys.length) { + return b.comparableKeys.length - a.comparableKeys.length + } + if (b.updatedAt !== a.updatedAt) { + return b.updatedAt - a.updatedAt + } + return a.sessionId.localeCompare(b.sessionId) + }) + + if (sessionStates.some((candidate) => candidate.active)) { + throw new Error('当前会话仍处于活跃状态,请等待会话结束后重试') + } + + const canonical = sessionStates[0] + if (!canonical) { + throw new Error(`No duplicate Hapi session found for thread: ${options.group.flavorSessionId}`) + } + + const knownKeys = new Set(canonical.comparableKeys) + const removedSessionIds: string[] = [] + const appendedMessages: StoredMessage[] = [] + let latestActivity = canonical.updatedAt + + for (const source of sessionStates.slice(1)) { + latestActivity = Math.max(latestActivity, source.updatedAt) + for (const message of source.storedMessages) { + const comparableKey = getComparableStoredMessageKey(message) + if (knownKeys.has(comparableKey)) { + continue + } + + const copied = options.store.messages.copyMessageToSession(canonical.sessionId, { + content: message.content, + createdAt: message.createdAt, + localId: message.localId, + invokedAt: message.invokedAt, + scheduledAt: message.scheduledAt + }) + knownKeys.add(comparableKey) + appendedMessages.push(copied) + latestActivity = Math.max(latestActivity, copied.invokedAt ?? copied.createdAt) + } + + if (engine) { + await engine.deleteSession(source.sessionId) + } else { + const deleted = options.store.sessions.deleteSession(source.sessionId, options.namespace) + if (!deleted) { + throw new Error(`Failed to delete duplicate Hapi session: ${source.sessionId}`) + } + } + removedSessionIds.push(source.sessionId) + } + + if (appendedMessages.length > 0) { + emitImportedMessageEvents(engine, canonical.sessionId, appendedMessages) + } + + if (engine) { + engine.recordSessionActivity(canonical.sessionId, latestActivity) + // 中文注释:即使这次只是删除重复分身、没有新增消息,也主动刷新 canonical 会话,确保左侧列表立刻收敛到合并后的状态。 + engine.handleRealtimeEvent({ + type: 'session-updated', + sessionId: canonical.sessionId + }) + } else { + options.store.sessions.touchSessionUpdatedAt(canonical.sessionId, latestActivity, options.namespace) + } + + return { + sessionId: options.group.flavorSessionId, + hapiSessionIds: sessionStates.map((candidate) => candidate.sessionId), + canonicalSessionId: canonical.sessionId, + removedSessionIds + } +} + +export function emitImportedMessageEvents( + engine: SyncEngine | null, + sessionId: string, + appendedMessages: StoredMessage[] +): void { + if (!engine) { + return + } + + // 中文注释:只有追加到已有 Hapi 会话时才逐条广播新增消息,确保当前打开的会话右侧消息区能立即刷新到最新 transcript。 + for (const message of appendedMessages) { + engine.handleRealtimeEvent({ + type: 'message-received', + sessionId, + message: { + id: message.id, + seq: message.seq, + localId: message.localId ?? null, + content: message.content, + createdAt: message.createdAt, + invokedAt: message.invokedAt + } + }) + } +} + +export function parseSyncSessionRequest(body: unknown): SyncSessionRequestParseResult { + // 中文注释:导入弹窗直接提交 thread ID;未传 body 时按“未选择会话”处理,避免再回退到旧的默认最新会话逻辑。 + if (body === null || typeof body !== 'object' || Array.isArray(body) || !('sessionIds' in body)) { + return { sessionIds: [] } + } + + const rawSessionIds = (body as { sessionIds?: unknown }).sessionIds + if (!Array.isArray(rawSessionIds)) { + return { sessionIds: [], error: 'Invalid sessionIds' } + } + + const sessionIds: string[] = [] + for (const value of rawSessionIds) { + if (typeof value !== 'string') { + return { sessionIds: [], error: 'Invalid sessionIds' } + } + const trimmed = value.trim() + if (trimmed) { + sessionIds.push(trimmed) + } + } + + // 中文注释:前端允许多选,这里按 thread 去重,避免重复导入同一条本地 transcript。 + return { sessionIds: Array.from(new Set(sessionIds)) } +} + +function getDirectImportWorkspace(): string { + const configured = process.env.HAPI_CODEX_WORKSPACE?.trim() + return configured ? resolveLocalPath(expandHomePath(configured)) : process.cwd() +} + +export function getDirectImportRouteContext(): { workspace: string } { + return { + workspace: getDirectImportWorkspace() + } +} + +export function appendScriptLog(workspace: string, kind: ScriptLogKind, message: string): void { + try { + const logDir = join(workspace, 'logs') + mkdirSync(logDir, { recursive: true }) + const line = `[${new Date().toISOString()}] [${kind}] ${message}\n` + appendFileSync(join(logDir, 'CodexDesktopScript.log'), line, 'utf-8') + } catch { + // Best-effort logging only; API response still carries the error. + } +} + +export function combineSyncOutputs(results: ScriptLaunchResponse[]): string | undefined { + const output = results + .map((result, index) => { + // 中文注释:direct import 不依赖隐藏脚本;这里把每个会话的导入摘要拼成一段文本,便于前端或日志统一查看。 + const detail = result.success ? (result.output ?? '') : (result.output ?? result.error) + return detail ? `[${index + 1}] ${detail}` : '' + }) + .filter(Boolean) + .join('\n\n') + .trim() + return output || undefined +} + +export function createImportErrorResponse( + flavor: TranscriptFlavor, + flavorSessionIds: string[], + error: string, + syncedCount = 0 +): ScriptLaunchResponse { + const { workspace } = getDirectImportRouteContext() + appendScriptLog(workspace, 'sync', `FAILED: ${error}; sessionIds=${flavorSessionIds.join(',') || '(none)'}`) + return { + success: false, + error, + cwd: workspace, + sessionIds: flavorSessionIds, + syncedCount + } +} + +export function createImportSuccessResponse( + flavor: TranscriptFlavor, + flavorSessionIds: string[], + results: ScriptLaunchResponse[] +): ScriptLaunchResponse { + const { workspace } = getDirectImportRouteContext() + const flavorLabel = flavor === 'codex' ? 'Codex' : 'Claude' + appendScriptLog( + workspace, + 'sync', + `SUCCESS: imported ${results.length} ${flavorLabel} session(s); sessionIds=${flavorSessionIds.join(',')}` + ) + return { + success: true, + message: `Imported ${results.length} ${flavorLabel} session(s) into Hapi`, + pid: 0, + command: DIRECT_IMPORT_COMMAND, + cwd: workspace, + output: combineSyncOutputs(results), + sessionIds: flavorSessionIds, + syncedCount: results.length + } +} + +export function importSingleSession(options: { + adapter: ImporterAdapter + sessionId: string + localSessionsById: Map + store: Store + namespace: string + getSyncEngine?: () => SyncEngine | null +}): ScriptLaunchResponse { + const { adapter } = options + const flavorLabel = adapter.flavor === 'codex' ? 'Codex' : 'Claude' + const summary = options.localSessionsById.get(options.sessionId) + if (!summary) { + return { + ...createImportErrorResponse(adapter.flavor, [options.sessionId], `Transcript not found for ${flavorLabel} session: ${options.sessionId}`), + output: `未找到对应的本地 transcript:${options.sessionId}` + } + } + + const transcript = adapter.parseTranscript(summary) + if (!transcript) { + return { + ...createImportErrorResponse(adapter.flavor, [options.sessionId], `Failed to parse ${flavorLabel} transcript: ${summary.file}`), + output: `解析 transcript 失败:${summary.file}` + } + } + + if (transcript.messages.length === 0) { + return { + ...createImportErrorResponse(adapter.flavor, [options.sessionId], `No importable conversation content found in transcript: ${summary.file}`), + output: `transcript 中没有可导入的会话内容:${summary.file}` + } + } + + const importedComparableMessages = transcript.messages + .map((message) => normalizeComparableContent(message.content)) + .filter((value): value is string => value !== null) + + try { + const candidates = collectImportCandidates(options.store, options.namespace, options.getSyncEngine) + const target = selectImportTargetSession( + options.store, + candidates, + adapter.sessionIdKey, + options.sessionId, + importedComparableMessages + ) + const engine = options.getSyncEngine?.() ?? null + const existingStored = target.sessionId ? options.store.sessions.getSessionByNamespace(target.sessionId, options.namespace) : null + const metadata = buildImportedSessionMetadata( + transcript, + adapter.flavor, + adapter.sessionIdKey, + asRecord(existingStored?.metadata), + resolveImportMachineId(transcript.cwd, options.namespace, engine) + ) + + let sessionId = existingStored?.id ?? null + let created = false + if (!sessionId) { + // 中文注释:找不到可安全续写的历史会话时,直接新建一个 Hapi 会话,避免把已分叉的数据硬写进旧会话。 + const createdSession = engine?.getOrCreateSession( + randomUUID(), + metadata, + {}, + options.namespace + ) ?? options.store.sessions.getOrCreateSession(randomUUID(), metadata, {}, options.namespace) + sessionId = createdSession.id + created = true + } else if (existingStored) { + const updatedMetadata = options.store.sessions.updateSessionMetadata( + existingStored.id, + metadata, + existingStored.metadataVersion, + options.namespace + ) + if (updatedMetadata.result !== 'success') { + throw new Error(`Failed to update metadata for Hapi session: ${existingStored.id}`) + } + engine?.handleRealtimeEvent({ type: 'session-updated', sessionId: existingStored.id }) + } + + if (!sessionId) { + throw new Error(`Failed to determine target Hapi session for ${flavorLabel} thread: ${options.sessionId}`) + } + + const comparablePrefixCount = sessionId ? target.comparablePrefixCount : 0 + const messagesToAppend = transcript.messages.slice(comparablePrefixCount) + // 中文注释:导入历史会话走 copyMessageToSession 落库,保留 transcript 记录里的原始时间戳; + // addMessage 会盖成 Date.now(),导致旧会话被排成“今天刚活跃”、消息时间线被压平。 + // 单条记录缺少逐行时间戳时回退到 transcript.modifiedAt(文件 mtime)。localId 置 null 走已读路径, + // invokedAt 跟随 createdAt 让消息直接落入聊天区而非排队浮条。 + const appendedMessages = messagesToAppend.map((message) => { + const createdAt = message.createdAt ?? transcript.modifiedAt + return options.store.messages.copyMessageToSession(sessionId!, { + content: message.content, + createdAt, + localId: null, + invokedAt: createdAt, + scheduledAt: null + }) + }) + + // 中文注释:更新 Hapi 会话的 updatedAt,并在已有会话追加时广播新增消息,让当前打开的聊天页立刻显示客户端新增内容。 + // 取这批消息里最大的真实时间戳作为最后活跃时间,避免个别乱序记录把会话排到错误位置。 + const latestMessageCreatedAt = appendedMessages.length > 0 + ? appendedMessages.reduce((max, message) => Math.max(max, message.invokedAt ?? message.createdAt), 0) + : Date.now() + if (created) { + // 中文注释:新建的导入会话出生时间是 now(今天),而真实最后活动在历史里;recordSessionActivity / + // touchSessionUpdatedAt 只前进不后退,无法把 updated_at 调回过去,否则历史会话会一直排在列表顶端 + // 显示成“今天刚活跃”。这里对刚建好的导入会话无条件回填真实最后活动时间,再刷新引擎缓存。 + options.store.sessions.setImportedSessionActivity(sessionId, latestMessageCreatedAt, options.namespace) + engine?.handleRealtimeEvent({ type: 'session-updated', sessionId }) + } else if (engine) { + engine.recordSessionActivity(sessionId, latestMessageCreatedAt) + } else { + options.store.sessions.touchSessionUpdatedAt(sessionId, latestMessageCreatedAt, options.namespace) + } + if (!created) { + emitImportedMessageEvents(engine, sessionId, appendedMessages) + } + + const output = [ + `${flavorLabel} thread: ${options.sessionId}`, + `Hapi session: ${sessionId}`, + `Action: ${created ? 'created' : 'updated'}`, + `Appended messages: ${appendedMessages.length}` + ].join('\n') + + appendScriptLog( + getDirectImportRouteContext().workspace, + 'sync', + `SUCCESS: ${adapter.sessionIdKey}=${options.sessionId}; hapiSessionId=${sessionId}; created=${created}; appended=${appendedMessages.length}` + ) + + return { + success: true, + message: created ? `${flavorLabel} session imported into a new Hapi session` : `${flavorLabel} session appended to existing Hapi session`, + pid: 0, + command: DIRECT_IMPORT_COMMAND, + cwd: getDirectImportRouteContext().workspace, + output, + sessionIds: [options.sessionId], + syncedCount: 1 + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { + ...createImportErrorResponse(adapter.flavor, [options.sessionId], message), + output: `${flavorLabel} thread: ${options.sessionId}\n${message}` + } + } +} + +export async function importSelectedSessions(options: { + adapter: ImporterAdapter + sessionIds: string[] + store: Store + namespace: string + getSyncEngine?: () => SyncEngine | null +}): Promise { + const { adapter } = options + const sessionIds = options.sessionIds + if (sessionIds.length === 0) { + return createImportErrorResponse(adapter.flavor, sessionIds, NO_SYNC_SESSION_SELECTED_ERROR) + } + + const localSessionsById = new Map(adapter.listLocalSessions().map((session) => [session.id, session])) + const results: ScriptLaunchResponse[] = [] + for (const sessionId of sessionIds) { + const result = importSingleSession({ + adapter, + sessionId, + localSessionsById, + store: options.store, + namespace: options.namespace, + getSyncEngine: options.getSyncEngine + }) + results.push(result) + + if (!result.success) { + return { + ...result, + sessionIds, + syncedCount: Math.max(0, results.length - 1), + output: combineSyncOutputs(results) ?? result.output + } + } + } + + return createImportSuccessResponse(adapter.flavor, sessionIds, results) +} diff --git a/hub/src/web/server.ts b/hub/src/web/server.ts index b0cf0592c5..485897a8a6 100644 --- a/hub/src/web/server.ts +++ b/hub/src/web/server.ts @@ -21,6 +21,8 @@ import { createMachinesRoutes } from './routes/machines' import { createGitRoutes } from './routes/git' import { createCliRoutes } from './routes/cli' import { createCodexDesktopRoutes } from './routes/codexDesktop' +import { createClaudeDesktopRoutes } from './routes/claudeDesktop' +import { createCursorImportRoutes } from './routes/cursorImport' import { createPushRoutes } from './routes/push' import { createVoiceRoutes } from './routes/voice' import type { SSEManager } from '../sse/sseManager' @@ -245,6 +247,16 @@ function createWebApp(options: { store: options.store, getSyncEngine: options.getSyncEngine })) + // 中文注释:与 Codex 对称,扫描本地 ~/.claude/projects transcript 以导入 Hapi(复用同一套导入引擎)。 + app.route('/api', createClaudeDesktopRoutes({ + store: options.store, + getSyncEngine: options.getSyncEngine + })) + // Cursor flavor of the multi-agent session import surface (ACP verify-probe). + app.route('/api', createCursorImportRoutes({ + store: options.store, + getSyncEngine: options.getSyncEngine + })) app.route('/api', createPushRoutes(options.store, options.vapidPublicKey)) app.route('/api', createVoiceRoutes()) diff --git a/scripts/audit-cursor-acp-verify.ts b/scripts/audit-cursor-acp-verify.ts new file mode 100644 index 0000000000..daa02831fc --- /dev/null +++ b/scripts/audit-cursor-acp-verify.ts @@ -0,0 +1,537 @@ +#!/usr/bin/env bun +/** + * Cursor ACP-verify audit (pre-PR gate for the upstream Cursor import PR). + * + * For every legacy Cursor chat at ~/.cursor/chats///store.db, this + * tool stages an isolated $HOME/$HAPI_HOME, copies the store to its ACP + * location, synthesizes meta.json, and drives `agent acp` through + * `initialize` + `session/load`. Each verify uses its own temp HOME so + * multiple verifies can run in parallel without colliding on the + * agent-acp-active lock or polluting ~/.cursor. + * + * The pass-rate decides whether the strict "ACP or unimportable" UX of the + * upstream Cursor import PR is viable. See: + * docs/plans/2026-06-08-upstream-cursor-import-acp-only.md (Pre-PR audit) + * docs/plans/2026-06-08-cursor-import-peer-briefing.md (gate logic) + * + * Usage: + * bun scripts/audit-cursor-acp-verify.ts # full run, default CSV + * bun scripts/audit-cursor-acp-verify.ts --limit 5 # smoke + * bun scripts/audit-cursor-acp-verify.ts --concurrency 4 --csv /path/out.csv + * bun scripts/audit-cursor-acp-verify.ts --uuid # single chat + * + * Outcomes (mirrors the migrator's refusal contract): + * ok - initialize + session/load both succeeded + * verify_init_failed - initialize RPC failed + * verify_load_failed - session/load RPC failed + * verify_timeout - any RPC timed out + * spawn_failed - agent binary missing / spawn errored + * corrupted_store - sqlite open / sanity check failed + * probe_crash - probe exited mid-RPC + * + * Output CSV columns: + * wsh,uuid,store_size_bytes,store_mtime_iso,result,duration_ms,error_tail + */ + +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' +import { + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, + appendFileSync +} from 'node:fs' +import { homedir, tmpdir } from 'node:os' +import { join, delimiter as pathDelimiter } from 'node:path' +import { Database } from 'bun:sqlite' + +const AUTH_FILES = ['cli-config.json', 'agent-cli-state.json', 'acp-config.json'] +const DEFAULT_INIT_TIMEOUT_MS = 20_000 +const DEFAULT_LOAD_TIMEOUT_MS = 30_000 +const REPLAY_DRAIN_MS = 1_500 + +// ---------------------------- arg parsing ---------------------------- + +interface AuditArgs { + limit: number | null + concurrency: number + csvPath: string + onlyUuid: string | null + runPrompt: boolean + chatsRoot: string + verbose: boolean +} + +function parseArgs(argv: string[]): AuditArgs { + const args: AuditArgs = { + limit: null, + concurrency: 1, + csvPath: '/home/heavygee/coding/hapi/docs/plans/2026-06-08-cursor-acp-verify-audit.csv', + onlyUuid: null, + runPrompt: false, + chatsRoot: join(homedir(), '.cursor', 'chats'), + verbose: false + } + for (let i = 0; i < argv.length; i++) { + const a = argv[i] + const next = () => argv[++i] + if (a === '--limit') args.limit = Number(next()) + else if (a === '--concurrency') args.concurrency = Math.max(1, Number(next())) + else if (a === '--csv') args.csvPath = next() + else if (a === '--uuid') args.onlyUuid = next() + else if (a === '--prompt') args.runPrompt = true + else if (a === '--chats-root') args.chatsRoot = next() + else if (a === '--verbose' || a === '-v') args.verbose = true + else if (a === '--help' || a === '-h') { + console.log(`audit-cursor-acp-verify - run agent acp verify against every legacy chat + +Options: + --limit N cap chats audited (smoke test) + --concurrency N parallel verifies (default 1) + --csv PATH output CSV path + --uuid ID audit a single chat + --prompt also run a tiny session/prompt (token cost) + --chats-root PATH override ~/.cursor/chats + --verbose per-chat progress to stderr`) + process.exit(0) + } + } + return args +} + +// ---------------------------- discovery ---------------------------- + +interface ChatRecord { + wsh: string + uuid: string + storeDbPath: string + sizeBytes: number + mtimeIso: string +} + +function discoverChats(root: string): ChatRecord[] { + const out: ChatRecord[] = [] + if (!existsSync(root)) return out + for (const wsh of readdirSync(root)) { + const wshDir = join(root, wsh) + let wshStat + try { + wshStat = statSync(wshDir) + } catch { + continue + } + if (!wshStat.isDirectory()) continue + let entries: string[] + try { + entries = readdirSync(wshDir) + } catch { + continue + } + for (const uuid of entries) { + const dbPath = join(wshDir, uuid, 'store.db') + try { + const s = statSync(dbPath) + if (!s.isFile()) continue + out.push({ + wsh, + uuid, + storeDbPath: dbPath, + sizeBytes: s.size, + mtimeIso: s.mtime.toISOString() + }) + } catch { + // missing store.db, skip + } + } + } + out.sort((a, b) => (a.mtimeIso < b.mtimeIso ? 1 : -1)) + return out +} + +// ---------------------------- store sanity ---------------------------- + +function storeSanityCheck(storeDbPath: string): { ok: true } | { ok: false; message: string } { + try { + const db = new Database(storeDbPath, { readonly: true }) + try { + db.query("SELECT name FROM sqlite_master WHERE type='table' LIMIT 1").get() + return { ok: true } + } finally { + db.close() + } + } catch (err) { + return { ok: false, message: err instanceof Error ? err.message : String(err) } + } +} + +// ---------------------------- minimal ACP probe ---------------------------- + +type RpcResponse = + | { ok: true; result: Record } + | { ok: false; error: { code: number; message: string; data?: unknown } } + +interface RpcNotification { + method: string + params: Record +} + +class MinimalAcpProbe { + private proc: ChildProcessWithoutNullStreams | null = null + private nextId = 0 + private buf = '' + private readonly pending = new Map void; timer: ReturnType }>() + private readonly notifications: RpcNotification[] = [] + private stderr = '' + private exited = false + + constructor(private readonly env: NodeJS.ProcessEnv, private readonly agentLookupHome: string) {} + + start(): void { + const lookupHome = this.agentLookupHome || process.env.HOME || '' + const cursorBins = lookupHome + ? [join(lookupHome, '.local', 'bin'), join(lookupHome, '.npm-global', 'bin')] + : [] + const existingPath = this.env.PATH ?? '' + const augmentedPath = [existingPath, ...cursorBins].filter(Boolean).join(pathDelimiter) + const spawnEnv = { ...this.env, PATH: augmentedPath } + const proc = spawn('agent', ['acp'], { stdio: ['pipe', 'pipe', 'pipe'], env: spawnEnv }) + this.proc = proc + proc.stdout.on('data', (b: Buffer) => this.handleStdout(b.toString('utf8'))) + proc.stderr.on('data', (b: Buffer) => { + this.stderr += b.toString('utf8') + if (this.stderr.length > 4096) this.stderr = this.stderr.slice(-4096) + }) + proc.on('error', (err) => this.failPending(err)) + proc.on('exit', () => { + this.exited = true + if (this.pending.size > 0) this.failPending(new Error('agent acp exited mid-RPC')) + }) + } + + async stop(): Promise { + const p = this.proc + this.proc = null + if (!p) return + try { + p.kill('SIGTERM') + } catch {} + if (!this.exited) { + await new Promise((resolve) => { + let done = false + const fin = () => { + if (done) return + done = true + resolve() + } + p.once('exit', fin) + p.once('close', fin) + setTimeout(fin, 5000) + }) + } + this.failPending(new Error('agent acp killed by stop()')) + } + + getStderrTail(n = 256): string { + return this.stderr.slice(-n).replace(/\s+/g, ' ').trim() + } + + initialize(timeoutMs = DEFAULT_INIT_TIMEOUT_MS): Promise { + return this.send( + 'initialize', + { + protocolVersion: 1, + clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false }, + clientInfo: { name: 'hapi-cursor-acp-verify-audit', version: '1' } + }, + timeoutMs + ) + } + + async loadSession( + params: { sessionId: string; cwd: string }, + timeoutMs = DEFAULT_LOAD_TIMEOUT_MS, + replayDrainMs = REPLAY_DRAIN_MS + ): Promise<{ response: RpcResponse; notificationCount: number; durationMs: number }> { + const t0 = Date.now() + const before = this.notifications.length + const response = await this.send( + 'session/load', + { sessionId: params.sessionId, cwd: params.cwd, mcpServers: [] }, + timeoutMs + ) + if (!response.ok) { + return { response, notificationCount: 0, durationMs: Date.now() - t0 } + } + if (replayDrainMs > 0) await sleep(replayDrainMs) + return { + response, + notificationCount: this.notifications.length - before, + durationMs: Date.now() - t0 + } + } + + private send(method: string, params: unknown, timeoutMs: number): Promise { + if (!this.proc || this.exited) { + return Promise.resolve({ ok: false, error: { code: -32603, message: 'agent acp not running' } }) + } + const id = ++this.nextId + const stdin = this.proc.stdin + const req = { jsonrpc: '2.0', id, method, params } + return new Promise((resolve) => { + const timer = setTimeout(() => { + this.pending.delete(id) + resolve({ + ok: false, + error: { code: -32603, message: `timeout ${method} after ${timeoutMs}ms`, data: { stderr_tail: this.getStderrTail(512) } } + }) + }, timeoutMs) + this.pending.set(id, { resolve, timer }) + try { + stdin.write(`${JSON.stringify(req)}\n`) + } catch (err) { + clearTimeout(timer) + this.pending.delete(id) + resolve({ + ok: false, + error: { code: -32603, message: `stdin write failed: ${err instanceof Error ? err.message : String(err)}` } + }) + } + }) + } + + private handleStdout(chunk: string): void { + this.buf += chunk + let idx: number + while ((idx = this.buf.indexOf('\n')) !== -1) { + const line = this.buf.slice(0, idx).trim() + this.buf = this.buf.slice(idx + 1) + if (!line) continue + let msg: Record + try { + msg = JSON.parse(line) as Record + } catch { + continue + } + const id = msg.id + if (typeof id === 'number' && this.pending.has(id)) { + const entry = this.pending.get(id)! + this.pending.delete(id) + clearTimeout(entry.timer) + if (msg.error && typeof msg.error === 'object') { + const err = msg.error as Record + entry.resolve({ + ok: false, + error: { + code: typeof err.code === 'number' ? err.code : -32603, + message: typeof err.message === 'string' ? err.message : 'agent acp error', + data: err.data + } + }) + } else if (msg.result && typeof msg.result === 'object') { + entry.resolve({ ok: true, result: msg.result as Record }) + } else { + entry.resolve({ ok: false, error: { code: -32603, message: 'malformed agent acp response' } }) + } + } else if (typeof msg.method === 'string' && msg.params && typeof msg.params === 'object') { + this.notifications.push({ method: msg.method as string, params: msg.params as Record }) + } + } + } + + private failPending(err: Error): void { + for (const [id, entry] of this.pending.entries()) { + clearTimeout(entry.timer) + entry.resolve({ ok: false, error: { code: -32603, message: err.message } }) + this.pending.delete(id) + } + } +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)) +} + +// ---------------------------- per-chat verify ---------------------------- + +interface AuditOutcome { + result: + | 'ok' + | 'verify_init_failed' + | 'verify_load_failed' + | 'verify_timeout' + | 'spawn_failed' + | 'corrupted_store' + | 'probe_crash' + durationMs: number + errorTail: string +} + +async function auditOne(chat: ChatRecord, args: AuditArgs): Promise { + const t0 = Date.now() + const sanity = storeSanityCheck(chat.storeDbPath) + if (!sanity.ok) { + return { result: 'corrupted_store', durationMs: Date.now() - t0, errorTail: shorten(sanity.message) } + } + + const tmpRoot = mkdtempSync(join(tmpdir(), `hapi-acp-audit-${chat.uuid.slice(0, 8)}-`)) + const fakeAcpSessionDir = join(tmpRoot, '.cursor', 'acp-sessions', chat.uuid) + try { + mkdirSync(fakeAcpSessionDir, { recursive: true }) + copyFileSync(chat.storeDbPath, join(fakeAcpSessionDir, 'store.db')) + writeFileSync( + join(fakeAcpSessionDir, 'meta.json'), + JSON.stringify({ schemaVersion: 1, cwd: tmpRoot }) + ) + const realCursor = join(homedir(), '.cursor') + const fakeCursor = join(tmpRoot, '.cursor') + for (const f of AUTH_FILES) { + const src = join(realCursor, f) + if (existsSync(src)) { + try { copyFileSync(src, join(fakeCursor, f)) } catch {} + } + } + const env: NodeJS.ProcessEnv = { + ...process.env, + HOME: tmpRoot, + HAPI_HOME: tmpRoot, + NO_COLOR: '1' + } + const probe = new MinimalAcpProbe(env, homedir()) + try { + try { + probe.start() + } catch (err) { + return { + result: 'spawn_failed', + durationMs: Date.now() - t0, + errorTail: shorten(err instanceof Error ? err.message : String(err)) + } + } + const initResp = await probe.initialize() + if (!initResp.ok) { + const isTimeout = /^timeout /.test(initResp.error.message) + return { + result: isTimeout ? 'verify_timeout' : 'verify_init_failed', + durationMs: Date.now() - t0, + errorTail: shorten(`${initResp.error.message} | stderr=${probe.getStderrTail(256)}`) + } + } + const load = await probe.loadSession({ sessionId: chat.uuid, cwd: tmpRoot }) + if (!load.response.ok) { + const isTimeout = /^timeout /.test(load.response.error.message) + return { + result: isTimeout ? 'verify_timeout' : 'verify_load_failed', + durationMs: Date.now() - t0, + errorTail: shorten(`${load.response.error.message} | stderr=${probe.getStderrTail(256)}`) + } + } + return { result: 'ok', durationMs: Date.now() - t0, errorTail: '' } + } finally { + await probe.stop() + } + } catch (err) { + return { + result: 'probe_crash', + durationMs: Date.now() - t0, + errorTail: shorten(err instanceof Error ? err.message : String(err)) + } + } finally { + try { rmSync(tmpRoot, { recursive: true, force: true }) } catch {} + } +} + +function shorten(s: string): string { + return s.replace(/\s+/g, ' ').slice(0, 400) +} + +function csvEscape(s: string): string { + if (/[,"\n]/.test(s)) return `"${s.replace(/"/g, '""')}"` + return s +} + +// ---------------------------- main ---------------------------- + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)) + let chats = discoverChats(args.chatsRoot) + if (args.onlyUuid) chats = chats.filter((c) => c.uuid === args.onlyUuid) + if (args.limit) chats = chats.slice(0, args.limit) + if (chats.length === 0) { + console.error('no chats discovered; nothing to audit') + process.exit(2) + } + + mkdirSync(join(args.csvPath, '..'), { recursive: true }) + writeFileSync(args.csvPath, 'wsh,uuid,store_size_bytes,store_mtime_iso,result,duration_ms,error_tail\n') + + const summary: Record = {} + const startedAt = Date.now() + let done = 0 + + const queue = [...chats] + const inflight: Promise[] = [] + const next = (): Promise | null => { + const c = queue.shift() + if (!c) return null + return (async () => { + const outcome = await auditOne(c, args) + summary[outcome.result] = (summary[outcome.result] ?? 0) + 1 + const row = [ + c.wsh, + c.uuid, + String(c.sizeBytes), + c.mtimeIso, + outcome.result, + String(outcome.durationMs), + csvEscape(outcome.errorTail) + ].join(',') + appendFileSync(args.csvPath, row + '\n') + done += 1 + if (args.verbose || done % 20 === 0 || done === chats.length) { + const pct = Math.round((done / chats.length) * 100) + const elapsedSec = Math.round((Date.now() - startedAt) / 1000) + console.error( + `[${done}/${chats.length} ${pct}%] ${elapsedSec}s elapsed | ${outcome.result.padEnd(20)} ${c.uuid} (${formatBytes(c.sizeBytes)}, ${outcome.durationMs}ms)` + ) + } + })() + } + for (let i = 0; i < args.concurrency; i++) { + const p = next() + if (p) inflight.push(p.then(async function loop(): Promise { + const n = next() + if (n) await n.then(loop) + })) + } + await Promise.all(inflight) + + const total = chats.length + const okCount = summary.ok ?? 0 + const passRate = total > 0 ? (okCount / total) * 100 : 0 + console.error('\n=== AUDIT SUMMARY ===') + console.error(`total: ${total}`) + for (const [k, v] of Object.entries(summary).sort((a, b) => b[1] - a[1])) { + console.error(` ${k.padEnd(22)} ${v} (${((v / total) * 100).toFixed(1)}%)`) + } + console.error(`PASS RATE: ${passRate.toFixed(1)}%`) + console.error(`elapsed: ${Math.round((Date.now() - startedAt) / 1000)}s`) + console.error(`csv: ${args.csvPath}`) + + process.exit(passRate >= 90 ? 0 : 1) +} + +function formatBytes(b: number): string { + if (b < 1024) return `${b}B` + if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)}KB` + return `${(b / 1024 / 1024).toFixed(1)}MB` +} + +main().catch((err) => { + console.error('audit fatal:', err) + process.exit(2) +}) diff --git a/scripts/dev/agent-import-picker-handoff.mjs b/scripts/dev/agent-import-picker-handoff.mjs new file mode 100755 index 0000000000..5a97f4c58e --- /dev/null +++ b/scripts/dev/agent-import-picker-handoff.mjs @@ -0,0 +1,102 @@ +#!/usr/bin/env node +/** + * Playwright handoff capture for the multi-agent session import picker (#732). + * + * Opens the sessions index, clicks the import affordance, switches to the + * Cursor flavor tab (Codex | Cursor | Claude dialog), and writes a full-page PNG. + * + * Usage (from repo root, hub serving this branch's embedded web on :3076): + * CLI_API_TOKEN=demo-token node scripts/dev/agent-import-picker-handoff.mjs \ + * --base http://127.0.0.1:3076 \ + * --token demo-token \ + * --out localdocs/playwright-runs/agent-import-picker-handoff.png + */ +import { chromium } from 'playwright' +import { mkdirSync } from 'node:fs' +import { dirname, resolve } from 'node:path' + +function parseArgs(argv) { + const opts = { + base: 'http://127.0.0.1:3076', + token: process.env.CLI_API_TOKEN ?? '', + out: 'localdocs/playwright-runs/agent-import-picker-handoff.png', + timeout: 30_000 + } + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i] + if (arg === '--base') opts.base = argv[++i] + else if (arg === '--token') opts.token = argv[++i] + else if (arg === '--out') opts.out = argv[++i] + else if (arg === '--timeout') opts.timeout = Number(argv[++i]) + } + if (!opts.token) { + throw new Error('missing --token or CLI_API_TOKEN') + } + return opts +} + +function launchOptions() { + const chromePath = process.env.PLAYWRIGHT_CHROME_PATH?.trim() + if (chromePath) return { headless: true, executablePath: chromePath } + if (process.platform === 'linux') return { headless: true, channel: 'chrome' } + return { headless: true } +} + +const opts = parseArgs(process.argv.slice(2)) +const outPath = resolve(opts.out) +mkdirSync(dirname(outPath), { recursive: true }) + +const browser = await chromium.launch(launchOptions()) +const page = await browser.newPage({ viewport: { width: 1440, height: 1100 } }) +const consoleMessages = [] +const failedRequests = [] +page.on('console', (msg) => consoleMessages.push(`${msg.type()}: ${msg.text()}`)) +page.on('requestfailed', (req) => failedRequests.push(`${req.method()} ${req.url()} ${req.failure()?.errorText ?? ''}`)) + +try { + const url = `${opts.base.replace(/\/$/, '')}/sessions?token=${encodeURIComponent(opts.token)}` + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: opts.timeout }) + await page.waitForTimeout(1500) + + // Import affordance (toolbar button). + const importButton = page.getByRole('button', { name: /import sessions from another agent/i }) + await importButton.waitFor({ timeout: opts.timeout }) + await importButton.click() + + // Dialog + flavor tabs. + await page.getByRole('dialog').waitFor({ timeout: opts.timeout }) + await page.getByText('Import sessions').waitFor({ timeout: opts.timeout }) + await page.getByRole('tab', { name: 'Cursor' }).click() + + // Wait for cursor list or loading/empty state inside the dialog. + await page.getByText(/Loading local Cursor chats|No local Cursor chats|chats selected|Strict ACP-only/i) + .first() + .waitFor({ timeout: opts.timeout }) + + await page.screenshot({ path: outPath, fullPage: true }) + + const bodyText = await page.locator('body').innerText() + const result = { + ok: true, + screenshot: outPath, + url: page.url().replace(/([?&]token=)[^&]+/g, '$1'), + hasCursorTab: bodyText.includes('Cursor'), + hasAcpHint: /Strict ACP-only|acp verify-probe/i.test(bodyText), + consoleMessages, + failedRequests + } + console.log(JSON.stringify(result, null, 2)) + if (failedRequests.length > 0) process.exitCode = 2 +} catch (error) { + await page.screenshot({ path: outPath, fullPage: true }).catch(() => {}) + console.error(JSON.stringify({ + ok: false, + error: error instanceof Error ? error.message : String(error), + screenshot: outPath, + consoleMessages, + failedRequests + }, null, 2)) + process.exitCode = 1 +} finally { + await browser.close() +} diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index f1c7f271a2..62c3445df7 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -34,6 +34,10 @@ export const MetadataSchema = z.object({ summary: MetadataSummarySchema.optional(), machineId: z.string().optional(), claudeSessionId: z.string().optional(), + // Source session ID when this session was created by forking a live one + // (`claude --resume --fork-session`). Lets the web list mark the new + // session as a branch of `` instead of an unrelated duplicate. + forkedFrom: z.string().optional(), codexSessionId: z.string().optional(), geminiSessionId: z.string().optional(), opencodeSessionId: z.string().optional(), diff --git a/web/src/api/client.ts b/web/src/api/client.ts index ba33a89255..9246b1e5a5 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -8,6 +8,13 @@ import type { CodexDesktopSyncRequest, CodexDesktopStatusResponse, CodexCollaborationMode, + ClaudeLocalSessionsResponse, + ClaudeImportScriptResponse, + ClaudeImportSyncRequest, + ClaudeStatusResponse, + CursorImportableSessionsResponse, + CursorImportRequest, + CursorImportResponse, FileSearchResponse, MachinesResponse, MessagesResponse, @@ -237,6 +244,33 @@ export class ApiClient { }) } + async syncClaudeSession(payload?: ClaudeImportSyncRequest): Promise { + // 中文注释:提交本地 transcript 对应的 Claude session ID 列表,后端按这些会话直接导入到 Hapi。 + return await this.request('/api/claude/sync-session', { + method: 'POST', + ...(payload ? { body: JSON.stringify(payload) } : {}) + }) + } + + async getClaudeSessions(): Promise { + return await this.request('/api/claude/sessions') + } + + async getClaudeStatus(): Promise { + return await this.request('/api/claude/status') + } + + async getCursorImportableSessions(): Promise { + return await this.request('/api/cursor/importable-sessions') + } + + async importCursorSessions(payload: CursorImportRequest): Promise { + return await this.request('/api/cursor/import', { + method: 'POST', + body: JSON.stringify(payload) + }) + } + async unsubscribePushNotifications(payload: PushUnsubscribePayload): Promise { await this.request('/api/push/subscribe', { method: 'DELETE', diff --git a/web/src/components/AgentSessionImportDialog.test.tsx b/web/src/components/AgentSessionImportDialog.test.tsx new file mode 100644 index 0000000000..c10e54c523 --- /dev/null +++ b/web/src/components/AgentSessionImportDialog.test.tsx @@ -0,0 +1,223 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { I18nProvider } from '@/lib/i18n-context' +import { AgentSessionImportDialog } from './AgentSessionImportDialog' +import type { + AgentImportFlavor, + CodexLocalSessionSummary, + CursorImportableSessionSummary, + CursorImportRowOutcome, + ClaudeLocalSessionSummary +} from '@/types/api' + +interface RenderOpts { + flavor?: AgentImportFlavor + onChangeFlavor?: (flavor: AgentImportFlavor) => void + codexSessions?: CodexLocalSessionSummary[] + currentCodexSessionId?: string | null + isLoadingCodex?: boolean + isPendingCodex?: boolean + onConfirmCodex?: (sessionIds: string[]) => Promise + onRestartCodexDesktop?: () => Promise + cursorSessions?: CursorImportableSessionSummary[] + isLoadingCursor?: boolean + isPendingCursor?: boolean + cursorLastOutcomes?: CursorImportRowOutcome[] | null + onConfirmCursor?: (uuids: string[]) => Promise + claudeSessions?: ClaudeLocalSessionSummary[] + currentClaudeSessionId?: string | null + isLoadingClaude?: boolean + isPendingClaude?: boolean + onConfirmClaude?: (sessionIds: string[]) => Promise +} + +function renderDialog(opts: RenderOpts = {}) { + const onChangeFlavor = opts.onChangeFlavor ?? vi.fn() + const onConfirmCodex = opts.onConfirmCodex ?? vi.fn(async () => {}) + const onConfirmCursor = opts.onConfirmCursor ?? vi.fn(async () => {}) + const onConfirmClaude = opts.onConfirmClaude ?? vi.fn(async () => {}) + const view = render( + + + + ) + return { ...view, onChangeFlavor, onConfirmCodex, onConfirmCursor, onConfirmClaude } +} + +const codexSampleSession: CodexLocalSessionSummary = { + id: 'codex-session-1', + title: 'Codex session title', + lastUserMessage: 'Last prompt', + cwd: '/home/user/project', + file: '/home/user/.codex/sessions/session.jsonl', + modifiedAt: Date.UTC(2026, 0, 2, 3, 4, 5), + originator: 'codex_cli', + cliVersion: '0.124.0' +} + +const cursorSampleAcp: CursorImportableSessionSummary = { + id: 'cursor-acp-uuid', + title: 'Cursor ACP chat', + firstUserMessage: 'First user prompt', + workspacePath: '/home/user/repo', + storeDbPath: '/home/user/.cursor/acp-sessions/cursor-acp-uuid/store.db', + sourceFormat: 'acp', + modifiedAt: Date.UTC(2026, 0, 3, 3, 4, 5), + sizeBytes: 12_345, + alreadyImportedHapiSessionId: null +} + +const cursorSampleLegacyAlreadyImported: CursorImportableSessionSummary = { + id: 'cursor-legacy-uuid', + title: 'Cursor legacy chat', + workspacePath: '/home/user/other', + storeDbPath: '/home/user/.cursor/chats/wsh/cursor-legacy-uuid/store.db', + sourceFormat: 'legacy', + modifiedAt: Date.UTC(2026, 0, 4, 3, 4, 5), + sizeBytes: 7_777, + alreadyImportedHapiSessionId: 'hapi-existing-id' +} + +const claudeSampleSession: ClaudeLocalSessionSummary = { + id: 'claude-session-1', + title: 'Claude session title', + lastUserMessage: 'Claude prompt', + cwd: '/home/user/claude-project', + file: '/home/user/.claude/projects/session.jsonl', + modifiedAt: Date.UTC(2026, 0, 5, 3, 4, 5), + originator: 'claude_cli', + cliVersion: '1.0.0' +} + +describe('AgentSessionImportDialog', () => { + afterEach(() => { + cleanup() + }) + + it('shows the Codex panel by default and lists Codex sessions', () => { + renderDialog({ codexSessions: [codexSampleSession] }) + expect(screen.getByText('Codex session title')).toBeInTheDocument() + expect(screen.getAllByText('/home/user/project').length).toBeGreaterThan(0) + expect(screen.getByRole('tab', { name: 'Codex' })).toHaveAttribute('aria-selected', 'true') + expect(screen.getByRole('tab', { name: 'Cursor' })).toHaveAttribute('aria-selected', 'false') + }) + + it('switches to the Cursor panel when the flavor tab is clicked', () => { + const onChangeFlavor = vi.fn() + renderDialog({ flavor: 'codex', onChangeFlavor }) + fireEvent.click(screen.getByRole('tab', { name: 'Cursor' })) + expect(onChangeFlavor).toHaveBeenCalledWith('cursor') + }) + + it('switches to the Claude panel when the flavor tab is clicked', () => { + const onChangeFlavor = vi.fn() + renderDialog({ flavor: 'codex', onChangeFlavor }) + fireEvent.click(screen.getByRole('tab', { name: 'Claude' })) + expect(onChangeFlavor).toHaveBeenCalledWith('claude') + }) + + it('renders the Claude panel and confirms selection', async () => { + const onConfirmClaude = vi.fn(async () => {}) + renderDialog({ + flavor: 'claude', + claudeSessions: [claudeSampleSession], + onConfirmClaude + }) + expect(screen.getByText('Claude session title')).toBeInTheDocument() + fireEvent.click(screen.getByRole('checkbox')) + fireEvent.click(screen.getByText('Import')) + await waitFor(() => expect(onConfirmClaude).toHaveBeenCalledWith(['claude-session-1'])) + }) + + it('renders the Cursor panel with ACP / legacy badges and the ACP-strict hint', () => { + renderDialog({ + flavor: 'cursor', + cursorSessions: [cursorSampleAcp, cursorSampleLegacyAlreadyImported] + }) + expect(screen.getByText('Cursor ACP chat')).toBeInTheDocument() + expect(screen.getByText('Cursor legacy chat')).toBeInTheDocument() + expect(screen.getByText('ACP')).toBeInTheDocument() + expect(screen.getByText('Legacy')).toBeInTheDocument() + expect(screen.getByText('Already imported')).toBeInTheDocument() + // Strict ACP hint visible above the list. + expect(screen.getByText(/acp verify-probe/i)).toBeInTheDocument() + }) + + it('disables the already-imported row and skips it when selecting all', () => { + const onConfirmCursor = vi.fn(async () => {}) + renderDialog({ + flavor: 'cursor', + cursorSessions: [cursorSampleAcp, cursorSampleLegacyAlreadyImported], + onConfirmCursor + }) + fireEvent.click(screen.getByText('Select all')) + fireEvent.click(screen.getByText('Import')) + expect(onConfirmCursor).toHaveBeenCalledTimes(1) + expect(onConfirmCursor).toHaveBeenCalledWith(['cursor-acp-uuid']) + }) + + it('surfaces a per-row refusal chip when the last outcome failed', () => { + const lastOutcomes: CursorImportRowOutcome[] = [ + { + ok: false, + uuid: 'cursor-acp-uuid', + reason: 'verify_load_failed', + message: 'agent acp session/load failed: bad blob graph', + durationMs: 123 + } + ] + renderDialog({ + flavor: 'cursor', + cursorSessions: [cursorSampleAcp], + cursorLastOutcomes: lastOutcomes + }) + expect(screen.getByText('agent acp could not load this chat')).toBeInTheDocument() + expect(screen.getByText(/bad blob graph/)).toBeInTheDocument() + }) + + it('confirms Codex selection and forwards selected session ids', async () => { + const onConfirmCodex = vi.fn(async () => {}) + renderDialog({ + flavor: 'codex', + codexSessions: [codexSampleSession], + onConfirmCodex + }) + const checkbox = screen.getByRole('checkbox') + fireEvent.click(checkbox) + fireEvent.click(screen.getByText('Import')) + await waitFor(() => expect(onConfirmCodex).toHaveBeenCalled()) + expect(onConfirmCodex).toHaveBeenCalledWith(['codex-session-1']) + }) + + it('shows the loading state on the active flavor', () => { + renderDialog({ flavor: 'cursor', isLoadingCursor: true }) + expect(screen.getByText('Loading local Cursor chats…')).toBeInTheDocument() + }) + + it('disables flavor switching while an import is in flight', () => { + renderDialog({ flavor: 'codex', isPendingCodex: true }) + expect(screen.getByRole('tab', { name: 'Cursor' })).toBeDisabled() + }) +}) diff --git a/web/src/components/AgentSessionImportDialog.tsx b/web/src/components/AgentSessionImportDialog.tsx new file mode 100644 index 0000000000..4926fdbf2a --- /dev/null +++ b/web/src/components/AgentSessionImportDialog.tsx @@ -0,0 +1,414 @@ +import { useEffect, useMemo, useState } from 'react' +import type { + AgentImportFlavor, + CodexLocalSessionSummary, + CursorImportableSessionSummary, + CursorImportRefusalReason, + CursorImportRowOutcome, + ClaudeLocalSessionSummary +} from '@/types/api' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { SessionImportPicker, type ImportSessionSummary } from '@/components/SessionImportPicker' +import { useTranslation } from '@/lib/use-translation' + +const CODEX_IMPORT_PICKER_LABELS = { + selectedCount: 'codexSync.confirm.selectedCount', + selectAll: 'codexSync.confirm.selectAll', + clearAll: 'codexSync.confirm.clearAll', + cwdFilter: 'codexSync.confirm.cwdFilter', + cwdFilterAll: 'codexSync.confirm.cwdFilterAll', + cwd: 'codexSync.confirm.cwd', + current: 'codexSync.confirm.current', + loading: 'codexSync.confirm.loading', + empty: 'codexSync.confirm.empty', + emptyForWorkdir: 'codexSync.confirm.emptyForWorkdir' +} as const + +const CURSOR_IMPORT_PICKER_LABELS = { + selectedCount: 'cursorSync.confirm.selectedCount', + selectAll: 'cursorSync.confirm.selectAll', + clearAll: 'cursorSync.confirm.clearAll', + cwdFilter: 'cursorSync.confirm.cwdFilter', + cwdFilterAll: 'cursorSync.confirm.cwdFilterAll', + cwd: 'cursorSync.confirm.cwd', + current: 'cursorSync.confirm.current', + loading: 'cursorSync.confirm.loading', + empty: 'cursorSync.confirm.empty', + emptyForWorkdir: 'cursorSync.confirm.emptyForWorkdir' +} as const + +const CLAUDE_IMPORT_PICKER_LABELS = { + selectedCount: 'claudeSync.confirm.selectedCount', + selectAll: 'claudeSync.confirm.selectAll', + clearAll: 'claudeSync.confirm.clearAll', + cwdFilter: 'claudeSync.confirm.cwdFilter', + cwdFilterAll: 'claudeSync.confirm.cwdFilterAll', + cwd: 'claudeSync.confirm.cwd', + current: 'claudeSync.confirm.current', + loading: 'claudeSync.confirm.loading', + empty: 'claudeSync.confirm.empty', + emptyForWorkdir: 'claudeSync.confirm.emptyForWorkdir' +} as const + +function toCodexImportSession(session: CodexLocalSessionSummary): ImportSessionSummary { + return { + id: session.id, + title: session.title, + lastUserMessage: session.lastUserMessage, + cwd: session.cwd, + modifiedAt: session.modifiedAt, + originator: session.originator, + cliVersion: session.cliVersion + } +} + +function toCursorImportSession(session: CursorImportableSessionSummary): ImportSessionSummary { + return { + id: session.id, + title: session.title, + lastUserMessage: session.firstUserMessage, + cwd: session.workspacePath, + modifiedAt: session.modifiedAt + } +} + +function toClaudeImportSession(session: ClaudeLocalSessionSummary): ImportSessionSummary { + return { + id: session.id, + title: session.title, + lastUserMessage: session.lastUserMessage, + cwd: session.cwd, + modifiedAt: session.modifiedAt, + originator: session.originator, + cliVersion: session.cliVersion + } +} + +function cursorRefusalKey(reason: CursorImportRefusalReason): string { + return `cursorSync.refusal.${reason}` +} + +export function AgentSessionImportDialog(props: { + isOpen: boolean + onClose: () => void + flavor: AgentImportFlavor + onChangeFlavor: (flavor: AgentImportFlavor) => void + codexSessions: CodexLocalSessionSummary[] + currentCodexSessionId: string | null + isLoadingCodex: boolean + isPendingCodex: boolean + isRestartingCodexDesktop: boolean + onConfirmCodex: (sessionIds: string[]) => Promise + onRestartCodexDesktop: () => Promise + cursorSessions: CursorImportableSessionSummary[] + isLoadingCursor: boolean + isPendingCursor: boolean + cursorLastOutcomes: CursorImportRowOutcome[] | null + onConfirmCursor: (uuids: string[]) => Promise + claudeSessions: ClaudeLocalSessionSummary[] + currentClaudeSessionId: string | null + isLoadingClaude: boolean + isPendingClaude: boolean + onConfirmClaude: (sessionIds: string[]) => Promise +}) { + const { t } = useTranslation() + const { + isOpen, + onClose, + flavor, + onChangeFlavor, + codexSessions, + currentCodexSessionId, + isLoadingCodex, + isPendingCodex, + isRestartingCodexDesktop, + onConfirmCodex, + onRestartCodexDesktop, + cursorSessions, + isLoadingCursor, + isPendingCursor, + cursorLastOutcomes, + onConfirmCursor, + claudeSessions, + currentClaudeSessionId, + isLoadingClaude, + isPendingClaude, + onConfirmClaude + } = props + + const [selectedCodexIds, setSelectedCodexIds] = useState([]) + const [selectedCursorIds, setSelectedCursorIds] = useState([]) + const [selectedClaudeIds, setSelectedClaudeIds] = useState([]) + + const isPending = flavor === 'codex' + ? isPendingCodex + : flavor === 'cursor' + ? isPendingCursor + : isPendingClaude + const isLoading = flavor === 'codex' + ? isLoadingCodex + : flavor === 'cursor' + ? isLoadingCursor + : isLoadingClaude + + const codexImportSessions = useMemo( + () => codexSessions.map(toCodexImportSession), + [codexSessions] + ) + const cursorImportSessions = useMemo( + () => cursorSessions.map(toCursorImportSession), + [cursorSessions] + ) + const claudeImportSessions = useMemo( + () => claudeSessions.map(toClaudeImportSession), + [claudeSessions] + ) + + const cursorSessionsById = useMemo(() => { + const map = new Map() + for (const session of cursorSessions) { + map.set(session.id, session) + } + return map + }, [cursorSessions]) + + const outcomesByUuid = useMemo(() => { + const map = new Map() + for (const outcome of cursorLastOutcomes ?? []) { + map.set(outcome.uuid, outcome) + } + return map + }, [cursorLastOutcomes]) + + useEffect(() => { + if (!isOpen) { + setSelectedCodexIds([]) + setSelectedCursorIds([]) + setSelectedClaudeIds([]) + } + }, [isOpen]) + + const handleConfirm = async () => { + if (flavor === 'codex') { + if (selectedCodexIds.length === 0 || isPendingCodex || isLoadingCodex) return + await onConfirmCodex(selectedCodexIds) + return + } + if (flavor === 'cursor') { + if (selectedCursorIds.length === 0 || isPendingCursor || isLoadingCursor) return + await onConfirmCursor(selectedCursorIds) + return + } + if (selectedClaudeIds.length === 0 || isPendingClaude || isLoadingClaude) return + await onConfirmClaude(selectedClaudeIds) + } + + return ( + !open && onClose()}> + +
+ + {t('agentImport.confirm.title')} + + {flavor === 'codex' + ? t('codexSync.confirm.description') + : flavor === 'cursor' + ? t('cursorSync.confirm.description') + : t('claudeSync.confirm.description')} + + + {flavor === 'codex' ? ( + + ) : null} +
+ +
+ + + +
+ + {flavor === 'codex' ? ( + + ) : flavor === 'cursor' ? ( + <> +
+ {t('cursorSync.confirm.acpStrictHint')} +
+ { + const raw = cursorSessionsById.get(session.id) + return Boolean(raw?.alreadyImportedHapiSessionId) + }} + renderSessionBadges={(session) => { + const raw = cursorSessionsById.get(session.id) + if (!raw) return null + const outcome = outcomesByUuid.get(session.id) + return ( + <> + + {raw.sourceFormat === 'acp' + ? t('cursorSync.confirm.sourceAcp') + : t('cursorSync.confirm.sourceLegacy')} + + {raw.alreadyImportedHapiSessionId ? ( + + {t('cursorSync.confirm.alreadyImported')} + + ) : null} + {outcome?.ok ? ( + + {t('cursorSync.outcome.ok')} + + ) : null} + + ) + }} + renderSessionFooter={(session) => { + const outcome = outcomesByUuid.get(session.id) + if (!outcome || outcome.ok) return null + return ( +
+
+ {t(cursorRefusalKey(outcome.reason))} +
+
+ {outcome.message} +
+
+ ) + }} + /> + + ) : ( + + )} + +
+ + +
+
+
+ ) +} diff --git a/web/src/components/ClaudeSessionSyncDialog.test.tsx b/web/src/components/ClaudeSessionSyncDialog.test.tsx new file mode 100644 index 0000000000..0b9381f6a0 --- /dev/null +++ b/web/src/components/ClaudeSessionSyncDialog.test.tsx @@ -0,0 +1,127 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { I18nProvider } from '@/lib/i18n-context' +import { ClaudeSessionSyncDialog } from './ClaudeSessionSyncDialog' +import type { ClaudeLocalSessionSummary } from '@/types/api' + +function renderDialog( + sessions: ClaudeLocalSessionSummary[], + onConfirm = vi.fn(async () => {}), + currentClaudeSessionId: string | null = null +) { + const view = render( + + + + ) + return { ...view, onConfirm } +} + +describe('ClaudeSessionSyncDialog', () => { + afterEach(() => { + cleanup() + }) + + it('shows the working directory for local Claude sessions', () => { + renderDialog([ + { + id: 'claude-session-1', + title: 'Session title', + lastUserMessage: 'Last prompt', + cwd: '/home/user/project', + file: '/home/user/.claude/projects/-home-user-project/session.jsonl', + modifiedAt: Date.UTC(2026, 0, 2, 3, 4, 5), + originator: 'claude_code', + cliVersion: '1.0.0' + } + ]) + + expect(screen.getByText('Working directory')).toBeInTheDocument() + expect(screen.getAllByText('/home/user/project')).toHaveLength(2) + }) + + it('filters sessions by working directory', () => { + renderDialog([ + { + id: 'claude-session-1', + title: 'Project one', + cwd: '/home/user/project-one', + file: '/home/user/.claude/projects/-home-user-project-one/one.jsonl', + modifiedAt: Date.UTC(2026, 0, 2, 3, 4, 5) + }, + { + id: 'claude-session-2', + title: 'Project two', + cwd: '/home/user/project-two', + file: '/home/user/.claude/projects/-home-user-project-two/two.jsonl', + modifiedAt: Date.UTC(2026, 0, 3, 3, 4, 5) + } + ]) + + fireEvent.change(screen.getByLabelText('Work directory'), { + target: { value: '/home/user/project-two' } + }) + + expect(screen.queryByText('Project one')).not.toBeInTheDocument() + expect(screen.getByText('Project two')).toBeInTheDocument() + }) + + it('selects only filtered sessions when selecting all and confirms them', async () => { + const onConfirm = vi.fn(async () => {}) + renderDialog([ + { + id: 'claude-session-1', + title: 'Project one', + cwd: '/home/user/project-one', + file: '/home/user/.claude/projects/-home-user-project-one/one.jsonl', + modifiedAt: Date.UTC(2026, 0, 2, 3, 4, 5) + }, + { + id: 'claude-session-2', + title: 'Project two', + cwd: '/home/user/project-two', + file: '/home/user/.claude/projects/-home-user-project-two/two.jsonl', + modifiedAt: Date.UTC(2026, 0, 3, 3, 4, 5) + } + ], onConfirm) + + fireEvent.change(screen.getByLabelText('Work directory'), { + target: { value: '/home/user/project-two' } + }) + fireEvent.click(screen.getByRole('button', { name: 'Select all' })) + + await waitFor(() => { + expect(screen.getByText('1 sessions selected')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Import' })).toBeEnabled() + }) + + fireEvent.click(screen.getByRole('button', { name: 'Import' })) + + expect(onConfirm).toHaveBeenCalledWith(['claude-session-2']) + }) + + it('defaults selection to the linked current Claude session', async () => { + renderDialog([ + { + id: 'claude-session-1', + title: 'Project one', + cwd: '/home/user/project-one', + file: '/home/user/.claude/projects/-home-user-project-one/one.jsonl', + modifiedAt: Date.UTC(2026, 0, 2, 3, 4, 5) + } + ], vi.fn(async () => {}), 'claude-session-1') + + await waitFor(() => { + expect(screen.getByText('1 sessions selected')).toBeInTheDocument() + expect(screen.getByText('Linked')).toBeInTheDocument() + }) + }) +}) diff --git a/web/src/components/ClaudeSessionSyncDialog.tsx b/web/src/components/ClaudeSessionSyncDialog.tsx new file mode 100644 index 0000000000..70ad55e833 --- /dev/null +++ b/web/src/components/ClaudeSessionSyncDialog.tsx @@ -0,0 +1,103 @@ +import { useEffect, useState } from 'react' +import type { ClaudeLocalSessionSummary } from '@/types/api' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { SessionImportPicker } from '@/components/SessionImportPicker' +import { useTranslation } from '@/lib/use-translation' + +const CLAUDE_IMPORT_PICKER_LABELS = { + selectedCount: 'claudeSync.confirm.selectedCount', + selectAll: 'claudeSync.confirm.selectAll', + clearAll: 'claudeSync.confirm.clearAll', + cwdFilter: 'claudeSync.confirm.cwdFilter', + cwdFilterAll: 'claudeSync.confirm.cwdFilterAll', + cwd: 'claudeSync.confirm.cwd', + current: 'claudeSync.confirm.current', + loading: 'claudeSync.confirm.loading', + empty: 'claudeSync.confirm.empty', + emptyForWorkdir: 'claudeSync.confirm.emptyForWorkdir' +} as const + +export function ClaudeSessionSyncDialog(props: { + isOpen: boolean + onClose: () => void + sessions: ClaudeLocalSessionSummary[] + currentClaudeSessionId: string | null + onConfirm: (sessionIds: string[]) => Promise + isPending: boolean + isLoading: boolean +}) { + const { t } = useTranslation() + const { + isOpen, + sessions, + currentClaudeSessionId, + onConfirm, + isPending, + isLoading, + onClose + } = props + const [selectedSessionIds, setSelectedSessionIds] = useState([]) + + useEffect(() => { + if (!isOpen) { + setSelectedSessionIds([]) + } + }, [isOpen]) + + const handleConfirm = async () => { + if (selectedSessionIds.length === 0 || isPending || isLoading) return + + // 中文注释:确认按钮只提交用户勾选的 Claude session,落库由父组件统一处理并给出 toast 提示。 + await onConfirm(selectedSessionIds) + } + + return ( + !open && onClose()}> + + + {t('claudeSync.confirm.title')} + + {t('claudeSync.confirm.description')} + + + + + +
+ + +
+
+
+ ) +} diff --git a/web/src/components/CodexSessionSyncDialog.tsx b/web/src/components/CodexSessionSyncDialog.tsx index c7eefe6062..18332a1366 100644 --- a/web/src/components/CodexSessionSyncDialog.tsx +++ b/web/src/components/CodexSessionSyncDialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useState } from 'react' import type { CodexLocalSessionSummary } from '@/types/api' import { Dialog, @@ -8,28 +8,21 @@ import { DialogDescription } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' +import { SessionImportPicker } from '@/components/SessionImportPicker' import { useTranslation } from '@/lib/use-translation' -const ALL_WORKDIR_FILTER = '__all__' - -function formatCodexSessionTime(value: number): string | null { - if (!Number.isFinite(value)) return null - return new Date(value).toLocaleString() -} - -function getCodexSessionPreview(session: CodexLocalSessionSummary): string { - if (session.lastUserMessage?.trim()) { - return session.lastUserMessage.trim() - } - - const parts = [session.originator, session.cliVersion].filter(Boolean) - return parts.join(' · ') -} - -function getCodexSessionCwd(session: CodexLocalSessionSummary): string | null { - const cwd = session.cwd?.trim() - return cwd ? cwd : null -} +const CODEX_IMPORT_PICKER_LABELS = { + selectedCount: 'codexSync.confirm.selectedCount', + selectAll: 'codexSync.confirm.selectAll', + clearAll: 'codexSync.confirm.clearAll', + cwdFilter: 'codexSync.confirm.cwdFilter', + cwdFilterAll: 'codexSync.confirm.cwdFilterAll', + cwd: 'codexSync.confirm.cwd', + current: 'codexSync.confirm.current', + loading: 'codexSync.confirm.loading', + empty: 'codexSync.confirm.empty', + emptyForWorkdir: 'codexSync.confirm.emptyForWorkdir' +} as const export function CodexSessionSyncDialog(props: { isOpen: boolean @@ -55,83 +48,13 @@ export function CodexSessionSyncDialog(props: { onClose } = props const [selectedSessionIds, setSelectedSessionIds] = useState([]) - const [hasInitializedSelection, setHasInitializedSelection] = useState(false) - const [workdirFilter, setWorkdirFilter] = useState(ALL_WORKDIR_FILTER) - const wasOpenRef = useRef(false) - - const sessionIdSet = useMemo( - () => new Set(sessions.map((session) => session.id)), - [sessions] - ) - const selectedSessionIdSet = useMemo( - () => new Set(selectedSessionIds), - [selectedSessionIds] - ) - const workdirOptions = useMemo(() => { - const directories = new Set() - for (const session of sessions) { - const cwd = getCodexSessionCwd(session) - if (cwd) directories.add(cwd) - } - return Array.from(directories).sort((a, b) => a.localeCompare(b)) - }, [sessions]) - const filteredSessions = useMemo(() => { - if (workdirFilter === ALL_WORKDIR_FILTER) return sessions - return sessions.filter((session) => getCodexSessionCwd(session) === workdirFilter) - }, [sessions, workdirFilter]) useEffect(() => { - if (isOpen && !wasOpenRef.current) { - wasOpenRef.current = true - setSelectedSessionIds([]) - setHasInitializedSelection(false) - setWorkdirFilter(ALL_WORKDIR_FILTER) - return - } - - if (!isOpen && wasOpenRef.current) { - wasOpenRef.current = false + if (!isOpen) { setSelectedSessionIds([]) - setHasInitializedSelection(false) - setWorkdirFilter(ALL_WORKDIR_FILTER) } }, [isOpen]) - useEffect(() => { - if (workdirFilter === ALL_WORKDIR_FILTER) return - if (workdirOptions.includes(workdirFilter)) return - setWorkdirFilter(ALL_WORKDIR_FILTER) - }, [workdirFilter, workdirOptions]) - - useEffect(() => { - if (!isOpen || isLoading || hasInitializedSelection) return - - // 中文注释:弹窗打开后等本地 Codex 会话列表加载完成,再尝试默认勾选当前 Hapi 会话关联的 Codex thread,避免异步加载时默认值丢失。 - const defaultSelected = currentCodexSessionId && sessionIdSet.has(currentCodexSessionId) - ? [currentCodexSessionId] - : [] - setSelectedSessionIds(defaultSelected) - setHasInitializedSelection(true) - }, [currentCodexSessionId, hasInitializedSelection, isLoading, isOpen, sessionIdSet]) - - const toggleSession = (sessionId: string) => { - if (isPending || isLoading) return - - // 中文注释:列表项支持多选导入;再次点击同一行则取消勾选,便于快速调整导入批次。 - setSelectedSessionIds((current) => current.includes(sessionId) - ? current.filter((id) => id !== sessionId) - : [...current, sessionId]) - } - - const selectAll = () => { - setSelectedSessionIds(filteredSessions.map((session) => session.id)) - } - - const clearAll = () => { - // 中文注释:全取消放在左侧,和底部“取消 / 导入”的左右语义保持一致。 - setSelectedSessionIds([]) - } - const handleConfirm = async () => { if (selectedSessionIds.length === 0 || isPending || isLoading) return @@ -163,121 +86,16 @@ export function CodexSessionSyncDialog(props: { -
-
-
- {t('codexSync.confirm.selectedCount', { n: selectedSessionIds.length })} -
-
- - -
-
- - {sessions.length > 0 ? ( - - ) : null} - -
- {isLoading ? ( -
- {t('codexSync.confirm.loading')} -
- ) : sessions.length === 0 ? ( -
- {t('codexSync.confirm.empty')} -
- ) : filteredSessions.length === 0 ? ( -
- {t('codexSync.confirm.emptyForWorkdir')} -
- ) : ( -
- {filteredSessions.map((session) => { - const checked = selectedSessionIdSet.has(session.id) - const time = formatCodexSessionTime(session.modifiedAt) - const preview = getCodexSessionPreview(session) - const cwd = getCodexSessionCwd(session) - return ( - - ) - })} -
- )} -
-
+
+ +
+ + + {sessions.length > 0 ? ( + + ) : null} + +
+ {isLoading ? ( +
+ {t(labels.loading)} +
+ ) : sessions.length === 0 ? ( +
+ {t(labels.empty)} +
+ ) : filteredSessions.length === 0 ? ( +
+ {t(labels.emptyForWorkdir)} +
+ ) : ( +
+ {filteredSessions.map((session) => { + const disabled = isSessionDisabled?.(session) ?? false + const checked = !disabled && selectedSessionIdSet.has(session.id) + const time = formatSessionTime(session.modifiedAt) + const preview = getSessionPreview(session) + const cwd = getSessionCwd(session) + return ( + + ) + })} +
+ )} +
+ + ) +} diff --git a/web/src/lib/claudeImportedSessions.ts b/web/src/lib/claudeImportedSessions.ts new file mode 100644 index 0000000000..57fb5b8013 --- /dev/null +++ b/web/src/lib/claudeImportedSessions.ts @@ -0,0 +1,94 @@ +const CLAUDE_IMPORTED_SESSIONS_STORAGE_KEY = 'hapi.claudeImportedSessions' +const CLAUDE_IMPORTED_SESSIONS_EVENT = 'hapi:claude-imported-sessions-updated' + +type ClaudeImportedSessionsMap = Record + +function isBrowser(): boolean { + return typeof window !== 'undefined' && typeof localStorage !== 'undefined' +} + +function dispatchClaudeImportedSessionsChanged(): void { + if (!isBrowser()) return + window.dispatchEvent(new CustomEvent(CLAUDE_IMPORTED_SESSIONS_EVENT)) +} + +export function readClaudeImportedSessions(): ClaudeImportedSessionsMap { + if (!isBrowser()) return {} + try { + const raw = localStorage.getItem(CLAUDE_IMPORTED_SESSIONS_STORAGE_KEY) + if (!raw) return {} + const parsed = JSON.parse(raw) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return {} + } + + const result: ClaudeImportedSessionsMap = {} + for (const [key, value] of Object.entries(parsed)) { + if (typeof key === 'string' && typeof value === 'number' && Number.isFinite(value)) { + result[key] = value + } + } + return result + } catch { + return {} + } +} + +function writeClaudeImportedSessions(map: ClaudeImportedSessionsMap): void { + if (!isBrowser()) return + localStorage.setItem(CLAUDE_IMPORTED_SESSIONS_STORAGE_KEY, JSON.stringify(map)) + dispatchClaudeImportedSessionsChanged() +} + +export function markClaudeSessionsImported(claudeSessionIds: string[], importedAt = Date.now()): void { + if (!isBrowser() || claudeSessionIds.length === 0) return + + // 中文注释:以 Claude session ID 为 key 记录导入时间,便于会话列表把时间文案切换成“从 Claude 导入”。 + const next = readClaudeImportedSessions() + for (const claudeSessionId of claudeSessionIds) { + const trimmed = claudeSessionId.trim() + if (trimmed) { + next[trimmed] = importedAt + } + } + writeClaudeImportedSessions(next) +} + +export function clearClaudeImportedSession(claudeSessionId: string | null | undefined): void { + if (!isBrowser() || !claudeSessionId) return + + const next = readClaudeImportedSessions() + if (!(claudeSessionId in next)) return + + // 中文注释:当用户已经在 Hapi 内继续这个会话后,移除导入标记,列表时间恢复为普通“xx 分钟前”。 + delete next[claudeSessionId] + writeClaudeImportedSessions(next) +} + +export function getClaudeImportedAt(claudeSessionId: string | null | undefined): number | null { + if (!claudeSessionId) return null + const importedAt = readClaudeImportedSessions()[claudeSessionId] + return typeof importedAt === 'number' && Number.isFinite(importedAt) ? importedAt : null +} + +export function subscribeClaudeImportedSessions(onChange: () => void): () => void { + if (!isBrowser()) { + return () => {} + } + + const handleStorage = (event: StorageEvent) => { + if (event.key === CLAUDE_IMPORTED_SESSIONS_STORAGE_KEY) { + onChange() + } + } + const handleCustomEvent = () => { + onChange() + } + + window.addEventListener('storage', handleStorage) + window.addEventListener(CLAUDE_IMPORTED_SESSIONS_EVENT, handleCustomEvent) + return () => { + window.removeEventListener('storage', handleStorage) + window.removeEventListener(CLAUDE_IMPORTED_SESSIONS_EVENT, handleCustomEvent) + } +} diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index d5f059c80c..d86ba08dd2 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -97,6 +97,69 @@ export default { 'codexSync.duplicates.merge.failed.title': 'Failed to merge duplicate sessions', 'codexSync.duplicates.merge.failed.body': 'Failed to merge duplicate sessions.', + // Multi-agent import (codex + cursor tabs) + 'agentImport.tooltip': 'Import sessions from another agent into Hapi', + 'agentImport.confirm.title': 'Import sessions', + 'agentImport.flavor.label': 'Choose source agent', + 'agentImport.flavor.codex': 'Codex', + 'agentImport.flavor.cursor': 'Cursor', + 'agentImport.flavor.claude': 'Claude', + + 'cursorSync.confirm.description': 'Choose Cursor chats to import into Hapi. Strict ACP-only: any chat that fails the ACP verify probe is unimportable.', + 'cursorSync.confirm.selectedCount': '{n} chats selected', + 'cursorSync.confirm.selectAll': 'Select all', + 'cursorSync.confirm.clearAll': 'Clear all', + 'cursorSync.confirm.confirm': 'Import', + 'cursorSync.confirm.confirming': 'Importing…', + 'cursorSync.confirm.loading': 'Loading local Cursor chats…', + 'cursorSync.confirm.empty': 'No local Cursor chats found', + 'cursorSync.confirm.emptyForWorkdir': 'No Cursor chats in this workspace', + 'cursorSync.confirm.cwd': 'Workspace', + 'cursorSync.confirm.cwdFilter': 'Workspace', + 'cursorSync.confirm.cwdFilterAll': 'All workspaces', + 'cursorSync.confirm.sourceAcp': 'ACP', + 'cursorSync.confirm.sourceLegacy': 'Legacy', + 'cursorSync.confirm.alreadyImported': 'Already imported', + 'cursorSync.confirm.acpStrictHint': 'Imports run agent acp verify-probe before any Hapi row is created. Chats that fail are reported per-row with a reason; no partial state is left behind.', + 'cursorSync.outcome.ok': 'Imported', + 'cursorSync.success.title': 'Cursor import complete', + 'cursorSync.success.body': 'Imported {n} Cursor chat(s) into Hapi.', + 'cursorSync.partial.title': 'Cursor import partially succeeded', + 'cursorSync.partial.body': 'Imported {ok} of {total} Cursor chats. See per-row details for the rest.', + 'cursorSync.failed.title': 'Failed to import Cursor chats', + 'cursorSync.failed.body': 'Failed to import Cursor chats.', + 'cursorSync.refusal.verify_load_failed': 'agent acp could not load this chat', + 'cursorSync.refusal.missing_on_disk_store': 'On-disk store.db is missing', + 'cursorSync.refusal.target_already_exists': 'Target ACP session directory already exists', + 'cursorSync.refusal.already_imported': 'Already imported as a Hapi session', + 'cursorSync.refusal.agent_binary_not_found': '`agent` (cursor-agent) binary is not on PATH', + 'cursorSync.refusal.verify_timeout': 'agent acp verify timed out', + 'cursorSync.refusal.corrupted_store': 'On-disk store.db looks corrupted', + 'cursorSync.refusal.ambiguous_legacy_store': 'Multiple workspace drawers contain this chat; specify a workspace', + 'cursorSync.refusal.internal_error': 'Internal error during import', + + // Claude session import + 'claudeSync.tooltip': 'Import sessions from Claude into Hapi', + 'claudeSync.confirm.title': 'Import Claude sessions', + 'claudeSync.confirm.description': 'Choose Claude sessions to import into Hapi', + 'claudeSync.confirm.selectAll': 'Select all', + 'claudeSync.confirm.clearAll': 'Clear all', + 'claudeSync.confirm.selectedCount': '{n} sessions selected', + 'claudeSync.confirm.empty': 'No local Claude sessions found', + 'claudeSync.confirm.current': 'Linked', + 'claudeSync.confirm.cwd': 'Working directory', + 'claudeSync.confirm.cwdFilter': 'Work directory', + 'claudeSync.confirm.cwdFilterAll': 'All work directories', + 'claudeSync.confirm.emptyForWorkdir': 'No Claude sessions in this work directory', + 'claudeSync.confirm.confirm': 'Import', + 'claudeSync.confirm.confirming': 'Importing…', + 'claudeSync.confirm.loading': 'Loading local Claude sessions…', + 'claudeSync.success.title': 'Import complete', + 'claudeSync.success.body': 'Imported {n} Claude session(s) into Hapi.', + 'claudeSync.failed.title': 'Failed to import Claude sessions', + 'claudeSync.failed.body': 'Failed to import Claude sessions.', + 'claudeSync.failed.bodyWithReason': 'Import failed: {reason}', + // Session list 'session.item.path': 'path', 'session.item.agent': 'agent', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 5e63692e94..c935005fa3 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -97,6 +97,68 @@ export default { 'codexSync.duplicates.merge.failed.title': '重复会话合并失败', 'codexSync.duplicates.merge.failed.body': '重复会话合并失败。', + 'agentImport.tooltip': '从其他代理导入会话到 Hapi', + 'agentImport.confirm.title': '导入会话', + 'agentImport.flavor.label': '选择来源代理', + 'agentImport.flavor.codex': 'Codex', + 'agentImport.flavor.cursor': 'Cursor', + 'agentImport.flavor.claude': 'Claude', + + 'cursorSync.confirm.description': '选择要导入 Hapi 的 Cursor 聊天。严格 ACP 模式:任何无法通过 ACP 校验的会话都不可导入。', + 'cursorSync.confirm.selectedCount': '已选 {n} 个聊天', + 'cursorSync.confirm.selectAll': '全选', + 'cursorSync.confirm.clearAll': '清空', + 'cursorSync.confirm.confirm': '导入', + 'cursorSync.confirm.confirming': '导入中…', + 'cursorSync.confirm.loading': '正在加载本地 Cursor 聊天…', + 'cursorSync.confirm.empty': '未找到本地 Cursor 聊天', + 'cursorSync.confirm.emptyForWorkdir': '该工作目录下没有 Cursor 聊天', + 'cursorSync.confirm.cwd': '工作区', + 'cursorSync.confirm.cwdFilter': '工作区', + 'cursorSync.confirm.cwdFilterAll': '所有工作区', + 'cursorSync.confirm.sourceAcp': 'ACP', + 'cursorSync.confirm.sourceLegacy': '旧版', + 'cursorSync.confirm.alreadyImported': '已导入', + 'cursorSync.confirm.acpStrictHint': '导入前会运行 agent acp 校验探针;未通过的会话会逐条返回失败原因,不会留下半完成状态。', + 'cursorSync.outcome.ok': '已导入', + 'cursorSync.success.title': 'Cursor 导入完成', + 'cursorSync.success.body': '已将 {n} 个 Cursor 聊天导入到 Hapi。', + 'cursorSync.partial.title': 'Cursor 导入部分成功', + 'cursorSync.partial.body': '共 {total} 个聊天,成功 {ok} 个;其余请见每行原因。', + 'cursorSync.failed.title': '导入 Cursor 聊天失败', + 'cursorSync.failed.body': '导入 Cursor 聊天失败。', + 'cursorSync.refusal.verify_load_failed': 'agent acp 无法加载该聊天', + 'cursorSync.refusal.missing_on_disk_store': '本地未找到 store.db', + 'cursorSync.refusal.target_already_exists': 'ACP 目标目录已存在', + 'cursorSync.refusal.already_imported': '该会话已在 Hapi 中存在', + 'cursorSync.refusal.agent_binary_not_found': '在 PATH 中未找到 cursor-agent (`agent`) 可执行文件', + 'cursorSync.refusal.verify_timeout': 'agent acp 校验超时', + 'cursorSync.refusal.corrupted_store': '本地 store.db 似乎已损坏', + 'cursorSync.refusal.ambiguous_legacy_store': '同一会话存在于多个工作区目录;请指定工作区', + 'cursorSync.refusal.internal_error': '导入过程中发生内部错误', + + // Claude 会话导入 + 'claudeSync.tooltip': '从 Claude 导入会话到 Hapi', + 'claudeSync.confirm.title': '导入 Claude 会话', + 'claudeSync.confirm.description': '选择需要导入到 Hapi 的 Claude 会话', + 'claudeSync.confirm.selectAll': '全选', + 'claudeSync.confirm.clearAll': '全取消', + 'claudeSync.confirm.selectedCount': '已选择 {n} 个会话', + 'claudeSync.confirm.empty': '未找到本地 Claude 会话', + 'claudeSync.confirm.current': '当前关联', + 'claudeSync.confirm.cwd': '工作目录', + 'claudeSync.confirm.cwdFilter': '工作目录', + 'claudeSync.confirm.cwdFilterAll': '全部工作目录', + 'claudeSync.confirm.emptyForWorkdir': '此工作目录下没有 Claude 会话', + 'claudeSync.confirm.confirm': '导入', + 'claudeSync.confirm.confirming': '导入中…', + 'claudeSync.confirm.loading': '正在读取本地 Claude 会话…', + 'claudeSync.success.title': '导入完成', + 'claudeSync.success.body': '已导入 {n} 个 Claude 会话到 Hapi。', + 'claudeSync.failed.title': '导入 Claude 会话失败', + 'claudeSync.failed.body': '导入 Claude 会话失败。', + 'claudeSync.failed.bodyWithReason': '导入失败:{reason}', + // Session list 'session.item.path': '路径', 'session.item.agent': '代理', diff --git a/web/src/router.tsx b/web/src/router.tsx index 84e030a531..a67913ca17 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -15,7 +15,7 @@ import { getScrollRestorationKey } from '@/lib/scrollRestorationKey' import { App } from '@/App' import { SessionChat } from '@/components/SessionChat' import { SessionList } from '@/components/SessionList' -import { CodexSessionSyncDialog } from '@/components/CodexSessionSyncDialog' +import { AgentSessionImportDialog } from '@/components/AgentSessionImportDialog' import { ConfirmDialog } from '@/components/ui/ConfirmDialog' import { NewSession } from '@/components/NewSession' import { WorkspaceBrowser } from '@/components/WorkspaceBrowser' @@ -40,7 +40,8 @@ import { clearDraftsAfterSend } from '@/lib/clearDraftsAfterSend' import { inactiveSessionCanResume } from '@/lib/sessionResume' import { markSessionSeen } from '@/lib/sessionLastSeen' import { clearCodexImportedSession, markCodexSessionsImported } from '@/lib/codexImportedSessions' -import type { Machine, CodexDuplicateSessionGroup, CodexLocalSessionSummary } from '@/types/api' +import { clearClaudeImportedSession, markClaudeSessionsImported } from '@/lib/claudeImportedSessions' +import type { Machine, CodexDuplicateSessionGroup, CodexLocalSessionSummary, ClaudeLocalSessionSummary, AgentImportFlavor, CursorImportableSessionSummary, CursorImportRowOutcome } from '@/types/api' import FilesPage from '@/routes/sessions/files' import FilePage from '@/routes/sessions/file' import TerminalPage from '@/routes/sessions/terminal' @@ -170,6 +171,14 @@ function SessionsPage() { const [duplicateSessionGroups, setDuplicateSessionGroups] = useState([]) const [isDuplicateMergeConfirmOpen, setIsDuplicateMergeConfirmOpen] = useState(false) const [isMergingDuplicateSessions, setIsMergingDuplicateSessions] = useState(false) + const [isSyncingClaudeSession, setIsSyncingClaudeSession] = useState(false) + const [claudeSessions, setClaudeSessions] = useState([]) + const [isLoadingClaudeSessions, setIsLoadingClaudeSessions] = useState(false) + const [importFlavor, setImportFlavor] = useState('codex') + const [cursorSessions, setCursorSessions] = useState([]) + const [isLoadingCursorSessions, setIsLoadingCursorSessions] = useState(false) + const [isImportingCursorSessions, setIsImportingCursorSessions] = useState(false) + const [cursorLastOutcomes, setCursorLastOutcomes] = useState(null) const handleRefresh = useCallback(() => { void refetch() @@ -200,6 +209,9 @@ function SessionsPage() { const currentCodexSessionId = selectedSession?.metadata?.flavor === 'codex' ? (selectedSession.metadata.agentSessionId ?? null) : null + const currentClaudeSessionId = selectedSession?.metadata?.flavor === 'claude' + ? (selectedSession.metadata.agentSessionId ?? null) + : null const isSessionsIndex = pathname === '/sessions' || pathname === '/sessions/' const sidebar = useSidebarResize() const handleNewSessionInDirectory = useCallback((args: { machineId: string | null; directory: string }) => { @@ -349,10 +361,54 @@ function SessionsPage() { t ]) + const loadCursorImportableSessions = useCallback(async () => { + setIsLoadingCursorSessions(true) + try { + const result = await api.getCursorImportableSessions() + if (!result.success) { + throw new Error(result.error || t('cursorSync.failed.body')) + } + setCursorSessions(result.sessions) + } catch (error) { + setCursorSessions([]) + addToast({ + title: t('cursorSync.failed.title'), + body: error instanceof Error ? error.message : t('cursorSync.failed.body'), + sessionId: '', + url: '' + }) + } finally { + setIsLoadingCursorSessions(false) + } + }, [addToast, api, t]) + + const loadClaudeImportableSessions = useCallback(async () => { + setIsLoadingClaudeSessions(true) + try { + const result = await api.getClaudeSessions() + setClaudeSessions(result.sessions) + } catch (error) { + setClaudeSessions([]) + addToast({ + title: t('claudeSync.failed.title'), + body: t('claudeSync.failed.bodyWithReason', { + reason: error instanceof Error ? error.message : t('dialog.error.default') + }), + sessionId: '', + url: '' + }) + } finally { + setIsLoadingClaudeSessions(false) + } + }, [addToast, api, t]) + const openCodexImportDialog = useCallback(async () => { if (isLoadingCodexSessions) return setIsSyncConfirmOpen(true) + setCursorLastOutcomes(null) + void loadCursorImportableSessions() + void loadClaudeImportableSessions() setIsLoadingCodexSessions(true) try { const result = await api.getCodexSessions() @@ -372,7 +428,56 @@ function SessionsPage() { } finally { setIsLoadingCodexSessions(false) } - }, [addToast, api, formatCodexSyncFailureBody, isLoadingCodexSessions, normalizeCodexScriptError, t]) + }, [addToast, api, formatCodexSyncFailureBody, isLoadingCodexSessions, loadClaudeImportableSessions, loadCursorImportableSessions, normalizeCodexScriptError, t]) + + const handleImportCursorSessions = useCallback(async (uuids: string[]) => { + if (isImportingCursorSessions || isLoadingCursorSessions) return + setIsImportingCursorSessions(true) + try { + const result = await api.importCursorSessions({ uuids }) + if (!result.success) { + throw new Error(result.error || t('cursorSync.failed.body')) + } + setCursorLastOutcomes(result.results) + const okCount = result.importedCount + const total = result.results.length + if (okCount === total) { + addToast({ + title: t('cursorSync.success.title'), + body: t('cursorSync.success.body', { n: okCount }), + sessionId: '', + url: '' + }) + setIsSyncConfirmOpen(false) + } else { + addToast({ + title: t('cursorSync.partial.title'), + body: t('cursorSync.partial.body', { ok: okCount, total }), + sessionId: '', + url: '' + }) + } + await refetch() + void loadCursorImportableSessions() + } catch (error) { + addToast({ + title: t('cursorSync.failed.title'), + body: error instanceof Error ? error.message : t('cursorSync.failed.body'), + sessionId: '', + url: '' + }) + } finally { + setIsImportingCursorSessions(false) + } + }, [ + addToast, + api, + isImportingCursorSessions, + isLoadingCursorSessions, + loadCursorImportableSessions, + refetch, + t + ]) const handleImportCodexSessions = useCallback(async (sessionIds: string[]) => { if (isSyncingCodexSession || isLoadingCodexSessions) return @@ -453,6 +558,41 @@ function SessionsPage() { t ]) + const handleImportClaudeSessions = useCallback(async (sessionIds: string[]) => { + if (isSyncingClaudeSession || isLoadingClaudeSessions) return + + setIsSyncingClaudeSession(true) + try { + // 中文注释:弹窗提交本地 Claude session ID;后端直接读取这些 transcript 并导入到 Hapi。 + const result = await api.syncClaudeSession({ sessionIds }) + if (!result.success) { + throw new Error(result.error || t('claudeSync.failed.body')) + } + + addToast({ + title: t('claudeSync.success.title'), + body: t('claudeSync.success.body', { n: result.syncedCount ?? sessionIds.length }), + sessionId: '', + url: '' + }) + // 中文注释:导入成功后先在浏览器侧记住这些 Claude session 的导入时间,供左侧会话列表显示特殊时间文案。 + markClaudeSessionsImported(sessionIds) + setIsSyncConfirmOpen(false) + await refetch() + } catch (syncError) { + addToast({ + title: t('claudeSync.failed.title'), + body: t('claudeSync.failed.bodyWithReason', { + reason: syncError instanceof Error ? syncError.message : t('dialog.error.default') + }), + sessionId: '', + url: '' + }) + } finally { + setIsSyncingClaudeSession(false) + } + }, [addToast, api, isLoadingClaudeSessions, isSyncingClaudeSession, refetch, t]) + return ( <>
@@ -469,13 +609,13 @@ function SessionsPage() {
- {/* 中文注释:这里展示的是本地 Codex transcript 列表;默认尝试勾选当前 Hapi 会话关联的 Codex thread。 */} - setIsSyncConfirmOpen(false)} - sessions={codexSessions} + flavor={importFlavor} + onChangeFlavor={setImportFlavor} + codexSessions={codexSessions} currentCodexSessionId={currentCodexSessionId} - onConfirm={handleImportCodexSessions} - onRestartCodexDesktop={handleRestartCodexDesktop} - isPending={isSyncingCodexSession} + isLoadingCodex={isLoadingCodexSessions} + isPendingCodex={isSyncingCodexSession} isRestartingCodexDesktop={isRestartingCodexDesktop} - isLoading={isLoadingCodexSessions} + onConfirmCodex={handleImportCodexSessions} + onRestartCodexDesktop={handleRestartCodexDesktop} + cursorSessions={cursorSessions} + isLoadingCursor={isLoadingCursorSessions} + isPendingCursor={isImportingCursorSessions} + cursorLastOutcomes={cursorLastOutcomes} + onConfirmCursor={handleImportCursorSessions} + claudeSessions={claudeSessions} + currentClaudeSessionId={currentClaudeSessionId} + isLoadingClaude={isLoadingClaudeSessions} + isPendingClaude={isSyncingClaudeSession} + onConfirmClaude={handleImportClaudeSessions} /> 0} @@ -651,6 +803,8 @@ function SessionPage() { clearDraftsAfterSend(sentSessionId, sessionId) // 中文注释:一旦用户已经在 Hapi 内继续这个 Codex 会话,就清除"刚从 Codex 导入"的标记。 clearCodexImportedSession(session?.metadata?.codexSessionId) + // 中文注释:Claude 会话同理;用户在 Hapi 内继续后清除"刚从 Claude 导入"的标记。 + clearClaudeImportedSession(session?.metadata?.claudeSessionId) // A successful send supersedes any previously-rendered error // for that session. Other sessions' errors stay put. setSendErrors((prev) => { diff --git a/web/src/types/api.ts b/web/src/types/api.ts index b9f5d0f762..6cbe087db7 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -214,6 +214,108 @@ export type CodexMergeDuplicateSessionsResponse = { error: string } +export type ClaudeImportScriptResponse = { + success: boolean + message?: string + cwd?: string + error?: string + // 中文注释:多选导入时返回实际处理完成的 Claude 会话数量,用于前端提示本次导入条数。 + syncedCount?: number + // 中文注释:这里存放本次导入对应的 Claude session ID 列表,方便日志和排查 direct import 结果。 + sessionIds?: string[] +} + +export type ClaudeLocalSessionSummary = { + id: string + title: string + lastUserMessage?: string | null + cwd?: string | null + file: string + modifiedAt: number + originator?: string | null + cliVersion?: string | null +} + +export type ClaudeLocalSessionsResponse = { + success: true + sessions: ClaudeLocalSessionSummary[] +} + +export type ClaudeImportSyncRequest = { + // 中文注释:前端弹窗直接提交 Claude session ID,后端会按这些 transcript 直接导入到 Hapi。 + sessionIds: string[] +} + +export type ClaudeStatusResponse = { + success: true + claudeProjectsAvailable: boolean +} + +export type AgentImportFlavor = 'codex' | 'cursor' | 'claude' + +export type CursorImportSourceFormat = 'legacy' | 'acp' + +export type CursorImportRefusalReason = + | 'verify_load_failed' + | 'missing_on_disk_store' + | 'target_already_exists' + | 'already_imported' + | 'agent_binary_not_found' + | 'verify_timeout' + | 'corrupted_store' + | 'ambiguous_legacy_store' + | 'internal_error' + +export type CursorImportableSessionSummary = { + id: string + title: string + firstUserMessage?: string | null + workspacePath?: string | null + storeDbPath: string + sourceFormat: CursorImportSourceFormat + modifiedAt: number + sizeBytes: number + alreadyImportedHapiSessionId?: string | null +} + +export type CursorImportableSessionsResponse = { + success: true + sessions: CursorImportableSessionSummary[] +} | { + success: false + error: string +} + +export type CursorImportRowOutcome = + | { + ok: true + uuid: string + hapiSessionId: string + sourceFormat: CursorImportSourceFormat + durationMs: number + } + | { + ok: false + uuid: string + reason: CursorImportRefusalReason + message: string + durationMs: number + } + +export type CursorImportRequest = { + uuids: string[] + workspacePath?: string | null +} + +export type CursorImportResponse = { + success: true + results: CursorImportRowOutcome[] + importedCount: number +} | { + success: false + error: string +} + export type VisibilityPayload = { subscriptionId: string visibility: 'visible' | 'hidden'