diff --git a/.gitignore b/.gitignore index c91004379..80a8fb6af 100644 --- a/.gitignore +++ b/.gitignore @@ -88,3 +88,4 @@ apps/desktop/resources/whisper/* .claude/scheduled_tasks.lock apps/web/public/images/updatedImages/ +.derivedData/ diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index 41f1834b1..09c7cb0ca 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -2822,6 +2822,280 @@ describe("adeRpcServer", () => { }); }); + it("scopes external-sessions ADE actions to the caller's lane", async () => { + const fixture = createRuntime(); + const ownChat = { id: "chat-1", laneId: "lane-1", chatSessionId: "chat-1" }; + fixture.runtime.sessionService.get.mockImplementation((sessionId: string) => { + if (sessionId === "chat-1") return ownChat; + return null; + }); + const lane1Cwd = path.resolve(fixture.runtime.laneService.getLaneWorktreePath("lane-1")); + const lane2Cwd = path.resolve(fixture.runtime.laneService.getLaneWorktreePath("lane-2")); + const outsideCwd = path.resolve(fixture.runtime.projectRoot, "..", "outside-project"); + const isInside = (parent: string, candidate: string): boolean => { + const relative = path.relative(parent, candidate); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); + }; + const externalRows = [ + { provider: "claude", id: "own-session", cwd: lane1Cwd, title: "Own", preview: "own" }, + { provider: "codex", id: "own-codex", cwd: lane1Cwd, title: "Own Codex", preview: "own codex" }, + { provider: "claude", id: "other-lane-session", cwd: lane2Cwd, title: "Other lane", preview: "other" }, + { provider: "claude", id: "outside-session", cwd: outsideCwd, title: "Outside", preview: "outside" }, + { provider: "codex", id: "outside-codex", cwd: outsideCwd, title: "Outside Codex", preview: "outside codex" }, + ]; + const list = vi.fn(async (args?: { providers?: string[]; scope?: string }) => { + let rows = externalRows; + if (args?.providers?.length) { + rows = rows.filter((row) => args.providers!.includes(row.provider)); + } + if (args?.scope === "project") { + rows = rows.filter((row) => isInside(lane1Cwd, row.cwd)); + } + return rows; + }); + const importExternalSession = vi.fn(async (args: { + provider: string; + sessionId: string; + laneId: string; + target?: string; + enforceLaneScopeCwd?: string; + }) => { + const source = externalRows.find((row) => row.provider === args.provider && row.id === args.sessionId); + if (args.enforceLaneScopeCwd && (!source || !isInside(args.enforceLaneScopeCwd, source.cwd))) { + throw new Error("External session import is not permitted for this lane."); + } + return args.target === "chat" + ? { kind: "chat", chatSessionId: "chat-import", laneId: args.laneId } + : { + kind: "cli", + sessionId: "terminal-import", + ptyId: "pty-import", + laneId: args.laneId, + }; + }); + (fixture.runtime as any).externalSessionsService = { list, importExternalSession }; + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + await initialize(handler, { callerId: "agent-1", role: "agent", chatSessionId: "chat-1" }); + + const listed = await callTool(handler, "run_ade_action", { + domain: "external-sessions", + action: "list", + args: { scope: "all" }, + }); + + expect(listed?.isError).toBeUndefined(); + expect(list).toHaveBeenCalledWith(expect.objectContaining({ + scope: "project", + laneId: "lane-1", + cwd: lane1Cwd, + })); + expect(listed.structuredContent.result).toEqual([ + { provider: "claude", id: "own-session", cwd: lane1Cwd, title: "Own", preview: "own" }, + { provider: "codex", id: "own-codex", cwd: lane1Cwd, title: "Own Codex", preview: "own codex" }, + ]); + + const deniedOutsideCliResume = await callTool(handler, "run_ade_action", { + domain: "external-sessions", + action: "import", + args: { + provider: "codex", + sessionId: "outside-codex", + laneId: "lane-1", + target: "cli", + mode: "resume", + }, + }); + expect(deniedOutsideCliResume.isError).toBe(true); + expect(importExternalSession).toHaveBeenCalledWith({ + provider: "codex", + sessionId: "outside-codex", + laneId: "lane-1", + target: "cli", + mode: "resume", + enforceLaneScopeCwd: lane1Cwd, + }); + + const deniedOutsideCliFork = await callTool(handler, "run_ade_action", { + domain: "external-sessions", + action: "import", + args: { + provider: "claude", + sessionId: "outside-session", + laneId: "lane-1", + target: "cli", + mode: "fork", + }, + }); + expect(deniedOutsideCliFork.isError).toBe(true); + expect(importExternalSession).toHaveBeenCalledWith({ + provider: "claude", + sessionId: "outside-session", + laneId: "lane-1", + target: "cli", + mode: "fork", + enforceLaneScopeCwd: lane1Cwd, + }); + + const deniedOutsideChatImport = await callTool(handler, "run_ade_action", { + domain: "external-sessions", + action: "import", + args: { + provider: "claude", + sessionId: "outside-session", + laneId: "lane-1", + target: "chat", + mode: "resume", + }, + }); + expect(deniedOutsideChatImport.isError).toBe(true); + expect(deniedOutsideChatImport.error?.code).toBe(JsonRpcErrorCode.methodNotFound); + expect(importExternalSession).toHaveBeenCalledTimes(2); + + const importedOwnCliResume = await callTool(handler, "run_ade_action", { + domain: "external-sessions", + action: "import", + args: { + provider: "codex", + sessionId: "own-codex", + laneId: "lane-1", + target: "cli", + mode: "resume", + }, + }); + expect(importedOwnCliResume?.isError).toBeUndefined(); + expect(importExternalSession).toHaveBeenCalledWith({ + provider: "codex", + sessionId: "own-codex", + laneId: "lane-1", + target: "cli", + mode: "resume", + enforceLaneScopeCwd: lane1Cwd, + }); + + const importedOwnCliFork = await callTool(handler, "run_ade_action", { + domain: "external-sessions", + action: "import", + args: { + provider: "claude", + sessionId: "own-session", + laneId: "lane-1", + target: "cli", + mode: "fork", + }, + }); + expect(importedOwnCliFork?.isError).toBeUndefined(); + expect(importExternalSession).toHaveBeenCalledWith({ + provider: "claude", + sessionId: "own-session", + laneId: "lane-1", + target: "cli", + mode: "fork", + enforceLaneScopeCwd: lane1Cwd, + }); + + const importedOwnChat = await callTool(handler, "run_ade_action", { + domain: "external-sessions", + action: "import", + args: { + provider: "claude", + sessionId: "own-session", + laneId: "lane-1", + target: "chat", + mode: "resume", + }, + }); + expect(importedOwnChat?.isError).toBeUndefined(); + expect(importExternalSession).toHaveBeenCalledWith({ + provider: "claude", + sessionId: "own-session", + laneId: "lane-1", + target: "chat", + mode: "resume", + enforceLaneScopeCwd: lane1Cwd, + }); + + const deniedOtherLaneImport = await callTool(handler, "run_ade_action", { + domain: "external-sessions", + action: "import", + args: { + provider: "codex", + sessionId: "own-session", + laneId: "lane-2", + target: "cli", + mode: "resume", + }, + }); + expect(deniedOtherLaneImport.isError).toBe(true); + expect(importExternalSession).toHaveBeenCalledTimes(5); + }); + + it("allows CTO callers to use unscoped external-sessions ADE actions", async () => { + const fixture = createRuntime(); + const list = vi.fn(async () => [ + { provider: "claude", id: "outside-session", cwd: "/tmp/outside", title: "Outside", preview: "outside" }, + ]); + const importExternalSession = vi.fn(async (args: { laneId: string }) => ({ + kind: "cli", + sessionId: "terminal-import", + ptyId: "pty-import", + laneId: args.laneId, + })); + (fixture.runtime as any).externalSessionsService = { list, importExternalSession }; + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + await initialize(handler, { callerId: "cto-1", role: "cto" }); + + const listed = await callTool(handler, "run_ade_action", { + domain: "external-sessions", + action: "list", + args: { scope: "all", limit: 10 }, + }); + expect(listed?.isError).toBeUndefined(); + expect(list).toHaveBeenCalledWith({ scope: "all", limit: 10 }); + expect(listed.structuredContent.result).toEqual([ + { provider: "claude", id: "outside-session", cwd: "/tmp/outside", title: "Outside", preview: "outside" }, + ]); + + const imported = await callTool(handler, "run_ade_action", { + domain: "external-sessions", + action: "import", + args: { + provider: "claude", + sessionId: "outside-session", + laneId: "lane-2", + target: "cli", + mode: "resume", + }, + }); + expect(imported?.isError).toBeUndefined(); + expect(importExternalSession).toHaveBeenCalledWith({ + provider: "claude", + sessionId: "outside-session", + laneId: "lane-2", + target: "cli", + mode: "resume", + }); + + const importedChat = await callTool(handler, "run_ade_action", { + domain: "external-sessions", + action: "import", + args: { + provider: "claude", + sessionId: "outside-session", + laneId: "lane-2", + target: "chat", + mode: "resume", + }, + }); + expect(importedChat?.isError).toBeUndefined(); + expect(importExternalSession).toHaveBeenLastCalledWith({ + provider: "claude", + sessionId: "outside-session", + laneId: "lane-2", + target: "chat", + mode: "resume", + }); + }); + it("keeps explicit chat ADE actions available to unbound external CLI callers", async () => { const fixture = createRuntime(); const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index 51d3c28c6..1097c158c 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -32,6 +32,8 @@ import { import { isActionablePrIssueComment } from "../../desktop/src/shared/prIssueResolution"; import { type ComputerUseBackendStyle, + type ExternalSessionProvider, + type ExternalSessionSummary, type ComputerUseArtifactOwner, type LaneLinearIssue, type LaneLinearIssueLink, @@ -2062,6 +2064,10 @@ function chatAccessDenied(method: string): never { throw new JsonRpcError(JsonRpcErrorCode.methodNotFound, `Unsupported chat method: ${method}`); } +function externalSessionsAccessDenied(method: string): never { + throw new JsonRpcError(JsonRpcErrorCode.methodNotFound, `Unsupported external sessions method: ${method}`); +} + function listPtySessionsForAuthorization(runtime: AdeRuntime): TerminalSessionSummary[] { try { const rows = runtime.ptyService.list({}); @@ -2359,6 +2365,149 @@ function scopeSearchAdeActionArgs( return { ...searchArgs, callerScope: { chatSessionId: callerChatSessionId } }; } +const EXTERNAL_SESSION_AUTH_FIND_LIMIT = 500; +const EXTERNAL_SESSION_PROVIDER_NAMES = new Set(["claude", "codex", "cursor", "droid", "opencode"]); + +function isExternalSessionProviderName(value: string | null): value is ExternalSessionProvider { + return Boolean(value && EXTERNAL_SESSION_PROVIDER_NAMES.has(value)); +} + +function realishPath(filePath: string): string { + try { + return fs.realpathSync(filePath); + } catch { + return path.resolve(filePath); + } +} + +function isPathInsideOrEqual(parent: string, candidate: string): boolean { + const relative = path.relative(realishPath(parent), realishPath(candidate)); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +function authorizedExternalSessionsLaneId( + runtime: AdeRuntime, + session: SessionState, + method: string, + externalArgs: Record, +): string { + const laneIds = authorizedPtyLaneIds(runtime, session); + const requestedLaneId = extractLaneId(externalArgs); + if (requestedLaneId) { + if (!laneIds.has(requestedLaneId)) externalSessionsAccessDenied(method); + return requestedLaneId; + } + if (laneIds.size === 1) return [...laneIds][0]!; + externalSessionsAccessDenied(method); +} + +function resolveAuthorizedExternalSessionsLane( + runtime: AdeRuntime, + session: SessionState, + method: string, + externalArgs: Record, +): { laneId: string; laneCwd: string } { + const laneId = authorizedExternalSessionsLaneId(runtime, session, method, externalArgs); + const laneCwd = resolveLaneWorktreePath(runtime, laneId); + if (!laneCwd) externalSessionsAccessDenied(method); + return { laneId, laneCwd: realishPath(laneCwd) }; +} + +function isExternalSessionSummaryLike(value: unknown): value is ExternalSessionSummary { + if (!isRecord(value)) return false; + const provider = asOptionalTrimmedString(value.provider); + return Boolean(isExternalSessionProviderName(provider) && asOptionalTrimmedString(value.id)); +} + +function filterExternalSessionSummariesForLane(result: unknown, laneCwd: string): unknown { + if (!Array.isArray(result)) return result; + return result.filter((session) => { + if (!isExternalSessionSummaryLike(session)) return false; + const cwd = asOptionalTrimmedString(session.cwd); + return Boolean(cwd && isPathInsideOrEqual(laneCwd, cwd)); + }); +} + +function scopeExternalSessionsListArgs( + runtime: AdeRuntime, + session: SessionState, + listArgs: Record, +): { scopedArgs: Record; laneCwd: string } { + const method = "run_ade_action:external-sessions.list"; + const { laneId, laneCwd } = resolveAuthorizedExternalSessionsLane(runtime, session, method, listArgs); + return { + scopedArgs: { + ...listArgs, + laneId, + cwd: laneCwd, + scope: "project", + }, + laneCwd, + }; +} + +function externalSessionImportUsesSourceRunCwd(provider: ExternalSessionProvider, mode: string): boolean { + if (mode === "resume") return provider !== "codex"; + if (mode === "fork") return provider === "opencode"; + return false; +} + +async function findExternalSessionSummaryForAuthorization( + runtime: AdeRuntime, + method: string, + provider: ExternalSessionProvider, + sessionId: string, + laneId: string, + laneCwd: string, +): Promise { + const externalSessionsService = runtime.externalSessionsService; + if (!externalSessionsService) externalSessionsAccessDenied(method); + const sessions = await externalSessionsService.list({ + providers: [provider], + laneId, + cwd: laneCwd, + scope: "project", + limit: EXTERNAL_SESSION_AUTH_FIND_LIMIT, + }); + return sessions.find((session) => session.id === sessionId) ?? null; +} + +async function scopeExternalSessionsImportArgs( + runtime: AdeRuntime, + session: SessionState, + importArgs: Record, +): Promise> { + const method = "run_ade_action:external-sessions.import"; + const { laneId, laneCwd } = resolveAuthorizedExternalSessionsLane(runtime, session, method, importArgs); + const scopedArgs: Record = { ...importArgs, laneId }; + delete scopedArgs.enforceLaneScopeCwd; + const provider = asOptionalTrimmedString(scopedArgs.provider); + const mode = asOptionalTrimmedString(scopedArgs.mode); + const target = asOptionalTrimmedString(scopedArgs.target); + const sessionId = asOptionalTrimmedString(scopedArgs.sessionId); + scopedArgs.enforceLaneScopeCwd = laneCwd; + const targetChatUsesSourceCwd = target === "chat" && (provider === "claude" || provider === "codex"); + if ( + (targetChatUsesSourceCwd || target === "cli") + && isExternalSessionProviderName(provider) + && mode + && sessionId + && (targetChatUsesSourceCwd || externalSessionImportUsesSourceRunCwd(provider, mode)) + ) { + const summary = await findExternalSessionSummaryForAuthorization( + runtime, + method, + provider, + sessionId, + laneId, + laneCwd, + ); + const runCwd = asOptionalTrimmedString(summary?.cwd); + if (!runCwd || !isPathInsideOrEqual(laneCwd, runCwd)) externalSessionsAccessDenied(method); + } + return scopedArgs; +} + async function runCtoOperatorBridgeTool( runtime: AdeRuntime, session: SessionState, @@ -3204,6 +3353,21 @@ async function runTool(args: { session, requireObjectArgsForScopedAdeAction(domain, action, argsList, hasScalarArg, rawObjectArgs), ); + } else if (!callerIsCto && domain === "external-sessions") { + const externalArgs = requireObjectArgsForScopedAdeAction(domain, action, argsList, hasScalarArg, rawObjectArgs); + if (action === "list") { + const scoped = scopeExternalSessionsListArgs(runtime, session, externalArgs); + const rawResult = await (callable as (args?: Record) => Promise).call( + service, + scoped.scopedArgs, + ); + result = filterExternalSessionSummariesForLane(rawResult, scoped.laneCwd); + scopedResultHandled = true; + } else if (action === "import") { + scopedObjectArgs = await scopeExternalSessionsImportArgs(runtime, session, externalArgs); + } else { + externalSessionsAccessDenied(`run_ade_action:${domain}.${action}`); + } } if (domain === "lane" && action === "create" && !argsList && !hasScalarArg) { // Same remote-first default as the `create_lane` tool and the sync diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index ad6f3b7e4..70533a48f 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -26,6 +26,9 @@ import { createDiffService } from "../../desktop/src/main/services/diffs/diffSer import { createPtyService } from "../../desktop/src/main/services/pty/ptyService"; import { createProjectSearchService } from "../../desktop/src/main/services/search/searchServiceWiring"; import type { SearchService } from "../../desktop/src/main/services/search/searchService"; +import { + createExternalSessionsService, +} from "../../desktop/src/main/services/externalSessions/externalSessionsService"; import { createSupervisedPtyLoader } from "../../desktop/src/main/services/pty/supervisedPtyHost"; import { createTestService } from "../../desktop/src/main/services/tests/testService"; import { createKeybindingsService } from "../../desktop/src/main/services/keybindings/keybindingsService"; @@ -227,6 +230,7 @@ export type AdeRuntime = { sessionDeltaService?: ReturnType | null; reviewService?: ReturnType | null; searchService?: SearchService | null; + externalSessionsService?: ReturnType | null; autoUpdateService?: ReturnType | null; appNavigationService?: { navigate(args: AppNavigationRequest): Promise; @@ -1315,6 +1319,7 @@ export async function createAdeRuntime(args: { }); } + let externalSessionsService: ReturnType | null = null; let syncService: ReturnType | null = null; if (resolvedArgs.syncRuntime?.enabled && agentChatService) { const { createSyncService } = await import("./services/sync/syncService"); @@ -1349,6 +1354,7 @@ export async function createAdeRuntime(args: { ctoMemoryService, linearCredentialService: headlessLinearServices.linearCredentialService, getLinearIssueTracker: () => headlessLinearServices.linearIssueTracker, + getExternalSessionsService: () => externalSessionsService, processService, sharedSyncListener: resolvedArgs.syncRuntime.sharedSyncListener ?? null, hostStartupEnabled: resolvedArgs.syncRuntime.hostStartupEnabled ?? true, @@ -1419,6 +1425,34 @@ export async function createAdeRuntime(args: { for (const pr of event.prs) searchService.notifyPrChanged(pr.id); } }); + const agentChatImportedRefsSource = agentChatService; + const chatImportedRefsProvider = agentChatImportedRefsSource + ? async () => { + const sessions = await agentChatImportedRefsSource.listSessions(undefined, { + includeIdentity: true, + includeAutomation: true, + includeArchived: true, + }); + return sessions.flatMap((session) => { + const importedFrom = session.importedFrom; + if (!importedFrom?.provider?.trim() || !importedFrom.sessionId?.trim()) return []; + return [{ + provider: importedFrom.provider, + externalId: importedFrom.sessionId, + chatSessionId: session.sessionId, + }]; + }); + } + : undefined; + externalSessionsService = createExternalSessionsService({ + projectRoot, + laneService, + sessionService, + ptyService, + logger, + chatImporter: agentChatService, + ...(chatImportedRefsProvider ? { chatImportedRefsProvider } : {}), + }); const runtime: AdeRuntime = { projectRoot, @@ -1455,6 +1489,7 @@ export async function createAdeRuntime(args: { testService, reviewService, searchService, + externalSessionsService, aiIntegrationService, agentChatService, orchestrationService, diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index dc78aea30..fe8e6fabe 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -838,6 +838,47 @@ describe("ADE CLI", () => { ]); }); + it("formats external session action results as text", () => { + const plan = expectExecutePlan(buildCliPlan([ + "actions", + "run", + "external-sessions.list", + "--arg", + "limit=1", + ])); + + const formatter = inferFormatter(plan); + expect(formatter).toBe("external-sessions"); + + const listText = formatOutput( + [ + { + provider: "codex", + id: "thread-1", + cwd: "/repo", + title: "Investigate flaky test", + alreadyImported: false, + possiblyActive: true, + }, + ], + { ...baseResolveOpts(), projectRoot: null, workspaceRoot: null, text: true }, + formatter, + ); + expect(listText).toContain("provider"); + expect(listText).toContain("codex"); + expect(listText).toContain("active"); + expect(listText).toContain("Investigate flaky test"); + + const importText = formatOutput( + { kind: "cli", sessionId: "terminal-1", ptyId: "pty-1", laneId: "lane-1" }, + { ...baseResolveOpts(), projectRoot: null, workspaceRoot: null, text: true }, + formatter, + ); + expect(importText).toContain("ADE external session import"); + expect(importText).toContain("terminal-1"); + expect(importText).toContain("pty-1"); + }); + it("builds typed ADE secret commands", () => { const list = expectExecutePlan(buildCliPlan(["secrets", "list"])); expect(list.formatter).toBe("project-secrets"); diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index b899af6c7..556dc04aa 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -180,7 +180,8 @@ type FormatterId = | "automation-run-detail" | "automation-ingress" | "search-results" - | "search-status"; + | "search-status" + | "external-sessions"; type CliPlan = | { kind: "help"; text: string } @@ -14671,6 +14672,32 @@ function formatSearchStatus(value: unknown): string { ]); } +function formatExternalSessions(value: unknown): string { + if (isRecord(value) && (value.kind === "cli" || value.kind === "chat")) { + return renderKeyValues("ADE external session import", [ + ["kind", value.kind], + ["session", value.sessionId ?? value.chatSessionId], + ["pty", value.ptyId], + ["lane", value.laneId], + ]); + } + + const sessions = Array.isArray(value) + ? value.filter(isRecord) + : firstArray(value, ["sessions", "results", "items"]); + return renderTable( + ["provider", "id", "cwd", "status", "title"], + sessions.map((session) => [ + session.provider, + session.id, + session.cwd, + session.alreadyImported ? "imported" : session.possiblyActive ? "active" : "", + session.title ?? session.preview, + ]), + "ADE external sessions\n(no sessions)", + ); +} + function formatChatList(value: unknown): string { const sessions = firstArray(value, ["sessions", "chats", "items"]); return renderTable( @@ -15729,6 +15756,8 @@ function formatTextOutput( return formatSearchResults(value); case "search-status": return formatSearchStatus(value); + case "external-sessions": + return formatExternalSessions(value); case "action-result": default: if (isRecord(value)) @@ -15848,6 +15877,18 @@ function inferFormatter( if (label === "history show") return "history-show"; if (label === "actions list") return "actions-list"; if (label.endsWith("actions")) return "actions-list"; + const firstStep = plan.steps[0]; + const params = typeof firstStep?.params === "object" && firstStep.params != null + ? firstStep.params as Record + : null; + const actionArgs = isRecord(params?.arguments) ? params.arguments as Record : null; + if ( + firstStep?.method === "ade/actions/call" + && params?.name === "run_ade_action" + && actionArgs?.domain === "external-sessions" + ) { + return "external-sessions"; + } return "action-result"; } diff --git a/apps/ade-cli/src/services/sync/syncHostService.ts b/apps/ade-cli/src/services/sync/syncHostService.ts index d2d26a741..87d8c37a9 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.ts @@ -164,6 +164,7 @@ const MAX_INBOUND_CHANGESET_BYTES = DEFAULT_MAX_CHANGESET_BATCH_BYTES * 40; function isMobileChangesetPeer(peer: { metadata: SyncPeerMetadata | null }): boolean { return peer.metadata?.deviceType === "phone" || peer.metadata?.platform === "iOS"; } + const DEFAULT_SYNC_HEARTBEAT_INTERVAL_MS = 30_000; const DEFAULT_SYNC_HEARTBEAT_MISS_LIMIT = 2; const MOBILE_SYNC_HEARTBEAT_MISS_LIMIT = 6; diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts index 3125ab96b..c69d59d84 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts @@ -3,12 +3,16 @@ import type { SyncCommandPayload } from "../../../../desktop/src/shared/types"; import { deriveDeterministicLaneNameFromPrompt } from "../../../../desktop/src/shared/laneNameFallback"; import { createSyncRemoteCommandService } from "./syncRemoteCommandService"; -function makePayload(action: string, args: Record = {}): SyncCommandPayload { +function makePayload( + action: string, + args: Record = {}, +): SyncCommandPayload { return { commandId: "cmd-1", action, args }; } function createService(options?: { agentChatService?: Record; + externalSessionsService?: Record; prService?: Record; }) { const ptyService = { @@ -26,9 +30,10 @@ function createService(options?: { sessionService: {}, fileService: {}, ...(options?.agentChatService ? { agentChatService: options.agentChatService } : {}), + ...(options?.externalSessionsService ? { externalSessionsService: options.externalSessionsService } : {}), logger, } as any); - return { service, ptyService, logger }; + return { service, ptyService, externalSessionsService: options?.externalSessionsService, logger }; } describe("createSyncRemoteCommandService", () => { @@ -82,6 +87,226 @@ describe("createSyncRemoteCommandService", () => { }); }); + it("routes work.listExternalSessions to the external session service", async () => { + const list = vi.fn().mockResolvedValue([{ + provider: "codex", + id: "thread-1", + cwd: "/repo", + title: "Fix tests", + preview: "Working on it", + createdAt: 10, + updatedAt: 20, + messageCount: 3, + alreadyImported: false, + possiblyActive: true, + cwdMatchesRequestedLane: true, + capabilities: { + resumeInPlace: true, + resumeInDifferentCwd: true, + fork: true, + forkIntoDifferentCwd: true, + importToChat: true, + }, + }]); + const { service } = createService({ + externalSessionsService: { list, importExternalSession: vi.fn() }, + }); + + expect(service.getDescriptor("work.listExternalSessions")).toEqual({ + action: "work.listExternalSessions", + scope: "project", + policy: { viewerAllowed: true }, + }); + + const result = await service.execute(makePayload("work.listExternalSessions", { + providers: ["codex"], + laneId: "lane-1", + cwd: "/repo", + scope: "all", + limit: 999, + })); + + expect(list).toHaveBeenCalledWith({ + providers: ["codex"], + laneId: "lane-1", + cwd: "/repo", + scope: "all", + limit: 100, + }); + expect(result).toEqual([{ + provider: "codex", + id: "thread-1", + cwd: "/repo", + title: "Fix tests", + preview: "Working on it", + createdAt: 10, + updatedAt: 20, + messageCount: 3, + alreadyImported: false, + possiblyActive: true, + cwdMatchesRequestedLane: true, + capabilities: { + resumeInPlace: true, + resumeInDifferentCwd: true, + fork: true, + forkIntoDifferentCwd: true, + importToChat: true, + }, + }]); + }); + + it("exposes work.listExternalSessions as a viewer-allowed descriptor and passes scope through", async () => { + // Paired-device access is gated once, by policy.viewerAllowed at the sync host + // (see syncHostService), not by a client-declared role at this layer. + const list = vi.fn().mockResolvedValue([]); + const { service } = createService({ + externalSessionsService: { list, importExternalSession: vi.fn() }, + }); + + expect(service.getDescriptor("work.listExternalSessions")).toEqual({ + action: "work.listExternalSessions", + scope: "project", + policy: { viewerAllowed: true }, + }); + + await expect(service.execute(makePayload("work.listExternalSessions", { + scope: "all", + }))).resolves.toEqual([]); + expect(list).toHaveBeenCalledWith({ scope: "all" }); + }); + + it("rejects invalid work.listExternalSessions filters", async () => { + const list = vi.fn(); + const { service } = createService({ + externalSessionsService: { list, importExternalSession: vi.fn() }, + }); + + await expect(service.execute(makePayload("work.listExternalSessions", { + providers: ["bogus"], + }))).rejects.toThrow("work.listExternalSessions requires a valid provider."); + await expect(service.execute(makePayload("work.listExternalSessions", { + scope: "workspace", + }))).rejects.toThrow("work.listExternalSessions scope must be project or all."); + await expect(service.execute(makePayload("work.listExternalSessions", { + laneId: 42, + }))).rejects.toThrow("work.listExternalSessions laneId must be a string."); + await expect(service.execute(makePayload("work.listExternalSessions", { + limit: Number.POSITIVE_INFINITY, + }))).rejects.toThrow("work.listExternalSessions limit must be a finite number."); + expect(list).not.toHaveBeenCalled(); + }); + + it("routes work.importExternalSession to the external session service", async () => { + const importExternalSession = vi.fn().mockResolvedValue({ + kind: "cli", + sessionId: "session-1", + ptyId: "pty-1", + laneId: "lane-1", + }); + const { service } = createService({ + externalSessionsService: { list: vi.fn(), importExternalSession }, + }); + + expect(service.getDescriptor("work.importExternalSession")).toEqual({ + action: "work.importExternalSession", + scope: "project", + policy: { viewerAllowed: true, queueable: true }, + }); + + const result = await service.execute(makePayload("work.importExternalSession", { + provider: "codex", + sessionId: "thread-1", + laneId: "lane-1", + target: "cli", + mode: "resume", + model: "gpt-5.3-codex", + permissionMode: "default", + })); + + expect(importExternalSession).toHaveBeenCalledWith({ + provider: "codex", + sessionId: "thread-1", + laneId: "lane-1", + target: "cli", + mode: "resume", + model: "gpt-5.3-codex", + permissionMode: "default", + }); + expect(result).toEqual({ + kind: "cli", + sessionId: "session-1", + ptyId: "pty-1", + laneId: "lane-1", + }); + }); + + it("routes chat work.importExternalSession results without CLI fields", async () => { + const importExternalSession = vi.fn().mockResolvedValue({ + kind: "chat", + chatSessionId: "chat-1", + laneId: "lane-1", + }); + const { service } = createService({ + externalSessionsService: { list: vi.fn(), importExternalSession }, + }); + + const result = await service.execute(makePayload("work.importExternalSession", { + provider: "claude", + sessionId: "thread-1", + laneId: "lane-1", + target: "chat", + mode: "fork", + })); + + expect(result).toEqual({ + kind: "chat", + chatSessionId: "chat-1", + laneId: "lane-1", + }); + }); + + it("rejects invalid work.importExternalSession payloads", async () => { + const importExternalSession = vi.fn(); + const { service } = createService({ + externalSessionsService: { list: vi.fn(), importExternalSession }, + }); + + await expect(service.execute(makePayload("work.importExternalSession", { + provider: "bogus", + sessionId: "thread-1", + laneId: "lane-1", + target: "cli", + mode: "resume", + }))).rejects.toThrow("work.importExternalSession requires a valid provider."); + await expect(service.execute(makePayload("work.importExternalSession", { + provider: "codex", + laneId: "lane-1", + target: "cli", + mode: "resume", + }))).rejects.toThrow("work.importExternalSession requires sessionId."); + await expect(service.execute(makePayload("work.importExternalSession", { + provider: "codex", + sessionId: "thread-1", + target: "cli", + mode: "resume", + }))).rejects.toThrow("work.importExternalSession requires laneId."); + await expect(service.execute(makePayload("work.importExternalSession", { + provider: "codex", + sessionId: "thread-1", + laneId: "lane-1", + target: "browser", + mode: "resume", + }))).rejects.toThrow("work.importExternalSession target must be cli or chat."); + await expect(service.execute(makePayload("work.importExternalSession", { + provider: "codex", + sessionId: "thread-1", + laneId: "lane-1", + target: "cli", + mode: "clone", + }))).rejects.toThrow("work.importExternalSession mode must be resume or fork."); + expect(importExternalSession).not.toHaveBeenCalled(); + }); + it("routes the canonical chat history page command to the chat service", async () => { const getChatEventHistoryPage = vi.fn().mockReturnValue({ sessionId: "chat-1", diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index 3012d03a2..294fe1676 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -105,6 +105,15 @@ import type { StartIntegrationResolutionArgs, StartQueueAutomationArgs, SubmitPrReviewArgs, + ExternalSessionImportArgs, + ExternalSessionImportResult, + ExternalSessionListArgs, + ExternalSessionProvider, + ExternalSessionSummary, + SyncImportExternalSessionArgs, + SyncImportExternalSessionResult, + SyncListExternalSessionsArgs, + SyncListExternalSessionsResult, SyncCommandPayload, SyncRemoteCommandAction, SyncRemoteCommandDescriptor, @@ -151,6 +160,19 @@ import type { PushPublisherService } from "../push/pushPublisherService"; import { deriveDeterministicLaneNameFromPrompt } from "../../../../desktop/src/shared/laneNameFallback"; import { resolveLaneCreateRemoteBase } from "../laneCreateRemoteBase"; import { normalizePrCreationStrategy } from "../../../../desktop/src/shared/prStrategy"; + +export type ExternalSessionsRemoteService = { + list(args?: ExternalSessionListArgs): Promise; + importExternalSession(args: ExternalSessionImportArgs): Promise; +}; + +const EXTERNAL_SESSION_PROVIDERS = new Set([ + "claude", + "codex", + "cursor", + "droid", + "opencode", +]); import type { createAgentChatService } from "../../../../desktop/src/main/services/chat/agentChatService"; import type { createCtoStateService } from "../../../../desktop/src/main/services/cto/ctoStateService"; import type { CtoMemoryService } from "../../../../desktop/src/main/services/cto/ctoMemoryService"; @@ -211,6 +233,8 @@ type SyncRemoteCommandServiceArgs = { laneTemplateService?: ReturnType | null; rebaseSuggestionService?: ReturnType | null; autoRebaseService?: ReturnType | null; + externalSessionsService?: ExternalSessionsRemoteService | null; + getExternalSessionsService?: () => ExternalSessionsRemoteService | null; /** * Deterministic stamp of the sync host's in-memory lane presence * (`devicesOpen`). The host decorates lane list/detail payloads with @@ -753,6 +777,74 @@ function parseStartCliSessionArgs(value: Record): SyncStartCliS }; } +function parseExternalSessionProvider(value: unknown, action: string): ExternalSessionProvider { + const provider = asTrimmedString(value)?.toLowerCase(); + if (!provider || !EXTERNAL_SESSION_PROVIDERS.has(provider as ExternalSessionProvider)) { + throw new Error(`${action} requires a valid provider.`); + } + return provider as ExternalSessionProvider; +} + +function parseListExternalSessionsArgs(value: Record): SyncListExternalSessionsArgs { + const result: SyncListExternalSessionsArgs = {}; + if (value.providers != null) { + if (!Array.isArray(value.providers)) throw new Error("work.listExternalSessions providers must be an array."); + result.providers = value.providers.map((provider) => + parseExternalSessionProvider(provider, "work.listExternalSessions")); + } + if (value.laneId != null) { + if (typeof value.laneId !== "string") throw new Error("work.listExternalSessions laneId must be a string."); + result.laneId = value.laneId.trim(); + } + if (value.cwd != null) { + if (typeof value.cwd !== "string") throw new Error("work.listExternalSessions cwd must be a string."); + result.cwd = value.cwd.trim(); + } + if (value.scope != null) { + if (value.scope !== "project" && value.scope !== "all") { + throw new Error("work.listExternalSessions scope must be project or all."); + } + result.scope = value.scope; + } + if (value.limit != null) { + if (typeof value.limit !== "number" || !Number.isFinite(value.limit)) { + throw new Error("work.listExternalSessions limit must be a finite number."); + } + result.limit = Math.max(1, Math.min(100, Math.floor(value.limit))); + } + return result; +} + +function parseImportExternalSessionArgs(value: Record): SyncImportExternalSessionArgs { + const provider = parseExternalSessionProvider(value.provider, "work.importExternalSession"); + const sessionId = requireString(value.sessionId, "work.importExternalSession requires sessionId."); + const laneId = requireString(value.laneId, "work.importExternalSession requires laneId."); + const target = asTrimmedString(value.target); + if (target !== "cli" && target !== "chat") { + throw new Error("work.importExternalSession target must be cli or chat."); + } + const mode = asTrimmedString(value.mode); + if (mode !== "resume" && mode !== "fork") { + throw new Error("work.importExternalSession mode must be resume or fork."); + } + return { + provider, + sessionId, + laneId, + target, + mode, + ...(asTrimmedString(value.model) ? { model: asTrimmedString(value.model)! } : {}), + ...(asTrimmedString(value.permissionMode) ? { permissionMode: asTrimmedString(value.permissionMode)! } : {}), + }; +} + +function resolveExternalSessionsService(args: SyncRemoteCommandServiceArgs): ExternalSessionsRemoteService { + return requireService( + args.getExternalSessionsService?.() ?? args.externalSessionsService, + "External sessions service not available.", + ); +} + function isChatToolType(toolType: string | null | undefined): boolean { if (!toolType) return false; const t = toolType.trim().toLowerCase(); @@ -2413,6 +2505,28 @@ function registerWorkRemoteCommands({ args, register }: RemoteCommandRegistratio session: result.session, } satisfies SyncStartCliSessionResult; }); + register("work.listExternalSessions", { viewerAllowed: true }, async (payload) => { + const parsed = parseListExternalSessionsArgs(payload); + const result = await resolveExternalSessionsService(args).list(parsed); + return result satisfies SyncListExternalSessionsResult; + }); + register("work.importExternalSession", { viewerAllowed: true, queueable: true }, async (payload) => { + const parsed = parseImportExternalSessionArgs(payload); + const result = await resolveExternalSessionsService(args).importExternalSession(parsed); + if (result.kind === "cli") { + return { + kind: "cli", + sessionId: result.sessionId, + ptyId: result.ptyId, + laneId: result.laneId, + } satisfies SyncImportExternalSessionResult; + } + return { + kind: "chat", + chatSessionId: result.chatSessionId, + laneId: result.laneId, + } satisfies SyncImportExternalSessionResult; + }); register("work.sendToSession", { viewerAllowed: true, queueable: true }, async (payload) => { const parsed = parseSendToSessionArgs(payload); const result = await args.ptyService.sendToSession({ diff --git a/apps/ade-cli/src/services/sync/syncService.test.ts b/apps/ade-cli/src/services/sync/syncService.test.ts index 2300953ce..58cac50e3 100644 --- a/apps/ade-cli/src/services/sync/syncService.test.ts +++ b/apps/ade-cli/src/services/sync/syncService.test.ts @@ -30,7 +30,11 @@ async function getUnusedPort(): Promise { return port; } -function createService(db: AdeDb, projectRoot: string): SyncService { +function createService( + db: AdeDb, + projectRoot: string, + overrides: Partial[0]> = {}, +): SyncService { return createSyncService({ db, logger: createLogger() as any, @@ -61,6 +65,7 @@ function createService(db: AdeDb, projectRoot: string): SyncService { processService: { listRuntime: vi.fn(() => []), } as any, + ...overrides, }); } @@ -167,4 +172,65 @@ describe("createSyncService", () => { await service.dispose(); db.close(); }); + + it("routes external-session remote commands through the lazy service getter", async () => { + const projectRoot = makeTempRoot("ade-sync-service-external-sessions-"); + cleanupRoots.push(projectRoot); + const db = await openKvDb(path.join(projectRoot, ".ade", "kv.sqlite"), createLogger() as any); + const list = vi.fn(async () => [{ + provider: "codex", + id: "thread-1", + cwd: projectRoot, + title: "Thread one", + preview: "Working", + createdAt: 10, + updatedAt: 20, + messageCount: 2, + alreadyImported: false, + possiblyActive: false, + cwdMatchesRequestedLane: true, + capabilities: { + resumeInPlace: true, + resumeInDifferentCwd: true, + fork: true, + forkIntoDifferentCwd: true, + importToChat: true, + }, + }]); + const externalSessionsService = { + list, + importExternalSession: vi.fn(), + }; + const service = createService(db, projectRoot, { + getExternalSessionsService: () => externalSessionsService as any, + }); + + try { + expect(service.getRemoteCommandDescriptor("work.listExternalSessions")).toEqual({ + action: "work.listExternalSessions", + scope: "project", + policy: { viewerAllowed: true }, + }); + + const result = await service.executeRemoteCommand({ + commandId: "cmd-1", + action: "work.listExternalSessions", + args: { + providers: ["codex"], + laneId: "lane-1", + scope: "project", + }, + }); + + expect(list).toHaveBeenCalledWith({ + providers: ["codex"], + laneId: "lane-1", + scope: "project", + }); + expect(result).toEqual(await list.mock.results[0]!.value); + } finally { + await service.dispose(); + db.close(); + } + }); }); diff --git a/apps/ade-cli/src/services/sync/syncService.ts b/apps/ade-cli/src/services/sync/syncService.ts index 1a1115883..12334c2ce 100644 --- a/apps/ade-cli/src/services/sync/syncService.ts +++ b/apps/ade-cli/src/services/sync/syncService.ts @@ -57,7 +57,7 @@ import { createSyncPeerService } from "./syncPeerService"; import { createSyncPinStore } from "./syncPinStore"; import { createSyncRuntimeNameStore } from "./syncRuntimeNameStore"; import { DEFAULT_SYNC_HOST_PORT } from "./syncProtocol"; -import { createSyncRemoteCommandService, type SyncRemoteCommandService } from "./syncRemoteCommandService"; +import { createSyncRemoteCommandService, type ExternalSessionsRemoteService, type SyncRemoteCommandService } from "./syncRemoteCommandService"; import type { PushPublisherService } from "../push/pushPublisherService"; import { acquireSyncHostSingleton, type SyncHostSingletonLease } from "./syncHostSingleton"; import type { SharedSyncListener } from "./sharedSyncListener"; @@ -105,6 +105,7 @@ type SyncServiceArgs = { * to them without requiring a specific init order. */ getLinearIssueTracker?: () => ReturnType | null; + getExternalSessionsService?: () => ExternalSessionsRemoteService | null; processService: ReturnType; /** * Brain-level websocket listener shared across hosted-project switches. @@ -643,6 +644,7 @@ export function createSyncService(args: SyncServiceArgs) { ctoMemoryService: args.ctoMemoryService, linearCredentialService: args.linearCredentialService, getLinearIssueTracker: args.getLinearIssueTracker, + getExternalSessionsService: args.getExternalSessionsService, projectConfigService: args.projectConfigService, processService: args.processService, portAllocationService: args.portAllocationService, diff --git a/apps/ade-cli/src/tuiClient/__tests__/externalSessionBrowser.test.ts b/apps/ade-cli/src/tuiClient/__tests__/externalSessionBrowser.test.ts new file mode 100644 index 000000000..cc09e5ee0 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/__tests__/externalSessionBrowser.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; +import type { ExternalSessionSummary } from "../../../../desktop/src/shared/types/externalSessions"; +import { + clampExternalSessionBrowserContent, + normalizeExternalSessionListResult, + visibleExternalSessions, +} from "../externalSessionBrowser"; +import type { RightPaneContent } from "../types"; + +function session(overrides: Partial): ExternalSessionSummary { + return { + provider: "claude", + id: "s1", + cwd: "/repo", + title: "Session", + preview: null, + createdAt: 100, + updatedAt: 100, + messageCount: 1, + alreadyImported: false, + possiblyActive: false, + cwdMatchesRequestedLane: true, + capabilities: { + resumeInPlace: true, + resumeInDifferentCwd: false, + fork: true, + forkIntoDifferentCwd: false, + importToChat: true, + }, + ...overrides, + }; +} + +describe("externalSessionBrowser helpers", () => { + it("normalizes list action responses", () => { + const row = session({ id: "one" }); + expect(normalizeExternalSessionListResult([row])).toEqual([row]); + expect(normalizeExternalSessionListResult({ sessions: [row] })).toEqual([row]); + expect(normalizeExternalSessionListResult(null)).toEqual([]); + }); + + it("filters by provider and typed query, then sorts newest first", () => { + const older = session({ id: "older", provider: "claude", title: "Budget notes", updatedAt: 10 }); + const newest = session({ id: "newest", provider: "claude", title: "Release plan", updatedAt: 50 }); + const otherProvider = session({ id: "cursor", provider: "cursor", title: "Release plan", updatedAt: 100 }); + + expect(visibleExternalSessions([older, newest, otherProvider], "claude", "plan").map((row) => row.id)) + .toEqual(["newest"]); + expect(visibleExternalSessions([older, newest, otherProvider], "all", "release").map((row) => row.id)) + .toEqual(["cursor", "newest"]); + }); + + it("clamps selection and action indexes after filter changes", () => { + const content: Extract = { + kind: "external-session-browser", + laneId: "lane-1", + laneLabel: "Lane", + providerFilter: "claude", + query: "release", + sessions: [ + session({ id: "one", title: "Release plan" }), + session({ id: "two", title: "Other" }), + ], + loading: false, + selectedIndex: 9, + actionIndex: 99, + }; + + expect(clampExternalSessionBrowserContent(content)).toMatchObject({ + selectedIndex: 0, + actionIndex: 3, + }); + }); + + it("keeps the extra resume-in-original action available after foreign-folder fork", () => { + const content: Extract = { + kind: "external-session-browser", + laneId: "lane-1", + laneLabel: "Lane", + providerFilter: "claude", + query: "", + sessions: [ + session({ + id: "foreign", + cwd: "/repo/other", + cwdMatchesRequestedLane: false, + capabilities: { + resumeInPlace: true, + resumeInDifferentCwd: false, + fork: true, + forkIntoDifferentCwd: true, + importToChat: true, + }, + }), + ], + loading: false, + selectedIndex: 0, + actionIndex: 99, + }; + + expect(clampExternalSessionBrowserContent(content)).toMatchObject({ + selectedIndex: 0, + actionIndex: 3, + }); + }); +}); diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index 7ca3c445f..6065c94d3 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -241,6 +241,16 @@ import { terminalDisplayWidth, } from "./displayWidth"; import { flushAdeCodeStateWrites, loadAdeCodeState, saveAdeCodeProjectState, scopedAdeCodeState } from "./state"; +import { + clampExternalSessionBrowserContent, + externalSessionActionKey, + externalSessionProviderLabel, + importAffordancesFor, + nextExternalSessionProviderFilter, + normalizeExternalSessionListResult, + visibleExternalSessions, + type ImportAffordance, +} from "./externalSessionBrowser"; import { SpinTickProvider } from "./spinTick"; import { ACTIVE_SESSION_PLACEHOLDER, buildLinearToolRequest } from "./linearCommands"; import { @@ -329,6 +339,10 @@ import type { SubagentSnapshot, RuntimeMode, } from "./types"; +import type { + ExternalSessionImportResult, + ExternalSessionSummary, +} from "../../../desktop/src/shared/types/externalSessions"; export { isTerminalSessionResumable } from "./closedCliSessions"; @@ -2914,6 +2928,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, // Full right-pane mirror so async draft-commit paths (first send) can check // the CURRENT pane without stale-closure state (see showChatInfoAfterDraftCommit). const rightPaneRef = useRef({ kind: "empty" }); + const externalSessionListGenerationRef = useRef(0); const lastLocalSendAtRef = useRef(0); const eventsRef = useRef([]); const eventCountRef = useRef(0); @@ -3899,7 +3914,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, : null; const goalBannerRows = goalBannerText ? 1 : 0; const addModeRows = addMode ? 1 : 0; - const rightPaneMaxWidth = rightPane.kind === "model-picker" + const rightPaneMaxWidth = rightPane.kind === "model-picker" || rightPane.kind === "external-session-browser" ? MODEL_PICKER_RIGHT_PANE_MAX_WIDTH : RIGHT_PANE_MAX_WIDTH; const rightPaneWidth = resolveRightPaneWidth(columns, rightOpen, drawerOpen, rightPaneMaxWidth); @@ -4569,19 +4584,29 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, () => buildProviderReadinessRows(aiStatus, storedApiKeyProviders, openCodeDiagnostics), [aiStatus, openCodeDiagnostics, storedApiKeyProviders], ); + const newChatImportLaneId = drawerLaneId ?? activeLaneId; + const newChatImportEnabled = Boolean(newChatImportLaneId && !unavailableLaneIds.has(newChatImportLaneId)); + const newChatImportDetail = !newChatImportLaneId + ? "Select a lane first — imports need a lane folder" + : unavailableLaneIds.has(newChatImportLaneId) + ? "Lane folder unavailable" + : "Browse external CLI sessions"; const newChatSetupRows = useMemo( () => buildSetupRows({ modelState, models, includeRefresh: false, includeApply: true, + includeImportSession: true, + importSessionEnabled: newChatImportEnabled, + importSessionDetail: newChatImportDetail, outputStyle: "default", outputStyleEditable: false, // Draft: the interface is the user's editable Chat/CLI choice. interfaceMode: modelState.interfaceMode, interfaceEditable: true, }), - [modelState, models], + [modelState, models, newChatImportDetail, newChatImportEnabled], ); // Once a session exists the interface is fixed by its type (a CLI terminal is // active ⇒ CLI; an SDK chat ⇒ Chat). With no committed session yet (/model on a @@ -5014,6 +5039,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, // Form panes (rename, new-lane, pr-open) are user-driven; never overwrite. if (rightPane.kind === "form") return; if (rightPane.kind === "model-picker") return; + if (rightPane.kind === "external-session-browser") return; if (pendingQuestionStateRef.current) return; const next = resolveContextDefault({ draftChatActive: draftChatActiveRef.current, @@ -7973,6 +7999,191 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, setTerminalSessions((current) => mergeOptimisticTerminalSessions(current, optimisticTerminalSessionsRef.current)); }, [lanesById]); + const loadExternalSessionsForLane = useCallback(async (laneId: string) => { + const conn = connectionRef.current; + const generation = externalSessionListGenerationRef.current + 1; + externalSessionListGenerationRef.current = generation; + setRightPane((prev) => prev.kind === "external-session-browser" && prev.laneId === laneId + ? { + ...prev, + loading: true, + error: null, + importError: null, + } + : prev); + if (!conn) { + setRightPane((prev) => prev.kind === "external-session-browser" && prev.laneId === laneId + ? { + ...prev, + loading: false, + error: "Runtime unavailable.", + } + : prev); + return; + } + try { + const result = await conn.action("external-sessions", "list", { + scope: "project", + laneId, + }); + const sessions = normalizeExternalSessionListResult(result); + if (externalSessionListGenerationRef.current !== generation) return; + setRightPane((prev) => { + if (prev.kind !== "external-session-browser" || prev.laneId !== laneId) return prev; + return clampExternalSessionBrowserContent({ + ...prev, + sessions, + loading: false, + error: null, + importError: null, + loadedAt: Date.now(), + }); + }); + } catch (err) { + if (externalSessionListGenerationRef.current !== generation) return; + const message = err instanceof Error ? err.message : String(err); + setRightPane((prev) => prev.kind === "external-session-browser" && prev.laneId === laneId + ? { + ...prev, + loading: false, + error: message, + } + : prev); + } + }, []); + + const openExternalSessionBrowser = useCallback(() => { + const currentPane = rightPaneRef.current; + const laneId = currentPane.kind === "model-picker" && currentPane.surface === "new-chat" && currentPane.laneId + ? currentPane.laneId + : drawerLaneIdRef.current ?? activeLaneIdRef.current; + const lane = laneId ? lanesById[laneId] ?? null : null; + if (!laneId || !lane) { + addNotice("Select a lane first — imports need a lane folder.", "info"); + return; + } + if (unavailableLaneIds.has(laneId)) { + addNotice("That lane folder is unavailable.", "error"); + return; + } + setRightPane({ + kind: "external-session-browser", + laneId, + laneLabel: lane.name, + providerFilter: "all", + query: "", + sessions: [], + loading: true, + error: null, + importError: null, + importingKey: null, + selectedIndex: 0, + actionIndex: 0, + loadedAt: null, + }); + setRightPaneScrollOffsetRows(0); + setRightOpen(true); + setPaneFocus("details"); + lastUserOpenedPaneRef.current = "external-session-browser"; + userDismissedRightPaneRef.current = false; + void loadExternalSessionsForLane(laneId); + }, [addNotice, lanesById, loadExternalSessionsForLane, setPaneFocus, unavailableLaneIds]); + + const adoptImportedExternalSession = useCallback(async ( + summary: ExternalSessionSummary, + result: ExternalSessionImportResult, + ) => { + const laneId = result.laneId; + pendingNewChatTitleRef.current = null; + setDraftChatMode(false); + setGridView(false); + setAttachedTerminalId(null); + setDrawerSection("chats"); + setSelectedDrawerLaneAction(null); + setSelectedDrawerLaneId(laneId); + setDrawerLaneId(laneId); + setSelectedDrawerChatAction(null); + selectActiveLaneId(laneId); + lastUserOpenedPaneRef.current = null; + userDismissedRightPaneRef.current = false; + setRightOpen(true); + setRightPane({ kind: "empty" }); + focusChat(); + + if (result.kind === "cli") { + registerOptimisticTerminalSession({ + sessionId: result.sessionId, + laneId, + title: summary.title?.trim() || summary.preview?.trim() || null, + provider: summary.provider as CliTerminalProvider, + }); + setSelectedDrawerChatId(result.sessionId); + selectActiveSessionId(result.sessionId); + addNotice(`Imported ${externalSessionProviderLabel(summary.provider)} CLI session.`, "success"); + } else { + setSelectedDrawerChatId(result.chatSessionId); + selectActiveSessionId(result.chatSessionId); + addNotice(`Imported ${externalSessionProviderLabel(summary.provider)} as ADE chat.`, "success"); + } + + await refreshState(); + }, [ + addNotice, + focusChat, + refreshState, + registerOptimisticTerminalSession, + selectActiveLaneId, + selectActiveSessionId, + setDraftChatMode, + setGridView, + ]); + + const importExternalSessionFromBrowser = useCallback(async ( + summary: ExternalSessionSummary, + affordance: ImportAffordance, + ) => { + const pane = rightPaneRef.current; + if (pane.kind !== "external-session-browser") return; + if (!affordance.enabled) { + const message = affordance.disabledReason ?? "This action is not available for that session."; + setRightPane((prev) => prev.kind === "external-session-browser" + ? { ...prev, importError: message } + : prev); + return; + } + const conn = connectionRef.current; + if (!conn) { + setRightPane((prev) => prev.kind === "external-session-browser" + ? { ...prev, importError: "Runtime unavailable." } + : prev); + return; + } + const importingKey = externalSessionActionKey(summary, affordance); + setRightPane((prev) => prev.kind === "external-session-browser" + ? { ...prev, importError: null, importingKey } + : prev); + try { + const result = await conn.action("external-sessions", "import", { + provider: summary.provider, + sessionId: summary.id, + laneId: pane.laneId, + target: affordance.target, + mode: affordance.mode, + }); + externalSessionListGenerationRef.current += 1; + await adoptImportedExternalSession(summary, result); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setRightPane((prev) => prev.kind === "external-session-browser" + ? { + ...prev, + importError: message, + importingKey: prev.importingKey === importingKey ? null : prev.importingKey, + } + : prev); + } + }, [adoptImportedExternalSession]); + const submitClaudePromptToTerminal = useCallback(async (terminal: ChatTerminalSession, text: string) => { const conn = connectionRef.current; const trimmed = text.trim(); @@ -10742,28 +10953,32 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, cycleProvider(direction); return; } - if (row.kind === "interface") { - // Only two values, so any cycle direction toggles Chat ↔ CLI. Editable - // rows only (disabled rows return above); a committed session's interface - // is fixed by its type. - const nextInterface = modelStateRef.current.interfaceMode === "cli" ? "chat" : "cli"; - const cursorModels = providerModelsCacheRef.current.get(providerModelsCacheKey("cursor", nextInterface)) - ?? (modelStateRef.current.provider === "cursor" ? models : registryModelsForProvider("cursor")); - persistExplicitDraftKind(nextInterface); - applyModelState((prev) => reconcileCursorModelStateForInterface(prev, nextInterface, cursorModels)); - if (modelStateRef.current.provider === "cursor") { - void loadProviderModels("cursor", { - applyDefault: false, - force: true, - interfaceMode: nextInterface, - }).then((loaded) => { - const current = modelStateRef.current; - if (current.provider !== "cursor" || current.interfaceMode !== nextInterface) return; - applyModelState((prev) => reconcileCursorModelStateForInterface(prev, nextInterface, loaded)); - }).catch(() => undefined); - } - return; - } + if (row.kind === "interface") { + // Only two values, so any cycle direction toggles Chat ↔ CLI. Editable + // rows only (disabled rows return above); a committed session's interface + // is fixed by its type. + const nextInterface = modelStateRef.current.interfaceMode === "cli" ? "chat" : "cli"; + const cursorModels = providerModelsCacheRef.current.get(providerModelsCacheKey("cursor", nextInterface)) + ?? (modelStateRef.current.provider === "cursor" ? models : registryModelsForProvider("cursor")); + persistExplicitDraftKind(nextInterface); + applyModelState((prev) => reconcileCursorModelStateForInterface(prev, nextInterface, cursorModels)); + if (modelStateRef.current.provider === "cursor") { + void loadProviderModels("cursor", { + applyDefault: false, + force: true, + interfaceMode: nextInterface, + }).then((loaded) => { + const current = modelStateRef.current; + if (current.provider !== "cursor" || current.interfaceMode !== nextInterface) return; + applyModelState((prev) => reconcileCursorModelStateForInterface(prev, nextInterface, loaded)); + }).catch(() => undefined); + } + return; + } + if (row.kind === "import-session") { + openExternalSessionBrowser(); + return; + } if (row.kind === "model") { cycleModel(direction); return; @@ -10829,7 +11044,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, lastUserOpenedPaneRef.current = null; focusChat(); } - }, [addNotice, applyModelState, cycleModel, cyclePermission, cycleProvider, cycleReasoning, focusChat, loadProviderModels, models, persistExplicitDraftKind, refreshAiSetupStatus, refreshState, sendClaudeModelCommandToTerminal]); + }, [addNotice, applyModelState, cycleModel, cyclePermission, cycleProvider, cycleReasoning, focusChat, loadProviderModels, models, openExternalSessionBrowser, persistExplicitDraftKind, refreshAiSetupStatus, refreshState, sendClaudeModelCommandToTerminal]); const recallPromptHistory = useCallback((direction: "previous" | "next"): boolean => { const focusedSessionId = (gridViewActiveRef.current ? focusedSessionIdForMultiView(multiViewRef.current) : null); @@ -12227,6 +12442,128 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, } return; } + if (pane === "details" && rightOpen && rightPane.kind === "external-session-browser") { + const browser = rightPane; + const visible = visibleExternalSessions(browser.sessions, browser.providerFilter, browser.query); + const selectedIndex = visible.length + ? Math.min(Math.max(0, browser.selectedIndex), visible.length - 1) + : 0; + const selectedSession = visible[selectedIndex] ?? null; + const actions = selectedSession ? importAffordancesFor(selectedSession) : []; + const actionIndex = actions.length + ? Math.min(Math.max(0, browser.actionIndex), actions.length - 1) + : 0; + + if (key.escape) { + setRightPane({ kind: "empty" }); + setRightOpen(false); + lastUserOpenedPaneRef.current = null; + userDismissedRightPaneRef.current = true; + focusAfterDetails(); + return; + } + if (key.upArrow) { + setRightPane((prev) => prev.kind === "external-session-browser" + ? clampExternalSessionBrowserContent({ + ...prev, + selectedIndex: Math.max(0, selectedIndex - 1), + actionIndex: 0, + importError: null, + }) + : prev); + return; + } + if (key.downArrow) { + setRightPane((prev) => prev.kind === "external-session-browser" + ? clampExternalSessionBrowserContent({ + ...prev, + selectedIndex: visible.length ? Math.min(visible.length - 1, selectedIndex + 1) : 0, + actionIndex: 0, + importError: null, + }) + : prev); + return; + } + if (key.leftArrow || key.rightArrow) { + const delta = key.leftArrow ? -1 : 1; + setRightPane((prev) => prev.kind === "external-session-browser" + ? clampExternalSessionBrowserContent({ + ...prev, + actionIndex: actions.length ? (actionIndex + delta + actions.length) % actions.length : 0, + importError: null, + }) + : prev); + return; + } + if (key.return) { + if (!selectedSession) return; + const action = actions[actionIndex] ?? actions.find((entry) => entry.enabled) ?? actions[0]; + if (!action) { + setRightPane((prev) => prev.kind === "external-session-browser" + ? { ...prev, importError: "No import action available for that session." } + : prev); + return; + } + void importExternalSessionFromBrowser(selectedSession, action); + return; + } + if ((input === "r" || input === "R") && !key.ctrl && !key.meta && !browser.query) { + void loadExternalSessionsForLane(browser.laneId); + return; + } + if ((input === "p" || input === "P") && !key.ctrl && !key.meta && !browser.query) { + setRightPane((prev) => prev.kind === "external-session-browser" + ? clampExternalSessionBrowserContent({ + ...prev, + providerFilter: nextExternalSessionProviderFilter(prev.providerFilter), + selectedIndex: 0, + actionIndex: 0, + importError: null, + }) + : prev); + return; + } + if (isPromptLineBackspace(input, key)) { + setRightPane((prev) => prev.kind === "external-session-browser" + ? clampExternalSessionBrowserContent({ + ...prev, + query: "", + selectedIndex: 0, + actionIndex: 0, + importError: null, + }) + : prev); + return; + } + if (key.backspace || key.delete) { + setRightPane((prev) => prev.kind === "external-session-browser" + ? clampExternalSessionBrowserContent({ + ...prev, + query: prev.query.slice(0, -1), + selectedIndex: 0, + actionIndex: 0, + importError: null, + }) + : prev); + return; + } + if (!key.ctrl && !key.meta && !key.return) { + const suffix = printableInput(input); + if (suffix) { + setRightPane((prev) => prev.kind === "external-session-browser" + ? clampExternalSessionBrowserContent({ + ...prev, + query: `${prev.query}${suffix}`, + selectedIndex: 0, + actionIndex: 0, + importError: null, + }) + : prev); + } + return; + } + return; + } const keybindingContext = pane === "details" ? rightPane.kind === "help" ? "Help" : "Select" : pane === "drawer" ? "Tabs" : "Chat"; diff --git a/apps/ade-cli/src/tuiClient/components/ModelPicker/ModelPickerPane.tsx b/apps/ade-cli/src/tuiClient/components/ModelPicker/ModelPickerPane.tsx index 8416bc5b5..f93ae5ec5 100644 --- a/apps/ade-cli/src/tuiClient/components/ModelPicker/ModelPickerPane.tsx +++ b/apps/ade-cli/src/tuiClient/components/ModelPicker/ModelPickerPane.tsx @@ -208,6 +208,7 @@ function RailLogoSlot({ mark, dim = false }: { mark: ProviderMark; dim?: boolean function settingIcon(kind: SetupPaneRowKind): string { switch (kind) { case "interface": return "⇄"; + case "import-session": return "⇩"; case "reasoning": return "✦"; case "permission": return "◆"; case "codex-fast": return "↯"; diff --git a/apps/ade-cli/src/tuiClient/components/RightPane.tsx b/apps/ade-cli/src/tuiClient/components/RightPane.tsx index 9d2cf1a34..2fc103763 100644 --- a/apps/ade-cli/src/tuiClient/components/RightPane.tsx +++ b/apps/ade-cli/src/tuiClient/components/RightPane.tsx @@ -10,6 +10,14 @@ import type { } from "../types"; import type { AgentChatSessionSummary } from "../../../../desktop/src/shared/types/chat"; import { theme } from "../theme"; +import { + externalSessionActionKey, + externalSessionProviderLabel, + importAffordancesFor, + shortenCwd, + visibleExternalSessions, +} from "../externalSessionBrowser"; +import { formatRelativePastTime } from "../relativeTime"; import { buildSubagentPaneRows, type SubagentPaneRow } from "../subagentPane"; import { ModelPickerPane } from "./ModelPicker/ModelPickerPane"; import { buildModelPickerLayout } from "./ModelPicker/modelPickerLayout"; @@ -75,6 +83,7 @@ function diffLineTone(kind: DiffLineKind): { color: string; dim: boolean; bold: const DEFAULT_PANE_WIDTH = 38; const LANE_FILE_PREVIEW_ROWS = 5; +const EXTERNAL_SESSION_ROW_WINDOW = 4; export const DETAILS_BODY_MAX_LINES = 26; // --------------------------------------------------------------------------- @@ -1315,6 +1324,157 @@ function buildDiffRenderLines( return out; } +function externalSessionAge(session: { updatedAt: number | null; createdAt: number | null }): string { + const timestamp = session.updatedAt ?? session.createdAt; + if (typeof timestamp !== "number" || !Number.isFinite(timestamp)) return "recently"; + return formatRelativePastTime(new Date(timestamp).toISOString()); +} + +function ExternalSessionBrowserPane({ + content, + width, +}: { + content: Extract; + width: number; +}) { + const inner = Math.max(12, width - 4); + const visible = visibleExternalSessions(content.sessions, content.providerFilter, content.query); + const selectedIndex = visible.length + ? Math.min(Math.max(0, content.selectedIndex), visible.length - 1) + : 0; + const selectedSession = visible[selectedIndex] ?? null; + const actionRows = selectedSession ? importAffordancesFor(selectedSession) : []; + const selectedActionIndex = actionRows.length + ? Math.min(Math.max(0, content.actionIndex), actionRows.length - 1) + : 0; + const windowStart = Math.min( + Math.max(0, selectedIndex - Math.floor(EXTERNAL_SESSION_ROW_WINDOW / 2)), + Math.max(0, visible.length - EXTERNAL_SESSION_ROW_WINDOW), + ); + const rowWindow = visible.slice(windowStart, windowStart + EXTERNAL_SESSION_ROW_WINDOW); + const providerFilter = externalSessionProviderLabel(content.providerFilter); + const queryText = content.query.trim(); + + return ( + + + filter + {providerFilter} + {` · ${visible.length}/${content.sessions.length}`} + {queryText ? ( + {` · "${endTruncate(queryText, Math.max(4, inner - 18))}"`} + ) : null} + + + {content.error ? ( + + {endTruncate(content.error, inner)} + + ) : null} + {content.importError ? ( + + {endTruncate(content.importError, inner)} + + ) : null} + {content.loading ? ( + + Scanning sessions... + + ) : null} + + {!content.loading && !rowWindow.length ? ( + + + {content.sessions.length ? "No sessions match this filter." : "No external sessions found."} + + + ) : null} + + {rowWindow.length ? ( + + {windowStart > 0 ? {` ${windowStart} earlier`} : null} + {rowWindow.map((session, offset) => { + const absoluteIndex = windowStart + offset; + const selected = absoluteIndex === selectedIndex; + const brand = theme.provider(session.provider); + const title = session.title?.trim() || session.preview?.trim() || session.id; + const cwd = shortenCwd(session.cwd, 4); + const messageCount = typeof session.messageCount === "number" && Number.isFinite(session.messageCount) + ? `${compactNumber(session.messageCount)} msg${session.messageCount === 1 ? "" : "s"}` + : "messages ?"; + const badges = [ + session.alreadyImported ? "imported" : null, + session.possiblyActive ? "may be open elsewhere" : null, + ].filter((value): value is string => Boolean(value)); + return ( + + + {selected ? theme.rail : " "} + {`${brand.glyph} ${externalSessionProviderLabel(session.provider)} `} + + {endTruncate(title, Math.max(8, inner - 15))} + + + + {` ${externalSessionAge(session)} · ${messageCount} · ${endTruncate(cwd, Math.max(8, inner - 20))}`} + + {badges.length ? ( + + {` ${endTruncate(badges.join(" · "), Math.max(8, inner - 2))}`} + + ) : null} + + ); + })} + {windowStart + rowWindow.length < visible.length ? ( + {` ${visible.length - windowStart - rowWindow.length} more`} + ) : null} + + ) : null} + + {selectedSession ? ( + + Actions + {actionRows.length ? actionRows.map((action, index) => { + const focused = index === selectedActionIndex; + const importing = content.importingKey === externalSessionActionKey(selectedSession, action); + const color = !action.enabled + ? theme.color.t5 + : focused + ? theme.color.violet + : action.hero + ? theme.color.t1 + : theme.color.t3; + const hint = action.disabledReason ?? action.hint ?? (action.foreignCwd ? `runs in ${shortenCwd(action.foreignCwd, 4)}` : null); + return ( + + + {focused ? theme.rail : " "} + {importing ? "◐ " : focused ? "↵ " : " "} + {endTruncate(action.label, Math.max(8, inner - 4))} + + {hint ? ( + + {` ${endTruncate(hint, Math.max(8, inner - 3))}`} + + ) : null} + + ); + }) : ( + No import actions available. + )} + + ) : null} + + + + {endTruncate("up/down rows · left/right actions · enter import · r refresh · p provider · type search", inner)} + + + + ); +} + export function rightPaneScrollableRowCount(content: RightPaneContent): number { switch (content.kind) { case "details": @@ -1338,6 +1498,7 @@ export function rightPaneScrollableRowCount(content: RightPaneContent): number { case "form": case "chat-info": case "model-picker": + case "external-session-browser": case "help": case "lane-details": // These panes have their own internal navigation (help uses selectedIndex, @@ -1826,6 +1987,8 @@ function paneTitle(content: RightPaneContent): { title: string; hint?: string; b return { title: `CHAT INFO · ${theme.provider(content.info.provider).label.toUpperCase()}` }; case "model-picker": return { title: content.surface === "new-chat" ? "MODEL · NEW CHAT" : "MODEL" }; + case "external-session-browser": + return { title: "IMPORT SESSION", hint: "r refresh · p provider" }; case "help": return { title: "HELP" }; case "status": @@ -1971,6 +2134,10 @@ function RightPaneComponent({ ) : null} + {content.kind === "external-session-browser" ? ( + + ) : null} + {content.kind === "usage" ? ( ) : null} diff --git a/apps/ade-cli/src/tuiClient/externalSessionBrowser.ts b/apps/ade-cli/src/tuiClient/externalSessionBrowser.ts new file mode 100644 index 000000000..95341cd0b --- /dev/null +++ b/apps/ade-cli/src/tuiClient/externalSessionBrowser.ts @@ -0,0 +1,104 @@ +import { + importAffordancesFor, + shortenCwd, + type ImportAffordance, +} from "../../../desktop/src/renderer/components/terminals/importSessions/affordances"; +import type { + ExternalSessionProvider, + ExternalSessionSummary, +} from "../../../desktop/src/shared/types/externalSessions"; +import type { RightPaneContent } from "./types"; + +export { importAffordancesFor, shortenCwd }; +export type { ImportAffordance }; + +export const EXTERNAL_SESSION_PROVIDER_FILTERS = [ + "all", + "claude", + "codex", + "cursor", + "droid", + "opencode", +] as const; + +export type ExternalSessionProviderFilter = (typeof EXTERNAL_SESSION_PROVIDER_FILTERS)[number]; + +const PROVIDER_LABELS: Record = { + claude: "Claude", + codex: "Codex", + cursor: "Cursor", + droid: "Droid", + opencode: "OpenCode", +}; + +export function externalSessionProviderLabel(provider: ExternalSessionProvider | "all"): string { + return provider === "all" ? "All" : PROVIDER_LABELS[provider] ?? provider; +} + +export function normalizeExternalSessionListResult(result: unknown): ExternalSessionSummary[] { + if (Array.isArray(result)) return result as ExternalSessionSummary[]; + if (!result || typeof result !== "object") return []; + const sessions = (result as { sessions?: unknown }).sessions; + return Array.isArray(sessions) ? sessions as ExternalSessionSummary[] : []; +} + +export function visibleExternalSessions( + sessions: readonly ExternalSessionSummary[], + providerFilter: ExternalSessionProviderFilter, + query: string, +): ExternalSessionSummary[] { + const needle = query.trim().toLowerCase(); + return sessions + .filter((session) => providerFilter === "all" || session.provider === providerFilter) + .filter((session) => { + if (!needle) return true; + return [ + session.title, + session.preview, + session.cwd, + session.id, + ] + .filter((value): value is string => typeof value === "string" && value.length > 0) + .some((value) => value.toLowerCase().includes(needle)); + }) + .sort((a, b) => { + const bTime = b.updatedAt ?? b.createdAt ?? 0; + const aTime = a.updatedAt ?? a.createdAt ?? 0; + if (bTime !== aTime) return bTime - aTime; + return (a.title ?? a.id).localeCompare(b.title ?? b.id); + }); +} + +export function nextExternalSessionProviderFilter( + current: ExternalSessionProviderFilter, + delta = 1, +): ExternalSessionProviderFilter { + const index = Math.max(0, EXTERNAL_SESSION_PROVIDER_FILTERS.indexOf(current)); + return EXTERNAL_SESSION_PROVIDER_FILTERS[ + (index + delta + EXTERNAL_SESSION_PROVIDER_FILTERS.length) % EXTERNAL_SESSION_PROVIDER_FILTERS.length + ] ?? "all"; +} + +export function clampExternalSessionBrowserContent( + content: Extract, +): Extract { + const visible = visibleExternalSessions(content.sessions, content.providerFilter, content.query); + const selectedIndex = visible.length + ? Math.min(Math.max(0, content.selectedIndex), visible.length - 1) + : 0; + const selected = visible[selectedIndex] ?? null; + const actions = selected ? importAffordancesFor(selected) : []; + const actionIndex = actions.length + ? Math.min(Math.max(0, content.actionIndex), actions.length - 1) + : 0; + return selectedIndex === content.selectedIndex && actionIndex === content.actionIndex + ? content + : { ...content, selectedIndex, actionIndex }; +} + +export function externalSessionActionKey( + session: ExternalSessionSummary, + affordance: ImportAffordance, +): string { + return `${session.provider}:${session.id}:${affordance.kind}`; +} diff --git a/apps/ade-cli/src/tuiClient/modelState.ts b/apps/ade-cli/src/tuiClient/modelState.ts index 165650d1c..9263b6965 100644 --- a/apps/ade-cli/src/tuiClient/modelState.ts +++ b/apps/ade-cli/src/tuiClient/modelState.ts @@ -439,6 +439,9 @@ export function buildSetupRows(args: { models: AgentChatModelInfo[]; includeRefresh: boolean; includeApply: boolean; + includeImportSession?: boolean; + importSessionEnabled?: boolean; + importSessionDetail?: string | null; outputStyle?: string | null; outputStyleEditable?: boolean; /** Draft/next-chat interface (Chat = SDK chat, CLI = tracked terminal). */ @@ -471,6 +474,15 @@ export function buildSetupRows(args: { disabled: !args.interfaceEditable, cyclable: args.interfaceEditable, }, + ...(args.includeImportSession + ? [{ + kind: "import-session" as const, + label: "Import session", + value: args.importSessionEnabled === false ? "unavailable" : "open", + detail: args.importSessionDetail ?? "external CLI history", + disabled: args.importSessionEnabled === false, + }] + : []), { kind: "model", label: "Model", diff --git a/apps/ade-cli/src/tuiClient/types.ts b/apps/ade-cli/src/tuiClient/types.ts index 3b61809b1..42fe3e95e 100644 --- a/apps/ade-cli/src/tuiClient/types.ts +++ b/apps/ade-cli/src/tuiClient/types.ts @@ -20,6 +20,10 @@ import type { CodexThreadGoal, PendingInputRequest, } from "../../../desktop/src/shared/types/chat"; +import type { + ExternalSessionProvider, + ExternalSessionSummary, +} from "../../../desktop/src/shared/types/externalSessions"; import type { LaneSummary } from "../../../desktop/src/shared/types/lanes"; import type { BufferedEvent } from "../eventBuffer"; import type { HelpGroup } from "./helpIndex"; @@ -125,6 +129,7 @@ export type ProviderReadinessRow = { export type SetupPaneRowKind = | "provider" | "interface" + | "import-session" | "model" | "reasoning" | "permission" @@ -266,6 +271,21 @@ export type RightPaneContent = | { kind: "context-usage"; title: string; usage: AgentChatContextUsage | null; error?: string | null } | { kind: "diff"; title: string; files: Array<{ path: string; additions?: number; deletions?: number; body?: string }> } | { kind: "chat-info"; info: ChatInfoSnapshot } + | { + kind: "external-session-browser"; + laneId: string; + laneLabel: string; + providerFilter: ExternalSessionProvider | "all"; + query: string; + sessions: ExternalSessionSummary[]; + loading: boolean; + error?: string | null; + importError?: string | null; + importingKey?: string | null; + selectedIndex: number; + actionIndex: number; + loadedAt?: number | null; + } | { // /usage pane: provider quota window(s) + this session's tokens & cost. // `quotaWindows` undefined ⇒ no quota data in the snapshot (the daemon diff --git a/apps/desktop/resources/ade-cli-help.txt b/apps/desktop/resources/ade-cli-help.txt index 7508f87d9..f1dd6f044 100644 --- a/apps/desktop/resources/ade-cli-help.txt +++ b/apps/desktop/resources/ade-cli-help.txt @@ -44,8 +44,8 @@ _ ____ _____ Control an attached session terminal $ ade history list | show | commits | export Inspect ADE operation timeline and lane commits $ ade chat list | create | send | interrupt Work with ADE agent chats - $ ade cto state | chats Operate CTO state and Work chats - $ ade linear graphql | workflows | run | sync Operate Linear GraphQL, routing, and sync workflows + $ ade linear attach | comment | set-state | issue | graphql + Read and write attached Linear issues $ ade github app-auth login | status | clear Authorize the machine ADE GitHub App (device flow) $ ade automations list | create | run | runs Manage automation rules $ ade coordinator Call coordinator runtime tools @@ -150,8 +150,8 @@ _ ____ _____ Control an attached session terminal $ ade history list | show | commits | export Inspect ADE operation timeline and lane commits $ ade chat list | create | send | interrupt Work with ADE agent chats - $ ade cto state | chats Operate CTO state and Work chats - $ ade linear graphql | workflows | run | sync Operate Linear GraphQL, routing, and sync workflows + $ ade linear attach | comment | set-state | issue | graphql + Read and write attached Linear issues $ ade github app-auth login | status | clear Authorize the machine ADE GitHub App (device flow) $ ade automations list | create | run | runs Manage automation rules $ ade coordinator Call coordinator runtime tools @@ -256,8 +256,8 @@ _ ____ _____ Control an attached session terminal $ ade history list | show | commits | export Inspect ADE operation timeline and lane commits $ ade chat list | create | send | interrupt Work with ADE agent chats - $ ade cto state | chats Operate CTO state and Work chats - $ ade linear graphql | workflows | run | sync Operate Linear GraphQL, routing, and sync workflows + $ ade linear attach | comment | set-state | issue | graphql + Read and write attached Linear issues $ ade github app-auth login | status | clear Authorize the machine ADE GitHub App (device flow) $ ade automations list | create | run | runs Manage automation rules $ ade coordinator Call coordinator runtime tools @@ -533,6 +533,7 @@ _ ____ _____ --lane, --lane-id Restrict to a lane (id or name; the service resolves names). --limit Max results to return. --cursor Continue from a previous query's nextCursor. + --actions List the raw search service actions exposed via ADE actions. --status Show index doc counts, backfill state, and index path. --rebuild Rebuild the whole index from scratch (CTO-only). --text Aligned KIND/TITLE/SNIPPET/ID rows plus a count summary. @@ -731,13 +732,104 @@ _ ____ _____ / ___ \| |_| | |___ /_/ \_\____/|_____| - CTO and Work state + Agent-focused command-line interface for ADE. + + ADE CLI commands operate through the machine ADE brain by default. + The brain is the always-on ADE process for this machine: it owns the project + catalog, sync endpoint, and execution authority for the channel. + + $ ade help Display help for a command + $ ade auth status Check local ADE CLI readiness + $ ade code Open ADE Work chat in the terminal + $ ade new chat --mode chat|cli --prompt "fix" Start an ADE Work chat or tracked CLI session + $ ade desktop Launch the installed desktop app + $ ade open Open an ade:// or ade-app.dev deeplink via the OS + $ ade link lane | session | branch | pr | linear-issue + Build a shareable deeplink (copies to clipboard) + $ ade linear install Register ADE as Linear's "Open in coding tool" target + $ ade skill list | show Browse ADE's bundled agent skills (local) + $ ade brain start | stop | status Manage the background ADE brain + $ ade runtime run --socket Run a manual runtime for dev/test work + $ ade rpc --stdio Speak ADE JSON-RPC over stdin/stdout + $ ade init [path] Register a project with this machine brain + $ ade projects list List projects registered on this machine + $ ade sync status | pin generate Manage machine sync and phone pairing + $ ade doctor Inspect project, brain, runtime, and tool availability + $ ade lanes list | show | create | child Work with lanes and lane stacks + $ ade git status | commit | push | stash Run ADE-aware git operations + $ ade operations status | wait Poll operation/test/chat/run status + $ ade diff changes | file | patch Inspect lane diffs (including raw git patch text) + $ ade files tree | read | write | search Read and edit lane workspaces + $ ade search "" --text Search chats, terminals, PRs, commits, lanes, files, Linear + $ ade prs list | create | show | checks Manage PRs, queues, and GitHub integration + $ ade run defs | ps | start | logs Manage Run tab process definitions and runtime + $ ade shell start | write | resize | close Launch and control tracked shell sessions + $ ade terminal list | resume | read | write | signal + Control an attached session terminal + $ ade history list | show | commits | export Inspect ADE operation timeline and lane commits + $ ade chat list | create | send | interrupt Work with ADE agent chats + $ ade linear attach | comment | set-state | issue | graphql + Read and write attached Linear issues + $ ade github app-auth login | status | clear Authorize the machine ADE GitHub App (device flow) + $ ade automations list | create | run | runs Manage automation rules + $ ade coordinator Call coordinator runtime tools + $ ade tests list | run | stop | runs | logs Run configured test suites + $ ade proof status | list | screenshot | record Manage proof and computer-use artifacts + $ ade ios-sim devices | apps | launch | tap Control iOS Simulator apps, capture, and input + $ ade app-control launch | snapshot | click Inspect and drive Electron apps + $ ade browser open | tabs | screenshot Use ADE's built-in browser pane + $ ade usage snapshot | refresh | budget Read provider quota usage and budget guardrails + $ ade secrets list | get | set | delete Manage encrypted ADE project secrets for agents + $ ade settings pr-transcript-gists enable Attach ADE chat transcript links to new PRs + $ ade settings action Call project config actions + $ ade update status | check | install | dismiss Read auto-update state and drive install + $ ade actions list | run | status | wait Escape hatch for every ADE service action + $ ade cursor cloud agents | runs | artifacts | repos | models | me + Drive Cursor Cloud agents via @cursor/sdk + + Global options: + --project-root ADE project root. Inside .ade/worktrees/, this resolves to the parent project. + --workspace-root Lane/worktree to treat as the active workspace. + --headless Skip the machine brain and run an in-process ADE runtime. + --socket Require a live ADE endpoint; fail instead of falling back to headless. + --json Print machine-readable JSON. This is the default output mode. + --text Print a compact human-readable summary when a formatter exists. + --timeout-ms Per-request timeout. Long agent/PR workflows may need several minutes. - $ ade cto state --text Read CTO identity, recent sessions - $ ade cto chats list --text List CTO work chats - $ ade cto chats spawn --lane --prompt "plan this" - $ ade cto chats send --text "continue" - $ ade actions run worker_agent.listAgents --input-json '{"includeDeleted":false}' + Common agent flows: + $ ade doctor --text + $ ade lanes list --text + $ ade lanes create --name fix-login --description "Repair login redirect" + $ ade git status --lane --text + $ ade git status --full --lane --text + $ ade git sync --lane --rebase --base main + $ ade git stage --lane src/index.ts + $ ade git commit --lane -m "Fix login redirect" + $ ade prs create --lane --base main --draft + $ ade prs checks --text + $ ade proof record --seconds 20 + $ ade ios-sim apps --text + $ ade ios-sim launch --target --text + $ ade app-control launch --command "pnpm dev" --text + $ ade --socket browser open http://localhost:5173 --new-tab --text + $ ade terminal read --chat-session --text + $ ade terminal read --pty --text + $ ade new chat --mode chat --lane --provider codex --model openai/gpt-5.5 --reasoning-effort xhigh --permissions full-auto --no-fast --prompt "Fix the tests" + $ ade new chat --mode cli --lane --provider codex --model openai/gpt-5.5 --reasoning-effort xhigh --permissions full-auto --no-fast --prompt "Fix the tests" + + Generic ADE action JSON contract: + Object-shaped call: + $ ade actions run git.push --input-json '{"laneId":"lane-1","setUpstream":true}' + $ ade actions run git.push --arg laneId=lane-1 --arg setUpstream=true + JSON value fields: + $ ade actions run pr.setLabels --arg prId=123 --arg-json 'labels=["ready","ship"]' + Multi-parameter service call: + $ ade actions run pr.submitReview --args-list-json '["pr-1",{"event":"APPROVE"}]' + $ ade actions list --text + $ ade actions list --domain pr --text + $ ade actions run --input-json '{"key":"value"}' + + Start with: ade doctor --text ## ade linear --help _ ____ _____ @@ -777,17 +869,6 @@ _ ____ _____ Search issues for the lane Linear-issue picker $ ade --role cto linear issue-comments --issue-id Fetch comments on a Linear issue - $ ade linear workflows --text List configured workflows - $ ade linear sync dashboard --text Show sync dashboard - $ ade linear sync run Trigger a sync run - $ ade linear sync queue --text List sync queue items - $ ade linear sync resolve --queue-item --action approve - $ ade linear ingress status --text Show Linear ingress status - $ ade linear ingress start --text Ensure Linear webhook and start relay ingress - $ ade linear ingress start-local --text Start only the local Linear webhook listener - $ ade linear route worker --input-json '{"issueId":"LIN-123","workerId":"worker-1"}' - $ ade linear install Register ADE as the "Open in coding tool" target - $ ade linear install --dry-run Preview the ~/.linear/coding-tools.json write ## ade automations --help _ ____ _____ @@ -835,13 +916,104 @@ _ ____ _____ / ___ \| |_| | |___ /_/ \_\____/|_____| - Flow policy + Agent-focused command-line interface for ADE. + + ADE CLI commands operate through the machine ADE brain by default. + The brain is the always-on ADE process for this machine: it owns the project + catalog, sync endpoint, and execution authority for the channel. + + $ ade help Display help for a command + $ ade auth status Check local ADE CLI readiness + $ ade code Open ADE Work chat in the terminal + $ ade new chat --mode chat|cli --prompt "fix" Start an ADE Work chat or tracked CLI session + $ ade desktop Launch the installed desktop app + $ ade open Open an ade:// or ade-app.dev deeplink via the OS + $ ade link lane | session | branch | pr | linear-issue + Build a shareable deeplink (copies to clipboard) + $ ade linear install Register ADE as Linear's "Open in coding tool" target + $ ade skill list | show Browse ADE's bundled agent skills (local) + $ ade brain start | stop | status Manage the background ADE brain + $ ade runtime run --socket Run a manual runtime for dev/test work + $ ade rpc --stdio Speak ADE JSON-RPC over stdin/stdout + $ ade init [path] Register a project with this machine brain + $ ade projects list List projects registered on this machine + $ ade sync status | pin generate Manage machine sync and phone pairing + $ ade doctor Inspect project, brain, runtime, and tool availability + $ ade lanes list | show | create | child Work with lanes and lane stacks + $ ade git status | commit | push | stash Run ADE-aware git operations + $ ade operations status | wait Poll operation/test/chat/run status + $ ade diff changes | file | patch Inspect lane diffs (including raw git patch text) + $ ade files tree | read | write | search Read and edit lane workspaces + $ ade search "" --text Search chats, terminals, PRs, commits, lanes, files, Linear + $ ade prs list | create | show | checks Manage PRs, queues, and GitHub integration + $ ade run defs | ps | start | logs Manage Run tab process definitions and runtime + $ ade shell start | write | resize | close Launch and control tracked shell sessions + $ ade terminal list | resume | read | write | signal + Control an attached session terminal + $ ade history list | show | commits | export Inspect ADE operation timeline and lane commits + $ ade chat list | create | send | interrupt Work with ADE agent chats + $ ade linear attach | comment | set-state | issue | graphql + Read and write attached Linear issues + $ ade github app-auth login | status | clear Authorize the machine ADE GitHub App (device flow) + $ ade automations list | create | run | runs Manage automation rules + $ ade coordinator Call coordinator runtime tools + $ ade tests list | run | stop | runs | logs Run configured test suites + $ ade proof status | list | screenshot | record Manage proof and computer-use artifacts + $ ade ios-sim devices | apps | launch | tap Control iOS Simulator apps, capture, and input + $ ade app-control launch | snapshot | click Inspect and drive Electron apps + $ ade browser open | tabs | screenshot Use ADE's built-in browser pane + $ ade usage snapshot | refresh | budget Read provider quota usage and budget guardrails + $ ade secrets list | get | set | delete Manage encrypted ADE project secrets for agents + $ ade settings pr-transcript-gists enable Attach ADE chat transcript links to new PRs + $ ade settings action Call project config actions + $ ade update status | check | install | dismiss Read auto-update state and drive install + $ ade actions list | run | status | wait Escape hatch for every ADE service action + $ ade cursor cloud agents | runs | artifacts | repos | models | me + Drive Cursor Cloud agents via @cursor/sdk + + Global options: + --project-root ADE project root. Inside .ade/worktrees/, this resolves to the parent project. + --workspace-root Lane/worktree to treat as the active workspace. + --headless Skip the machine brain and run an in-process ADE runtime. + --socket Require a live ADE endpoint; fail instead of falling back to headless. + --json Print machine-readable JSON. This is the default output mode. + --text Print a compact human-readable summary when a formatter exists. + --timeout-ms Per-request timeout. Long agent/PR workflows may need several minutes. - $ ade flow policy get --text Read current workflow policy - $ ade flow policy validate --input-json '{...}' Validate policy JSON - $ ade flow policy save --input-json '{...}' Save policy JSON - $ ade flow policy revisions --text List saved revisions - $ ade flow policy rollback Restore a prior revision + Common agent flows: + $ ade doctor --text + $ ade lanes list --text + $ ade lanes create --name fix-login --description "Repair login redirect" + $ ade git status --lane --text + $ ade git status --full --lane --text + $ ade git sync --lane --rebase --base main + $ ade git stage --lane src/index.ts + $ ade git commit --lane -m "Fix login redirect" + $ ade prs create --lane --base main --draft + $ ade prs checks --text + $ ade proof record --seconds 20 + $ ade ios-sim apps --text + $ ade ios-sim launch --target --text + $ ade app-control launch --command "pnpm dev" --text + $ ade --socket browser open http://localhost:5173 --new-tab --text + $ ade terminal read --chat-session --text + $ ade terminal read --pty --text + $ ade new chat --mode chat --lane --provider codex --model openai/gpt-5.5 --reasoning-effort xhigh --permissions full-auto --no-fast --prompt "Fix the tests" + $ ade new chat --mode cli --lane --provider codex --model openai/gpt-5.5 --reasoning-effort xhigh --permissions full-auto --no-fast --prompt "Fix the tests" + + Generic ADE action JSON contract: + Object-shaped call: + $ ade actions run git.push --input-json '{"laneId":"lane-1","setUpstream":true}' + $ ade actions run git.push --arg laneId=lane-1 --arg setUpstream=true + JSON value fields: + $ ade actions run pr.setLabels --arg prId=123 --arg-json 'labels=["ready","ship"]' + Multi-parameter service call: + $ ade actions run pr.submitReview --args-list-json '["pr-1",{"event":"APPROVE"}]' + $ ade actions list --text + $ ade actions list --domain pr --text + $ ade actions run --input-json '{"key":"value"}' + + Start with: ade doctor --text ## ade coordinator --help _ ____ _____ @@ -1182,8 +1354,8 @@ _ ____ _____ Control an attached session terminal $ ade history list | show | commits | export Inspect ADE operation timeline and lane commits $ ade chat list | create | send | interrupt Work with ADE agent chats - $ ade cto state | chats Operate CTO state and Work chats - $ ade linear graphql | workflows | run | sync Operate Linear GraphQL, routing, and sync workflows + $ ade linear attach | comment | set-state | issue | graphql + Read and write attached Linear issues $ ade github app-auth login | status | clear Authorize the machine ADE GitHub App (device flow) $ ade automations list | create | run | runs Manage automation rules $ ade coordinator Call coordinator runtime tools diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 07e9e54de..31c9f1414 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -62,6 +62,7 @@ import { createOperationService } from "./services/history/operationService"; import { createGitOperationsService } from "./services/git/gitOperationsService"; import { createProjectSearchService } from "./services/search/searchServiceWiring"; import type { SearchService } from "./services/search/searchService"; +import { createExternalSessionsService } from "./services/externalSessions/externalSessionsService"; import { runGit } from "./services/git/git"; import { createJobEngine } from "./services/jobs/jobEngine"; import { createTranscriptionService } from "./services/transcription/transcriptionService"; @@ -3283,6 +3284,32 @@ app.whenReady().then(async () => { backfillDelayMs: 10_000, }); searchServiceHolder.current = searchService; + const externalSessionsService = createExternalSessionsService({ + projectRoot, + laneService, + sessionService, + ptyService, + logger, + chatImporter: agentChatService, + chatImportedRefsProvider: async () => { + const sessions = await agentChatService.listSessions(undefined, { + includeIdentity: true, + includeAutomation: true, + includeArchived: true, + }); + return sessions.flatMap((session) => { + const importedFrom = session.importedFrom; + if (!importedFrom?.provider?.trim() || !importedFrom.sessionId?.trim()) return []; + return [{ + provider: importedFrom.provider, + externalId: importedFrom.sessionId, + chatSessionId: session.sessionId, + }]; + }); + }, + homeDir: os.homedir(), + env: process.env, + }); const iosSimulatorService = createIosSimulatorService({ projectRoot, logger, @@ -3450,6 +3477,7 @@ app.whenReady().then(async () => { ctoStateService, linearCredentialService, getLinearIssueTracker: () => linearIssueTracker, + getExternalSessionsService: () => externalSessionsService, processService, hostStartupEnabled: syncHostAutoStart, phonePairingStateDir: machineAdeLayout.secretsDir, @@ -3815,6 +3843,7 @@ app.whenReady().then(async () => { queueLandingService, fileService, searchService, + externalSessionsService, ctoStateService, ctoMemoryService, linearCredentialService, @@ -4077,6 +4106,7 @@ app.whenReady().then(async () => { prSummaryService, reviewService, searchService, + externalSessionsService, jobEngine, transcriptionService: getSharedTranscriptionService(logger), automationService, diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index 37c8a5cbd..9779b410e 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -116,6 +116,7 @@ export const ADE_ACTION_DOMAIN_NAMES = [ "issue", "orchestration", "search", + "external-sessions", ] as const; export type AdeActionDomain = (typeof ADE_ACTION_DOMAIN_NAMES)[number]; @@ -676,6 +677,7 @@ export const ADE_ACTION_ALLOWLIST: Partial[0]); + }, + import(args: unknown) { + return externalSessionsService.importExternalSession( + (args ?? {}) as Parameters[0], + ); + }, + } as OpaqueService; +} + export function getAdeActionDomainServices( runtime: AdeRuntime, ): Partial> { @@ -2882,6 +2911,7 @@ export function getAdeActionDomainServices( issue: toService(buildIssueDomainService(runtime)), orchestration: toService(buildOrchestrationDomainService(runtime)), search: toService(buildSearchDomainService(runtime)), + "external-sessions": toService(buildExternalSessionsDomainService(runtime)), }; } diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index d7b8ce3b4..efe7b013f 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -1156,6 +1156,7 @@ function createMockLaneService() { function createMockSessionService() { const sessions = mockState.sessions; + const claudePointers = new Map(); return { create: vi.fn((args: any) => { sessions.set(args.sessionId, { @@ -1245,6 +1246,12 @@ function createMockSessionService() { row.resumeCommand = resumeCommand; } }), + upsertClaudeSessionPointer: vi.fn((pointer: any) => { + claudePointers.set(pointer.chatSessionId, pointer); + return pointer; + }), + getClaudeSessionPointerByChatSessionId: vi.fn((chatSessionId: string) => claudePointers.get(chatSessionId) ?? null), + listClaudeSessionPointers: vi.fn(() => Array.from(claudePointers.values())), } as any; } @@ -1675,6 +1682,7 @@ describe("createAgentChatService", () => { it("returns an object with all expected methods", () => { const { service } = createService(); expect(service.createSession).toBeTypeOf("function"); + expect(service.importExternalChatSession).toBeTypeOf("function"); expect(service.handoffSession).toBeTypeOf("function"); expect(service.sendMessage).toBeTypeOf("function"); expect(service.steer).toBeTypeOf("function"); @@ -1775,6 +1783,289 @@ describe("createAgentChatService", () => { expect(session.status).toBe("idle"); }); + it("imports a same-cwd Claude external chat with persisted resume identity and visible history", async () => { + const externalSessionId = "11111111-2222-3333-4444-555555555555"; + const claudeConfigRoot = process.env.CLAUDE_CONFIG_DIR?.trim() || path.join(tmpHomeRoot, ".claude"); + const claudeProjectDir = path.join( + claudeConfigRoot, + "projects", + tmpRoot.replace(/[^A-Za-z0-9]/g, "-"), + ); + fs.mkdirSync(claudeProjectDir, { recursive: true }); + fs.writeFileSync( + path.join(claudeProjectDir, `${externalSessionId}.jsonl`), + [ + JSON.stringify({ + type: "user", + uuid: "user-1", + timestamp: "2026-07-06T10:00:00.000Z", + cwd: tmpRoot, + sessionId: externalSessionId, + message: { role: "user", content: [{ type: "text", text: "Please inspect the failing test." }] }, + }), + JSON.stringify({ + type: "assistant", + uuid: "assistant-1", + timestamp: "2026-07-06T10:00:02.000Z", + cwd: tmpRoot, + sessionId: externalSessionId, + message: { + role: "assistant", + content: [ + { type: "text", text: "I will check the focused test output." }, + { type: "tool_use", id: "toolu_01", name: "Bash", input: { command: "npm test" } }, + ], + }, + }), + ].join("\n"), + "utf8", + ); + + const { service, sessionService } = createService(); + const result = await service.importExternalChatSession({ + provider: "claude", + externalSessionId, + laneId: "lane-1", + cwd: tmpRoot, + fork: false, + }); + + const persisted = readPersistedChatState(result.chatSessionId); + expect(persisted.sdkSessionId).toBe(externalSessionId); + expect(persisted.claudeBackgroundResumeSessionId).toBe(externalSessionId); + expect(persisted.importedFrom).toMatchObject({ + provider: "claude", + sessionId: externalSessionId, + }); + expect(typeof persisted.importedFrom.importedAt).toBe("number"); + expect(sessionService.getClaudeSessionPointerByChatSessionId(result.chatSessionId)).toMatchObject({ + sessionId: externalSessionId, + laneId: "lane-1", + chatSessionId: result.chatSessionId, + }); + expect(sessionService.get(result.chatSessionId)?.title).toBe("Please inspect the failing test"); + + const history = service.getChatEventHistory(result.chatSessionId, { maxEvents: 10 }); + expect(history.events.map((envelope) => envelope.event.type)).toEqual([ + "system_notice", + "user_message", + "text", + "tool_call", + ]); + expect(history.events[0]!.event).toMatchObject({ type: "system_notice", message: "Session imported from claude CLI (11111111)" }); + expect(history.events[1]!.event).toMatchObject({ type: "user_message", text: "Please inspect the failing test." }); + expect(history.events[2]!.event).toMatchObject({ type: "text", text: "I will check the focused test output." }); + await expect(service.getSessionSummary(result.chatSessionId)).resolves.toMatchObject({ + importedFrom: { + provider: "claude", + sessionId: externalSessionId, + }, + }); + }); + + it("forks a same-cwd Claude chat import into a new SDK session id", async () => { + const externalSessionId = "12121212-3434-4343-8343-565656565656"; + const previousClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; + const claudeConfigRoot = path.join(tmpHomeRoot, ".claude"); + process.env.CLAUDE_CONFIG_DIR = claudeConfigRoot; + try { + const sourcePath = path.join( + claudeConfigRoot, + "projects", + tmpRoot.replace(/[^A-Za-z0-9]/g, "-"), + `${externalSessionId}.jsonl`, + ); + fs.mkdirSync(path.dirname(sourcePath), { recursive: true }); + fs.writeFileSync( + sourcePath, + [ + JSON.stringify({ + type: "user", + uuid: "user-1", + timestamp: "2026-07-06T10:00:00.000Z", + cwd: tmpRoot, + sessionId: externalSessionId, + message: { role: "user", content: [{ type: "text", text: "Fork this without mutating the source." }] }, + }), + ].join("\n") + "\n", + "utf8", + ); + const sourceBefore = fs.readFileSync(sourcePath, "utf8"); + const readline = await import("node:readline"); + const createLineReader = (options: { input: AsyncIterable }) => ({ + on: vi.fn(), + close: vi.fn(), + [Symbol.asyncIterator]: () => (async function* () { + const chunks: string[] = []; + for await (const chunk of options.input) chunks.push(String(chunk)); + for (const line of chunks.join("").split(/\r?\n/u)) yield line; + })(), + }); + vi.mocked(readline.createInterface).mockImplementationOnce(createLineReader as any); + vi.mocked((readline as any).default.createInterface).mockImplementationOnce(createLineReader as any); + const { service } = createService(); + + const result = await service.importExternalChatSession({ + provider: "claude", + externalSessionId, + laneId: "lane-1", + cwd: tmpRoot, + fork: true, + }); + + const persisted = readPersistedChatState(result.chatSessionId); + expect(persisted.sdkSessionId).not.toBe(externalSessionId); + expect(persisted.claudeBackgroundResumeSessionId).toBe(persisted.sdkSessionId); + expect(fs.readFileSync(sourcePath, "utf8")).toBe(sourceBefore); + const projectsDir = path.join(claudeConfigRoot, "projects"); + const forkedPath = fs.readdirSync(projectsDir) + .flatMap((entry) => { + const projectDir = path.join(projectsDir, entry); + return fs.statSync(projectDir).isDirectory() + ? fs.readdirSync(projectDir).map((fileName) => path.join(projectDir, fileName)) + : []; + }) + .find((candidate) => path.basename(candidate) === `${persisted.sdkSessionId}.jsonl`); + expect(forkedPath).toBeTruthy(); + const forkedRows = fs.readFileSync(forkedPath!, "utf8").trim().split(/\r?\n/u).map((line) => JSON.parse(line)); + expect(forkedRows[0]).toMatchObject({ + cwd: tmpRoot, + sessionId: persisted.sdkSessionId, + }); + } finally { + if (previousClaudeConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = previousClaudeConfigDir; + } + } + }); + + it("preserves the source Claude JSONL when a failed cross-cwd chat import follows transplant", async () => { + const externalSessionId = "22222222-3333-4333-8333-666666666666"; + const sourceCwd = path.join(tmpHomeRoot, "source-project"); + const claudeConfigRoot = process.env.CLAUDE_CONFIG_DIR?.trim() || path.join(tmpHomeRoot, ".claude"); + const sourcePath = path.join( + claudeConfigRoot, + "projects", + sourceCwd.replace(/[^A-Za-z0-9]/g, "-"), + `${externalSessionId}.jsonl`, + ); + fs.mkdirSync(path.dirname(sourcePath), { recursive: true }); + fs.writeFileSync( + sourcePath, + JSON.stringify({ + type: "user", + uuid: "user-1", + timestamp: "2026-07-06T10:00:00.000Z", + cwd: sourceCwd, + sessionId: externalSessionId, + message: { role: "user", content: [{ type: "text", text: "Keep my original transcript." }] }, + }) + "\n", + "utf8", + ); + const failingSessionService = createMockSessionService(); + vi.mocked(failingSessionService.create).mockImplementationOnce(() => { + throw new Error("create failed after transplant"); + }); + const readline = await import("node:readline"); + const createLineReader = (options: { input: AsyncIterable }) => ({ + on: vi.fn(), + close: vi.fn(), + [Symbol.asyncIterator]: () => (async function* () { + const chunks: string[] = []; + for await (const chunk of options.input) chunks.push(String(chunk)); + for (const line of chunks.join("").split(/\r?\n/u)) yield line; + })(), + }); + vi.mocked(readline.createInterface).mockImplementationOnce(createLineReader as any); + vi.mocked((readline as any).default.createInterface).mockImplementationOnce(createLineReader as any); + const { service } = createService({ sessionService: failingSessionService }); + + await expect(service.importExternalChatSession({ + provider: "claude", + externalSessionId, + laneId: "lane-1", + cwd: sourceCwd, + fork: false, + })).rejects.toThrow("create failed after transplant"); + + expect(fs.existsSync(sourcePath)).toBe(true); + }); + + it("archives a forked Codex provider thread when a chat import fails after fork", async () => { + mockState.codexResponseOverrides.set("thread/fork", () => ({ + thread: { id: "forked-thread-1" }, + })); + mockState.codexResponseOverrides.set("thread/read", (payload) => { + const params = payload.params as { threadId?: unknown } | undefined; + if (params?.threadId === "source-thread-1") { + return { thread: { id: "source-thread-1", turns: [] } }; + } + return {}; + }); + const { service, sessionService } = createService(); + + await expect(service.importExternalChatSession({ + provider: "codex", + externalSessionId: "source-thread-1", + laneId: "lane-1", + cwd: tmpRoot, + fork: true, + })).rejects.toThrow(/was not found by thread\/read/i); + + expect(mockState.codexRequestPayloads).toEqual(expect.arrayContaining([ + expect.objectContaining({ + method: "thread/archive", + params: { threadId: "forked-thread-1" }, + }), + ])); + expect(sessionService.get("test-uuid-1")).toBeNull(); + }); + + it("reacquires a Codex runtime to archive a forked provider thread when the import runtime cannot clean up", async () => { + let archiveAttempts = 0; + mockState.codexResponseOverrides.set("thread/fork", () => ({ + thread: { id: "forked-thread-2" }, + })); + mockState.codexResponseOverrides.set("thread/read", (payload) => { + const params = payload.params as { threadId?: unknown } | undefined; + if (params?.threadId === "source-thread-2") { + return { thread: { id: "source-thread-2", turns: [] } }; + } + return {}; + }); + mockState.codexResponseOverrides.set("thread/archive", () => { + archiveAttempts += 1; + if (archiveAttempts === 1) { + return { error: { code: -32000, message: "dead runtime" } }; + } + return {}; + }); + const { service, logger, sessionService } = createService(); + + await expect(service.importExternalChatSession({ + provider: "codex", + externalSessionId: "source-thread-2", + laneId: "lane-1", + cwd: tmpRoot, + fork: true, + })).rejects.toThrow(/was not found by thread\/read/i); + + const initializeRequests = mockState.codexRequestPayloads.filter((payload) => payload.method === "initialize"); + const archiveRequests = mockState.codexRequestPayloads.filter((payload) => payload.method === "thread/archive"); + expect(initializeRequests).toHaveLength(2); + expect(archiveRequests).toEqual([ + expect.objectContaining({ params: { threadId: "forked-thread-2" } }), + expect.objectContaining({ params: { threadId: "forked-thread-2" } }), + ]); + expect(logger.warn).not.toHaveBeenCalledWith( + "agent_chat.external_import_codex_fork_cleanup_leaked", + expect.anything(), + ); + expect(sessionService.get("test-uuid-1")).toBeNull(); + }); + it("derives the runtime model from modelId when raw action callers omit model", async () => { const { service } = createService(); const session = await service.createSession({ @@ -3818,8 +4109,14 @@ describe("createAgentChatService", () => { ); const startPayload = mockState.codexRequestPayloads.find((payload) => payload.method === "thread/start"); - const startParams = startPayload?.params as { effort?: unknown; reasoningEffort?: unknown; reasoning_effort?: unknown } | undefined; - expect(startParams?.effort).toBe("low"); + const startParams = startPayload?.params as { + config?: { model_reasoning_effort?: unknown }; + effort?: unknown; + reasoningEffort?: unknown; + reasoning_effort?: unknown; + } | undefined; + expect(startParams?.config?.model_reasoning_effort).toBe("low"); + expect(startParams?.effort).toBeUndefined(); expect(startParams?.reasoningEffort).toBeUndefined(); expect(startParams?.reasoning_effort).toBeUndefined(); }); @@ -16562,10 +16859,12 @@ describe("createAgentChatService", () => { reasoningEffort?: unknown; reasoning_effort?: unknown; effort?: unknown; + config?: { model_reasoning_effort?: unknown }; } | undefined; expect(params?.approvalPolicy).toBe("never"); expect(params?.sandbox).toBe("danger-full-access"); - expect(params?.effort).toBe("medium"); + expect(params?.config?.model_reasoning_effort).toBe("medium"); + expect(params?.effort).toBeUndefined(); expect(params?.reasoningEffort).toBeUndefined(); expect(params?.reasoning_effort).toBeUndefined(); @@ -16697,8 +16996,14 @@ describe("createAgentChatService", () => { }); const threadStartRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "thread/start"); - const threadStartParams = threadStartRequest?.params as { reasoningEffort?: unknown; reasoning_effort?: unknown; effort?: unknown } | undefined; - expect(threadStartParams?.effort).toBe("xhigh"); + const threadStartParams = threadStartRequest?.params as { + config?: { model_reasoning_effort?: unknown }; + reasoningEffort?: unknown; + reasoning_effort?: unknown; + effort?: unknown; + } | undefined; + expect(threadStartParams?.config?.model_reasoning_effort).toBe("xhigh"); + expect(threadStartParams?.effort).toBeUndefined(); expect(threadStartParams?.reasoningEffort).toBeUndefined(); expect(threadStartParams?.reasoning_effort).toBeUndefined(); const turnStartRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "turn/start"); @@ -16844,10 +17149,12 @@ describe("createAgentChatService", () => { sandbox?: unknown; reasoningEffort?: unknown; effort?: unknown; + config?: { model_reasoning_effort?: unknown }; } | undefined; expect(params?.approvalPolicy).toBe("never"); expect(params?.sandbox).toBe("danger-full-access"); - expect(params?.effort).toBe("medium"); + expect(params?.config?.model_reasoning_effort).toBe("medium"); + expect(params?.effort).toBeUndefined(); expect(params?.reasoningEffort).toBeUndefined(); const turnStartRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "turn/start"); @@ -17787,10 +18094,20 @@ describe("createAgentChatService", () => { const resumed = await service.resumeSession({ sessionId: session.id }); const resumeRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "thread/resume"); - const resumeParams = resumeRequest?.params as { reasoningEffort?: unknown; reasoning_effort?: unknown; effort?: unknown } | undefined; - expect(resumeParams?.effort).toBe("xhigh"); + const resumeParams = resumeRequest?.params as { + config?: { model_reasoning_effort?: unknown }; + reasoningEffort?: unknown; + reasoning_effort?: unknown; + effort?: unknown; + dynamicTools?: unknown; + persistExtendedHistory?: unknown; + } | undefined; + expect(resumeParams?.config?.model_reasoning_effort).toBe("xhigh"); + expect(resumeParams?.effort).toBeUndefined(); expect(resumeParams?.reasoningEffort).toBeUndefined(); expect(resumeParams?.reasoning_effort).toBeUndefined(); + expect(resumeParams?.dynamicTools).toBeUndefined(); + expect(resumeParams?.persistExtendedHistory).toBeUndefined(); expect(resumed.codexApprovalPolicy).toBe("never"); expect(resumed.codexSandbox).toBe("danger-full-access"); expect(resumed.permissionMode).toBe("full-auto"); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index f269df1e2..1f2b1e124 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -1,5 +1,9 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { randomUUID } from "node:crypto"; +// Static import so bundlers (tsup → apps/ade-cli brain) include the module; a +// dynamic import() with a variable path is left unresolved in cli.cjs and fails +// at runtime ("Cannot find module .../externalSessions/claudeSessionTransplant"). +import { transplantClaudeSession as staticTransplantClaudeSession } from "../externalSessions/claudeSessionTransplant"; import fs from "node:fs"; import net from "node:net"; import os from "node:os"; @@ -147,6 +151,9 @@ import type { AgentChatHandoffArgs, AgentChatHandoffResult, AgentChatIdentityKey, + AgentChatImportedFrom, + AgentChatImportExternalSessionArgs, + AgentChatImportExternalSessionResult, AgentChatNoticeDetail, AgentChatInteractionMode, AgentChatInterruptArgs, @@ -213,6 +220,13 @@ import { buildChatContextAttachmentPrompt, normalizeChatContextAttachments, } from "../../../shared/chatContextAttachments"; +import { + claudeJsonlToChatEvents, + codexTurnsToChatEvents, + deriveImportedChatTitle, + MAX_IMPORT_TRANSCRIPT_BYTES, + readTailLines, +} from "./externalChatHistoryImport"; import { getDefaultModelDescriptor, getDynamicOpenCodeModelDescriptors, @@ -453,6 +467,7 @@ type PersistedChatState = { codexGoal?: CodexThreadGoal | null; codexTokenUsage?: CodexThreadTokenUsage | null; threadId?: string; + importedFrom?: AgentChatImportedFrom; /** Factory Droid SDK session id for Droid resume across app restarts (best-effort). */ droidSdkSessionId?: string; sdkSessionId?: string; @@ -2280,6 +2295,11 @@ function codexServiceTierArgs(session: AgentChatSession): { serviceTier: CodexSe return { serviceTier }; } +function codexThreadConfigArgs(reasoningEffort: string | null | undefined): { config?: Record } { + const effort = typeof reasoningEffort === "string" ? reasoningEffort.trim() : ""; + return effort ? { config: { model_reasoning_effort: effort } } : {}; +} + function normalizeCodexServiceTier(value: unknown): string | null { if (typeof value === "string") { const normalized = value.trim().toLowerCase(); @@ -5211,6 +5231,18 @@ function normalizePersistedCompletion(value: unknown): AgentChatCompletionReport }; } +function normalizeImportedFrom(value: unknown): AgentChatImportedFrom | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const record = value as Record; + const provider = typeof record.provider === "string" ? record.provider.trim() : ""; + const sessionId = typeof record.sessionId === "string" ? record.sessionId.trim() : ""; + const importedAt = typeof record.importedAt === "number" && Number.isFinite(record.importedAt) + ? record.importedAt + : undefined; + if (!provider || !sessionId || importedAt === undefined) return undefined; + return { provider, sessionId, importedAt }; +} + function normalizeIdentityKey(value: unknown): AgentChatIdentityKey | undefined { return value === "cto" ? "cto" : undefined; } @@ -8862,6 +8894,9 @@ export function createAgentChatService(args: { ...(managed.session.codexGoal ? { codexGoal: normalizeAdeCodexGoal(managed.session.codexGoal) } : {}), ...(managed.session.codexTokenUsage ? { codexTokenUsage: managed.session.codexTokenUsage } : {}), ...(managed.session.threadId ? { threadId: managed.session.threadId } : {}), + ...(managed.session.importedFrom + ? { importedFrom: managed.session.importedFrom } + : prevPersisted?.importedFrom ? { importedFrom: prevPersisted.importedFrom } : {}), ...(managed.session.runtimeMode ? { runtimeMode: managed.session.runtimeMode } : {}), ...(managed.runtime?.kind === "droid" && managed.runtime.sdkSessionId ? { droidSdkSessionId: managed.runtime.sdkSessionId } @@ -9021,6 +9056,7 @@ export function createAgentChatService(args: { const codexGoal = normalizeCodexGoalPayload(record.codexGoal); const codexTokenUsageRecord = asRecord(record.codexTokenUsage); const codexTokenUsage = codexTokenUsageRecord ? normalizeCodexThreadTokenUsage(codexTokenUsageRecord) : null; + const importedFrom = normalizeImportedFrom(record.importedFrom); if (!laneId || !model) return null; const recentConversationEntries = Array.isArray(record.recentConversationEntries) ? record.recentConversationEntries @@ -9142,6 +9178,7 @@ export function createAgentChatService(args: { ...(completion ? { completion } : {}), ...(codexGoal ? { codexGoal } : {}), ...(codexTokenUsage ? { codexTokenUsage } : {}), + ...(importedFrom ? { importedFrom } : {}), ...(typeof record.threadId === "string" && record.threadId.trim().length ? { threadId: record.threadId.trim() } : {}), @@ -9822,6 +9859,70 @@ export function createAgentChatService(args: { commitChatEventWithCanonical(managed, normalizedEvent, options); }; + const publishChatEnvelope = (envelope: AgentChatEventEnvelope): void => { + onEvent?.(envelope); + for (const subscriber of eventSubscribers) { + try { + subscriber(envelope); + } catch (error) { + logger.warn("agent_chat.event_subscriber_failed", { + sessionId: envelope.sessionId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + }; + + const appendImportedChatEvents = ( + managed: ManagedChatSession, + envelopes: AgentChatEventEnvelope[], + ): void => { + flushBufferedReasoning(managed); + flushBufferedText(managed); + + for (const envelope of envelopes) { + const liveEvent = envelope.event; + const storedEvent = compactChatEventForStorage(liveEvent); + trackSubagentEvent(managed, liveEvent); + appendRecentConversationEntry(managed, liveEvent); + + if (storedEvent.type === "text") { + updatePreviewFromText(managed, storedEvent); + } else if (storedEvent.type === "command") { + setSessionPreview(managed, storedEvent.output); + } else if (storedEvent.type === "error") { + setSessionPreview(managed, storedEvent.message); + } else if (storedEvent.type === "completion_report") { + managed.session.completion = storedEvent.report; + if (storedEvent.report.summary.trim().length > 0) { + setSessionPreview(managed, storedEvent.report.summary); + } + } + + const timestamp = envelope.timestamp || nowIso(); + const sequence = ++managed.eventSequence; + const storedEnvelope: AgentChatEventEnvelope = { + ...envelope, + sessionId: managed.session.id, + timestamp, + event: storedEvent, + sequence, + }; + const liveEnvelope: AgentChatEventEnvelope = liveEvent === storedEvent + ? storedEnvelope + : { + ...storedEnvelope, + event: liveEvent, + }; + writeTranscript(managed, storedEnvelope); + recordChatEventInHistory(storedEnvelope); + publishChatEnvelope(liveEnvelope); + } + + managed.session.lastActivityAt = nowIso(); + managed.lastActivityTimestamp = Date.now(); + }; + const setCodexGoalAndMaybeEmitUpdate = ( managed: ManagedChatSession, runtime: CodexRuntime, @@ -11052,6 +11153,7 @@ export function createAgentChatService(args: { completion: persisted?.completion ?? null, codexGoal: persisted?.codexGoal ?? null, codexTokenUsage: persisted?.codexTokenUsage ?? null, + ...(persisted?.importedFrom ? { importedFrom: persisted.importedFrom } : {}), status: mapTerminalStatusToChatStatus(row.status), idleSinceAt: persisted?.idleSinceAt ?? null, ...(persisted?.threadId ? { threadId: persisted.threadId } : {}), @@ -18614,7 +18716,7 @@ export function createAgentChatService(args: { const startResponse = await runtime.request("thread/start", { model: managed.session.model, cwd: managed.laneWorktreePath, - effort: reasoningEffort, + ...codexThreadConfigArgs(reasoningEffort), developerInstructions: buildCodexDeveloperInstructions({ laneWorktreePath: managed.laneWorktreePath, session: managed.session, @@ -18625,7 +18727,6 @@ export function createAgentChatService(args: { ...codexPolicyArgs(codexPolicy), ...(dynamicTools.length ? { dynamicTools } : {}), experimentalRawEvents: false, - persistExtendedHistory: true }); applyCodexEffectiveThreadState(managed, startResponse, { requestedReasoningEffort: reasoningEffort, @@ -20498,6 +20599,423 @@ export function createAgentChatService(args: { }; }; + type ExternalChatImportErrorCode = + | "EXTERNAL_CHAT_SESSION_NOT_FOUND" + | "EXTERNAL_CHAT_SESSION_INVALID_ARGS" + | "EXTERNAL_CHAT_SESSION_READ_FAILED" + | "EXTERNAL_CHAT_SESSION_TRANSPLANT_UNAVAILABLE"; + + type ClaudeSessionTransplantModule = { + transplantClaudeSession: (args: { + sessionId: string; + sourceCwd: string; + targetCwd: string; + fork: boolean; + configDir?: string; + }) => Promise<{ newSessionId: string; targetPath: string }>; + }; + + const externalChatImportError = ( + code: ExternalChatImportErrorCode, + message: string, + ): Error & { code: ExternalChatImportErrorCode } => { + const error = new Error(message) as Error & { code: ExternalChatImportErrorCode }; + error.name = "ExternalChatSessionImportError"; + error.code = code; + return error; + }; + + const claudeConfigDir = (): string => + path.resolve(process.env.CLAUDE_CONFIG_DIR?.trim() || path.join(os.homedir(), ".claude")); + + const claudeProjectSlugForCwd = (cwd: string): string => + cwd.replace(/[^A-Za-z0-9]/g, "-"); + + const hasPathSeparator = (value: string): boolean => + value.includes("/") || value.includes("\\") || value.includes(path.sep); + + const readClaudeCwdFromLines = (lines: readonly string[]): string | null => { + for (const line of lines.slice(0, 50)) { + try { + const parsed = JSON.parse(line) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue; + const record = parsed as Record; + const cwd = typeof record.cwd === "string" && record.cwd.trim().length ? record.cwd.trim() : null; + if (cwd) return cwd; + } catch { + // Ignore malformed JSONL rows; the history mapper skips them too. + } + } + return null; + }; + + const findClaudeSessionTranscript = ( + sessionId: string, + sourceCwd: string | null, + ): { transcriptPath: string; sourceCwd: string | null } | null => { + const configDir = claudeConfigDir(); + const projectsDir = path.join(configDir, "projects"); + const fileName = `${sessionId}.jsonl`; + if (sourceCwd?.trim()) { + const candidate = path.join(projectsDir, claudeProjectSlugForCwd(sourceCwd.trim()), fileName); + if (fs.existsSync(candidate)) return { transcriptPath: candidate, sourceCwd: sourceCwd.trim() }; + } + try { + const entries = fs.readdirSync(projectsDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const candidate = path.join(projectsDir, entry.name, fileName); + if (fs.existsSync(candidate)) return { transcriptPath: candidate, sourceCwd: sourceCwd?.trim() || null }; + } + } catch { + return null; + } + return null; + }; + + const resolveComparableCwd = (cwd: string): string => { + const resolved = path.resolve(cwd); + try { + return fs.realpathSync.native(resolved); + } catch { + return resolved; + } + }; + + const isSameCwd = (left: string, right: string): boolean => + resolveComparableCwd(left) === resolveComparableCwd(right); + + const loadClaudeSessionTransplant = async (): Promise => { + return { transplantClaudeSession: staticTransplantClaudeSession }; + }; + + const applyImportedChatMetadata = ( + managed: ManagedChatSession, + args: AgentChatImportExternalSessionArgs, + envelopes: AgentChatEventEnvelope[], + importedAt: number, + ): void => { + managed.session.importedFrom = { + provider: args.provider, + sessionId: args.externalSessionId, + importedAt, + }; + const explicitTitle = typeof args.title === "string" ? args.title.trim() : ""; + if (!explicitTitle) { + setManagedSessionTitle(managed, deriveImportedChatTitle(envelopes, args.provider), { syncToRuntime: false }); + } + }; + + const importClaudeExternalChatSession = async ( + args: AgentChatImportExternalSessionArgs, + laneWorktreePath: string, + importedAt: number, + ): Promise => { + const externalSessionId = args.externalSessionId.trim(); + if (hasPathSeparator(externalSessionId)) { + throw externalChatImportError("EXTERNAL_CHAT_SESSION_INVALID_ARGS", "Claude session id must be a file name, not a path."); + } + const source = findClaudeSessionTranscript(externalSessionId, args.cwd); + if (!source) { + const sourceHint = args.cwd?.trim() + ? ` for cwd '${args.cwd.trim()}'` + : ""; + throw externalChatImportError( + "EXTERNAL_CHAT_SESSION_NOT_FOUND", + `External Claude session '${externalSessionId}' was not found${sourceHint} under ${path.join(claudeConfigDir(), "projects")}.`, + ); + } + + const sourceRead = readTailLines(source.transcriptPath, MAX_IMPORT_TRANSCRIPT_BYTES); + const sourceCwd = args.cwd?.trim() || readClaudeCwdFromLines(sourceRead.lines); + if (!sourceCwd) { + throw externalChatImportError( + "EXTERNAL_CHAT_SESSION_READ_FAILED", + `External Claude session '${externalSessionId}' does not include a cwd and no cwd was provided.`, + ); + } + + let targetClaudeSessionId = externalSessionId; + let targetTranscriptPath = source.transcriptPath; + let targetLines = sourceRead.lines; + let targetTranscriptTruncated = sourceRead.truncated; + if (args.fork || !isSameCwd(sourceCwd, laneWorktreePath)) { + const { transplantClaudeSession } = await loadClaudeSessionTransplant(); + const transplant = await transplantClaudeSession({ + sessionId: externalSessionId, + sourceCwd, + targetCwd: laneWorktreePath, + fork: true, + configDir: claudeConfigDir(), + }); + targetClaudeSessionId = typeof transplant.newSessionId === "string" ? transplant.newSessionId.trim() : ""; + targetTranscriptPath = typeof transplant.targetPath === "string" ? transplant.targetPath.trim() : ""; + if (!targetClaudeSessionId || !targetTranscriptPath || !fs.existsSync(targetTranscriptPath)) { + throw externalChatImportError( + "EXTERNAL_CHAT_SESSION_READ_FAILED", + `Claude transplant for session '${externalSessionId}' did not produce a readable target transcript.`, + ); + } + const targetRead = readTailLines(targetTranscriptPath, MAX_IMPORT_TRANSCRIPT_BYTES); + targetLines = targetRead.lines; + targetTranscriptTruncated = targetRead.truncated; + } + + let createdSessionId: string | null = null; + try { + const created = await createSession({ + laneId: args.laneId, + provider: "claude", + model: DEFAULT_CLAUDE_MODEL, + ...(args.title?.trim() ? { title: args.title.trim() } : {}), + }); + createdSessionId = created.id; + const managed = ensureManagedSession(created.id); + if (managed.runtime?.kind === "claude") { + resetClaudeQuerySession(managed, managed.runtime, "session_reset", { clearSdkSessionId: true }); + managed.runtime.sdkSessionId = targetClaudeSessionId; + managed.runtime.forkFromSdkSessionId = null; + managed.runtimeInvalidated = false; + } + managed.claudeBackgroundResumeSessionId = targetClaudeSessionId; + mirrorClaudeSessionPointer(managed, targetClaudeSessionId, { + ...(args.title?.trim() ? { title: args.title.trim() } : {}), + }); + const repair = repairClaudeResumeTranscript(targetClaudeSessionId, managed.laneWorktreePath); + if (repair.repaired) { + logger.warn("agent_chat.external_import_claude_thinking_transcript_repaired", { + sessionId: managed.session.id, + sdkSessionId: targetClaudeSessionId, + responsesRekeyed: repair.responsesRekeyed, + reusedMessageIds: repair.reusedMessageIds, + }); + } + const events = claudeJsonlToChatEvents(targetLines, { + sessionId: managed.session.id, + provider: "claude", + externalSessionId, + importedAt, + laneId: managed.session.laneId, + transcriptBytesTruncated: targetTranscriptTruncated, + transcriptByteLimit: MAX_IMPORT_TRANSCRIPT_BYTES, + }); + applyImportedChatMetadata(managed, args, events, importedAt); + persistChatState(managed); + appendImportedChatEvents(managed, events); + persistChatState(managed); + return { chatSessionId: managed.session.id }; + } catch (error) { + if (createdSessionId) { + await deleteSession({ sessionId: createdSessionId }).catch((cleanupError) => { + logger.warn("agent_chat.external_import_cleanup_failed", { + sessionId: createdSessionId, + error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError), + }); + }); + } + throw error; + } + }; + + const extractCodexThreadTurns = ( + response: unknown, + threadId: string, + ): { turns: unknown[]; responseThreadId: string; foundThread: boolean } => { + const record = response && typeof response === "object" && !Array.isArray(response) + ? response as Record + : {}; + const thread = record.thread && typeof record.thread === "object" && !Array.isArray(record.thread) + ? record.thread as Record + : null; + const topLevelTurns = Array.isArray(record.turns) ? record.turns : null; + const responseThreadId = typeof thread?.id === "string" && thread.id.trim().length + ? thread.id.trim() + : threadId; + const turns = Array.isArray(thread?.turns) + ? thread.turns + : topLevelTurns + ? topLevelTurns + : []; + return { turns, responseThreadId, foundThread: Boolean(thread) || topLevelTurns !== null }; + }; + + const importCodexExternalChatSession = async ( + args: AgentChatImportExternalSessionArgs, + importedAt: number, + ): Promise => { + const externalThreadId = args.externalSessionId.trim(); + let createdSessionId: string | null = null; + let forkedProviderThreadId: string | null = null; + let forkedProviderRuntime: CodexRuntime | null = null; + const archiveForkedProviderThread = async (managed: ManagedChatSession): Promise => { + if (!forkedProviderThreadId) return; + let staleRuntimeError: unknown = null; + if (forkedProviderRuntime) { + try { + await forkedProviderRuntime.request("thread/archive", { + threadId: forkedProviderThreadId, + }, { timeoutMs: CODEX_ARCHIVE_REQUEST_TIMEOUT_MS }); + return; + } catch (error) { + staleRuntimeError = error; + } + } + + let freshRuntime: CodexRuntime | null = null; + try { + if (forkedProviderRuntime && managed.runtime === forkedProviderRuntime) { + teardownRuntime(managed, "ended_session"); + } + freshRuntime = await startCodexRuntime(managed); + managed.runtime = freshRuntime; + managed.runtimeInvalidated = false; + await freshRuntime.request("thread/archive", { + threadId: forkedProviderThreadId, + }, { timeoutMs: CODEX_ARCHIVE_REQUEST_TIMEOUT_MS }); + } catch (retryError) { + logger.warn("agent_chat.external_import_codex_fork_cleanup_leaked", { + provider: "codex", + forkedThreadId: forkedProviderThreadId, + sourceId: externalThreadId, + initialError: staleRuntimeError instanceof Error ? staleRuntimeError.message : staleRuntimeError == null ? null : String(staleRuntimeError), + error: retryError instanceof Error ? retryError.message : String(retryError), + }); + } finally { + if (freshRuntime && managed.runtime === freshRuntime) { + teardownRuntime(managed, "ended_session"); + } + } + }; + try { + const created = await createSession({ + laneId: args.laneId, + provider: "codex", + model: DEFAULT_CODEX_MODEL, + ...(args.title?.trim() ? { title: args.title.trim() } : {}), + }); + createdSessionId = created.id; + const managed = ensureManagedSession(created.id); + const runtime = await ensureCodexSessionRuntime(managed); + forkedProviderRuntime = runtime; + let targetThreadId = externalThreadId; + if (args.fork) { + const sourceReadResponse = await runtime.request("thread/read", { + threadId: externalThreadId, + includeTurns: true, + }); + const sourceThread = extractCodexThreadTurns(sourceReadResponse, externalThreadId); + if (!sourceThread.foundThread) { + throw externalChatImportError( + "EXTERNAL_CHAT_SESSION_NOT_FOUND", + `External Codex thread '${externalThreadId}' was not found by thread/read.`, + ); + } + const forkResponse = await runtime.request("thread/fork", { + threadId: sourceThread.responseThreadId, + excludeTurns: true, + }); + const forkedThreadId = typeof forkResponse.thread?.id === "string" ? forkResponse.thread.id.trim() : ""; + if (!forkedThreadId) { + throw externalChatImportError( + "EXTERNAL_CHAT_SESSION_READ_FAILED", + `Codex thread/fork did not return a new thread id for '${sourceThread.responseThreadId}'.`, + ); + } + forkedProviderThreadId = forkedThreadId; + targetThreadId = forkedThreadId; + } + + const readResponse = await runtime.request("thread/read", { + threadId: targetThreadId, + includeTurns: true, + }); + const { turns, responseThreadId, foundThread } = extractCodexThreadTurns(readResponse, targetThreadId); + if (!foundThread) { + throw externalChatImportError( + "EXTERNAL_CHAT_SESSION_NOT_FOUND", + `External Codex thread '${targetThreadId}' was not found by thread/read.`, + ); + } + targetThreadId = responseThreadId; + managed.session.threadId = targetThreadId; + runtime.threadResumed = false; + runtime.canAttachResumedTurnStart = false; + sessionService.setResumeCommand(managed.session.id, `chat:codex:${targetThreadId}`); + const events = codexTurnsToChatEvents(turns, { + sessionId: managed.session.id, + provider: "codex", + externalSessionId: externalThreadId, + importedAt, + laneId: managed.session.laneId, + }); + applyImportedChatMetadata(managed, args, events, importedAt); + persistChatState(managed); + appendImportedChatEvents(managed, events); + persistChatState(managed); + return { chatSessionId: managed.session.id }; + } catch (error) { + if (createdSessionId && forkedProviderThreadId) { + const managed = managedSessions.get(createdSessionId); + if (managed) { + await archiveForkedProviderThread(managed); + } else { + logger.warn("agent_chat.external_import_codex_fork_cleanup_leaked", { + provider: "codex", + forkedThreadId: forkedProviderThreadId, + sourceId: externalThreadId, + error: "Managed chat session was missing during cleanup.", + }); + } + } + if (createdSessionId) { + await deleteSession({ sessionId: createdSessionId }).catch((cleanupError) => { + logger.warn("agent_chat.external_import_cleanup_failed", { + sessionId: createdSessionId, + error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError), + }); + }); + } + throw error; + } + }; + + const importExternalChatSession = async ( + args: AgentChatImportExternalSessionArgs, + ): Promise => { + const provider = args.provider; + const externalSessionId = typeof args.externalSessionId === "string" ? args.externalSessionId.trim() : ""; + const laneId = typeof args.laneId === "string" ? args.laneId.trim() : ""; + if (provider !== "claude" && provider !== "codex") { + throw externalChatImportError("EXTERNAL_CHAT_SESSION_INVALID_ARGS", `Unsupported chat import provider '${String(provider)}'.`); + } + if (!externalSessionId) { + throw externalChatImportError("EXTERNAL_CHAT_SESSION_INVALID_ARGS", "externalSessionId is required."); + } + if (!laneId) { + throw externalChatImportError("EXTERNAL_CHAT_SESSION_INVALID_ARGS", "laneId is required."); + } + const launchContext = resolveLaneLaunchContext({ + laneService, + projectRoot, + laneId, + purpose: "import this chat", + }); + const normalizedArgs: AgentChatImportExternalSessionArgs = { + ...args, + provider, + externalSessionId, + laneId, + cwd: typeof args.cwd === "string" && args.cwd.trim().length ? args.cwd.trim() : null, + fork: args.fork === true, + ...(args.title?.trim() ? { title: args.title.trim() } : {}), + }; + const importedAt = Date.now(); + if (provider === "claude") { + return importClaudeExternalChatSession(normalizedArgs, launchContext.laneWorktreePath, importedAt); + } + return importCodexExternalChatSession(normalizedArgs, importedAt); + }; + const prepareSendMessage = ({ sessionId, text, @@ -23554,7 +24072,9 @@ export function createAgentChatService(args: { if (threadIdToResume) { try { - const dynamicTools = refreshCodexDynamicTools(managed, runtime); + // `thread/resume` has no dynamicTools field; refresh ADE's local + // handler map while leaving tool registration to the original thread. + refreshCodexDynamicTools(managed, runtime); const resumeReasoningEffort = resolveCodexReasoningEffortForRuntime( managed.session.reasoningEffort, readPersistedState(sessionId)?.reasoningEffort, @@ -23565,7 +24085,7 @@ export function createAgentChatService(args: { threadId: threadIdToResume, model: managed.session.model, cwd: managed.laneWorktreePath, - effort: resumeReasoningEffort, + ...codexThreadConfigArgs(resumeReasoningEffort), developerInstructions: buildCodexDeveloperInstructions({ laneWorktreePath: managed.laneWorktreePath, session: managed.session, @@ -23574,9 +24094,7 @@ export function createAgentChatService(args: { }), ...codexServiceTierArgs(managed.session), ...codexPolicyArgs(codexPolicy), - ...(dynamicTools.length ? { dynamicTools } : {}), excludeTurns: true, - persistExtendedHistory: true }); applyCodexEffectiveThreadState(managed, resumeResponse, { requestedCodexPolicy: codexPolicy, @@ -24720,17 +25238,17 @@ export function createAgentChatService(args: { if (threadId) { const { codexPolicy } = resolveCodexThreadParams(managed); try { - const dynamicTools = refreshCodexDynamicTools(managed, runtime); + // `thread/resume` has no dynamicTools field; refresh ADE's local + // handler map while leaving tool registration to the original thread. + refreshCodexDynamicTools(managed, runtime); const resumeResponse = await runtime.request("thread/resume", { threadId, model: managed.session.model, cwd: managed.laneWorktreePath, - effort: managed.session.reasoningEffort, + ...codexThreadConfigArgs(managed.session.reasoningEffort), ...codexServiceTierArgs(managed.session), ...codexPolicyArgs(codexPolicy), - ...(dynamicTools.length ? { dynamicTools } : {}), excludeTurns: true, - persistExtendedHistory: true }); applyCodexEffectiveThreadState(managed, resumeResponse, { requestedCodexPolicy: codexPolicy, @@ -25059,6 +25577,9 @@ export function createAgentChatService(args: { completion: liveSession?.completion ?? persisted?.completion ?? null, codexGoal: normalizeAdeCodexGoal(liveSession?.codexGoal ?? persisted?.codexGoal ?? null), codexTokenUsage: liveSession?.codexTokenUsage ?? persisted?.codexTokenUsage ?? null, + ...(liveSession?.importedFrom || persisted?.importedFrom + ? { importedFrom: liveSession?.importedFrom ?? persisted?.importedFrom } + : {}), status: liveSession?.status ?? (row.status === "running" ? "idle" : "ended"), idleSinceAt: (liveSession?.status ?? (row.status === "running" ? "idle" : "ended")) === "idle" ? liveSession?.idleSinceAt ?? persisted?.idleSinceAt ?? null @@ -26726,6 +27247,10 @@ export function createAgentChatService(args: { if (managed.session.identityKey === "cto" && (modelChanged || reasoningEffort !== undefined)) { persistCtoModelPreference(managed, descriptor.id); } + if (reasoningEffort !== undefined && managed.runtime?.kind === "codex") { + managed.runtime.threadResumed = false; + managed.runtime.canAttachResumedTurnStart = false; + } } else if (reasoningEffort !== undefined) { const prev = managed.session.reasoningEffort ?? null; const requested = normalizeReasoningEffort(reasoningEffort); @@ -26749,6 +27274,10 @@ export function createAgentChatService(args: { resetClaudeQuerySession(managed, managed.runtime, "session_reset"); } } + if (prev !== next && managed.runtime?.kind === "codex") { + managed.runtime.threadResumed = false; + managed.runtime.canAttachResumedTurnStart = false; + } // A reasoning-only change on the CTO thread must also land in identity // modelPreferences, or the next ensured session resurrects the old tier. if (managed.session.identityKey === "cto" && prev !== next) { @@ -28691,7 +29220,9 @@ export function createAgentChatService(args: { const { codexPolicy } = resolveCodexThreadParams(managed); if (threadId) { try { - const dynamicTools = refreshCodexDynamicTools(managed, runtime); + // `thread/resume` has no dynamicTools field; refresh ADE's local + // handler map while leaving tool registration to the original thread. + refreshCodexDynamicTools(managed, runtime); const resumeReasoningEffort = resolveCodexReasoningEffortForRuntime( managed.session.reasoningEffort, readPersistedState(managed.session.id)?.reasoningEffort, @@ -28702,7 +29233,7 @@ export function createAgentChatService(args: { threadId, model: managed.session.model, cwd: managed.laneWorktreePath, - effort: resumeReasoningEffort, + ...codexThreadConfigArgs(resumeReasoningEffort), developerInstructions: buildCodexDeveloperInstructions({ laneWorktreePath: managed.laneWorktreePath, session: managed.session, @@ -28710,9 +29241,7 @@ export function createAgentChatService(args: { }), ...codexServiceTierArgs(managed.session), ...codexPolicyArgs(codexPolicy), - ...(dynamicTools.length ? { dynamicTools } : {}), excludeTurns: true, - persistExtendedHistory: true, }, { timeoutMs: CODEX_INLINE_COMMAND_TIMEOUT_MS }); applyCodexEffectiveThreadState(managed, resumeResponse, { requestedCodexPolicy: codexPolicy, @@ -28845,6 +29374,7 @@ export function createAgentChatService(args: { return { createSession, + importExternalChatSession, launchHeadless, suggestLaneNameFromPrompt, handoffSession, @@ -28941,3 +29471,5 @@ export function createAgentChatService(args: { }, }; } + +export type AgentChatService = ReturnType; diff --git a/apps/desktop/src/main/services/chat/externalChatHistoryImport.test.ts b/apps/desktop/src/main/services/chat/externalChatHistoryImport.test.ts new file mode 100644 index 000000000..9608067fe --- /dev/null +++ b/apps/desktop/src/main/services/chat/externalChatHistoryImport.test.ts @@ -0,0 +1,306 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + claudeJsonlToChatEvents, + codexTurnsToChatEvents, + deriveImportedChatTitle, + readTailLines, +} from "./externalChatHistoryImport"; + +const baseOptions = { + sessionId: "chat-1", + provider: "claude" as const, + externalSessionId: "11111111-2222-3333-4444-555555555555", + importedAt: Date.parse("2026-07-06T12:00:00.000Z"), + laneId: "lane-1", +}; + +describe("claudeJsonlToChatEvents", () => { + it("tail-reads oversized JSONL and drops the first partial line", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-import-tail-")); + try { + const filePath = path.join(root, "session.jsonl"); + const first = JSON.stringify({ message: { role: "user", content: "first" } }); + const second = JSON.stringify({ message: { role: "user", content: "second" } }); + const third = JSON.stringify({ message: { role: "user", content: "third" } }); + fs.writeFileSync(filePath, `${first}\n${second}\n${third}\n`, "utf8"); + + const maxBytes = Buffer.byteLength(`${second.slice(4)}\n${third}\n`, "utf8"); + const result = readTailLines(filePath, maxBytes); + + expect(result.truncated).toBe(true); + expect(result.lines).toEqual([third]); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("maps Claude JSONL user, assistant, and tool blocks into chat events", () => { + const lines = [ + JSON.stringify({ + type: "user", + uuid: "user-1", + timestamp: "2026-07-06T10:00:00.000Z", + cwd: "/Users/admin/Projects/ADE", + sessionId: "11111111-2222-3333-4444-555555555555", + message: { + role: "user", + content: [{ type: "text", text: "Please inspect the failing test." }], + }, + }), + JSON.stringify({ + type: "assistant", + uuid: "assistant-1", + timestamp: "2026-07-06T10:00:02.000Z", + cwd: "/Users/admin/Projects/ADE", + sessionId: "11111111-2222-3333-4444-555555555555", + message: { + role: "assistant", + content: [ + { type: "thinking", thinking: "private reasoning omitted" }, + { type: "text", text: "I will check the test output." }, + { type: "tool_use", id: "toolu_01", name: "Bash", input: { command: "npm test" } }, + ], + }, + }), + JSON.stringify({ + type: "user", + uuid: "user-2", + timestamp: "2026-07-06T10:00:04.000Z", + cwd: "/Users/admin/Projects/ADE", + sessionId: "11111111-2222-3333-4444-555555555555", + message: { + role: "user", + content: [{ type: "tool_result", tool_use_id: "toolu_01", content: "1 failed test" }], + }, + }), + ]; + + const events = claudeJsonlToChatEvents(lines, baseOptions); + + expect(events.map((envelope) => envelope.event.type)).toEqual([ + "system_notice", + "user_message", + "text", + "tool_call", + "tool_result", + ]); + expect(events[0]!.event).toMatchObject({ + type: "system_notice", + message: "Session imported from claude CLI (11111111)", + }); + expect(events[1]!.event).toMatchObject({ type: "user_message", text: "Please inspect the failing test." }); + expect(events[2]!.event).toMatchObject({ type: "text", text: "I will check the test output." }); + expect(events[3]!.event).toMatchObject({ type: "tool_call", tool: "Bash", itemId: "toolu_01" }); + expect(events[4]!.event).toMatchObject({ type: "tool_result", itemId: "toolu_01", result: "1 failed test" }); + }); + + it("caps pathological Claude imports and keeps the newest content messages plus notices", () => { + const lines = Array.from({ length: 5 }, (_, index) => JSON.stringify({ + type: "user", + uuid: `user-${index}`, + timestamp: `2026-07-06T10:00:0${index}.000Z`, + message: { role: "user", content: [{ type: "text", text: `message ${index}` }] }, + })); + + const events = claudeJsonlToChatEvents(lines, { ...baseOptions, maxEvents: 3 }); + + expect(events).toHaveLength(5); + expect(events[0]!.event).toMatchObject({ + type: "system_notice", + message: "Session imported from claude CLI (11111111)", + }); + expect(events[1]!.event).toMatchObject({ + type: "system_notice", + message: "Imported: 2 earlier messages truncated", + }); + expect(events.slice(2).map((envelope) => envelope.event)).toEqual([ + expect.objectContaining({ type: "user_message", text: "message 2" }), + expect.objectContaining({ type: "user_message", text: "message 3" }), + expect.objectContaining({ type: "user_message", text: "message 4" }), + ]); + }); + + it("adds a byte-level truncation notice when a tail-read was required", () => { + const events = claudeJsonlToChatEvents([ + JSON.stringify({ + type: "user", + uuid: "user-tail", + timestamp: "2026-07-06T10:00:00.000Z", + message: { role: "user", content: [{ type: "text", text: "tail message" }] }, + }), + ], { ...baseOptions, transcriptBytesTruncated: true, transcriptByteLimit: 16 }); + + expect(events[0]!.event).toMatchObject({ + type: "system_notice", + message: "Session imported from claude CLI (11111111)", + }); + expect(events[1]!.event).toMatchObject({ + type: "system_notice", + message: "Imported: earlier transcript bytes truncated to the last 16 bytes", + }); + expect(events.map((envelope) => envelope.event.type)).toEqual([ + "system_notice", + "system_notice", + "user_message", + ]); + }); + + it("keeps both truncation notices when byte and event caps both apply", () => { + const lines = Array.from({ length: 6 }, (_, index) => JSON.stringify({ + type: "user", + uuid: `user-${index}`, + timestamp: `2026-07-06T10:00:0${index}.000Z`, + message: { role: "user", content: [{ type: "text", text: `message ${index}` }] }, + })); + + const events = claudeJsonlToChatEvents(lines, { + ...baseOptions, + maxEvents: 3, + transcriptBytesTruncated: true, + transcriptByteLimit: 16, + }); + + expect(events.slice(0, 3).map((envelope) => envelope.event)).toEqual([ + expect.objectContaining({ + type: "system_notice", + message: "Session imported from claude CLI (11111111)", + }), + expect.objectContaining({ + type: "system_notice", + message: "Imported: earlier transcript bytes truncated to the last 16 bytes", + }), + expect.objectContaining({ + type: "system_notice", + message: "Imported: 3 earlier messages truncated", + }), + ]); + const contentEvents = events.filter((envelope) => envelope.event.type !== "system_notice"); + expect(contentEvents).toHaveLength(3); + expect(contentEvents.map((envelope) => envelope.event)).toEqual([ + expect.objectContaining({ type: "user_message", text: "message 3" }), + expect.objectContaining({ type: "user_message", text: "message 4" }), + expect.objectContaining({ type: "user_message", text: "message 5" }), + ]); + }); +}); + +describe("codexTurnsToChatEvents", () => { + it("maps Codex app-server ThreadItems into chat events", () => { + const turns = [{ + id: "turn-1", + startedAt: "2026-07-06T11:00:00.000Z", + items: [ + { type: "userMessage", id: "item-user", content: [{ type: "input_text", text: "Run the focused test." }] }, + { type: "agentMessage", id: "item-agent", text: "I will run it now." }, + { + type: "commandExecution", + id: "item-cmd", + command: "npm test -- externalChatHistoryImport.test.ts", + cwd: "/repo/apps/desktop", + aggregatedOutput: "PASS externalChatHistoryImport.test.ts", + exitCode: 0, + status: "completed", + }, + ], + }]; + + const events = codexTurnsToChatEvents(turns, { + ...baseOptions, + provider: "codex", + externalSessionId: "thread_abc123456789", + }); + + expect(events.map((envelope) => envelope.event.type)).toEqual([ + "system_notice", + "user_message", + "text", + "command", + ]); + expect(events[0]!.event).toMatchObject({ + type: "system_notice", + message: "Session imported from codex CLI (thread_a)", + }); + expect(events[3]!.event).toMatchObject({ + type: "command", + command: "npm test -- externalChatHistoryImport.test.ts", + status: "completed", + output: "PASS externalChatHistoryImport.test.ts", + }); + }); + + it("maps rollout-style message and function call items", () => { + const turns = [{ + id: "turn-2", + items: [ + { type: "message", role: "user", content: [{ type: "input_text", text: "List files." }] }, + { type: "message", role: "assistant", content: [{ type: "output_text", text: "Sure." }] }, + { type: "function_call", call_id: "call-1", name: "shell", arguments: "{\"cmd\":\"ls\"}" }, + { type: "function_call_output", call_id: "call-1", output: "README.md" }, + ], + }]; + + const events = codexTurnsToChatEvents(turns, { + ...baseOptions, + provider: "codex", + externalSessionId: "thread_def456789", + }); + + expect(events.map((envelope) => envelope.event.type)).toEqual([ + "system_notice", + "user_message", + "text", + "tool_call", + "tool_result", + ]); + expect(events[3]!.event).toMatchObject({ type: "tool_call", tool: "shell", args: { cmd: "ls" } }); + expect(events[4]!.event).toMatchObject({ type: "tool_result", result: "README.md" }); + }); + + it("maps Codex tagged file-change kinds from app-server history", () => { + const events = codexTurnsToChatEvents([ + { + id: "turn-file-change", + items: [ + { + type: "fileChange", + id: "item-file", + status: "completed", + changes: [ + { path: "src/new.ts", kind: { type: "add" }, diff: "new file diff" }, + { path: "src/old.ts", kind: { type: "delete" }, diff: "delete diff" }, + { path: "src/edit.ts", kind: { type: "update", movePath: null }, diff: "edit diff" }, + ], + }, + ], + }, + ], { + ...baseOptions, + provider: "codex", + externalSessionId: "thread_file_change", + }); + + const fileChanges = events + .map((event) => event.event) + .filter((event) => event.type === "file_change"); + expect(fileChanges).toEqual([ + expect.objectContaining({ path: "src/new.ts", kind: "create" }), + expect.objectContaining({ path: "src/old.ts", kind: "delete" }), + expect.objectContaining({ path: "src/edit.ts", kind: "modify" }), + ]); + }); + + it("derives an imported chat title from the first user message", () => { + const events = codexTurnsToChatEvents([ + { id: "turn-3", items: [{ type: "userMessage", id: "item-user", content: [{ type: "input_text", text: "Explain the lane status" }] }] }, + ], { + ...baseOptions, + provider: "codex", + externalSessionId: "thread_title", + }); + + expect(deriveImportedChatTitle(events, "codex")).toBe("Explain the lane status"); + }); +}); diff --git a/apps/desktop/src/main/services/chat/externalChatHistoryImport.ts b/apps/desktop/src/main/services/chat/externalChatHistoryImport.ts new file mode 100644 index 000000000..a6b738fcf --- /dev/null +++ b/apps/desktop/src/main/services/chat/externalChatHistoryImport.ts @@ -0,0 +1,585 @@ +import fs from "node:fs"; +import type { + AgentChatEvent, + AgentChatEventEnvelope, + AgentChatImportProvider, +} from "../../../shared/types"; + +const DEFAULT_MAX_IMPORTED_EVENTS = 2000; +export const MAX_IMPORT_TRANSCRIPT_BYTES = 32 * 1024 * 1024; + +export type ExternalChatHistoryImportOptions = { + sessionId: string; + provider: AgentChatImportProvider; + externalSessionId: string; + importedAt?: number; + maxEvents?: number; + laneId?: string | null; + transcriptBytesTruncated?: boolean; + transcriptByteLimit?: number; +}; + +export type TailLinesReadResult = { + lines: string[]; + truncated: boolean; +}; + +type JsonRecord = Record; + +function isRecord(value: unknown): value is JsonRecord { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function stringOrNull(value: unknown): string | null { + return typeof value === "string" && value.trim().length ? value : null; +} + +function timestampFrom(value: unknown, fallbackMs: number): string { + if (typeof value === "string" && value.trim().length) { + const parsed = Date.parse(value); + if (Number.isFinite(parsed)) return new Date(parsed).toISOString(); + } + if (typeof value === "number" && Number.isFinite(value)) { + const ms = value > 10_000_000_000 ? value : value * 1000; + return new Date(ms).toISOString(); + } + return new Date(fallbackMs).toISOString(); +} + +function normalizeWhitespace(value: string): string { + return value.replace(/\s+/g, " ").trim(); +} + +function nonEmptyLines(text: string): string[] { + return text.split(/\r?\n/u).filter((line) => line.trim().length > 0); +} + +export function readTailLines( + filePath: string, + maxBytes = MAX_IMPORT_TRANSCRIPT_BYTES, +): TailLinesReadResult { + const limit = Math.max(1, Math.floor(maxBytes)); + const stat = fs.statSync(filePath); + if (stat.size <= limit) { + return { lines: nonEmptyLines(fs.readFileSync(filePath, "utf8")), truncated: false }; + } + + const offset = stat.size - limit; + const buffer = Buffer.allocUnsafe(limit); + const fd = fs.openSync(filePath, "r"); + try { + const bytesRead = fs.readSync(fd, buffer, 0, limit, offset); + const lines = buffer.subarray(0, bytesRead).toString("utf8").split(/\r?\n/u); + lines.shift(); + return { lines: lines.filter((line) => line.trim().length > 0), truncated: true }; + } finally { + fs.closeSync(fd); + } +} + +export function shortExternalSessionId(sessionId: string): string { + const trimmed = sessionId.trim(); + if (trimmed.length <= 12) return trimmed; + return trimmed.slice(0, 8); +} + +function parseJsonObject(line: string): JsonRecord | null { + const trimmed = line.trim(); + if (!trimmed) return null; + try { + const parsed = JSON.parse(trimmed); + return isRecord(parsed) ? parsed : null; + } catch { + return null; + } +} + +function maybeParseJson(value: string): unknown { + const trimmed = value.trim(); + if (!trimmed) return value; + if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return value; + try { + return JSON.parse(trimmed); + } catch { + return value; + } +} + +function contentBlockText(block: unknown): string { + if (typeof block === "string") return block; + if (!isRecord(block)) return ""; + const type = stringOrNull(block.type)?.toLowerCase() ?? ""; + if (type === "text" || type === "output_text" || type === "input_text") { + return stringOrNull(block.text) ?? ""; + } + if (typeof block.text === "string") return block.text; + if (typeof block.content === "string") return block.content; + if (Array.isArray(block.content)) return contentToText(block.content); + if (type === "image" || type === "input_image") return "[Image attachment]"; + if (type === "document" || type === "input_file") return "[File attachment]"; + return ""; +} + +function contentToText(content: unknown): string { + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .map(contentBlockText) + .filter((part) => part.trim().length > 0) + .join("\n") + .trim(); + } + if (isRecord(content)) { + return contentBlockText(content); + } + return ""; +} + +function stringifyResult(value: unknown): unknown { + if (typeof value === "string") return value; + if (Array.isArray(value)) { + const text = contentToText(value); + return text || value; + } + return value ?? ""; +} + +function importedRoleForEvent(event: AgentChatEvent): NonNullable["role"] { + if (event.type === "user_message") return "user"; + if (event.type === "text") return "agent"; + return null; +} + +function makeEnvelope( + event: AgentChatEvent, + options: ExternalChatHistoryImportOptions, + timestamp: string, + messageId?: string | null, +): AgentChatEventEnvelope { + return { + sessionId: options.sessionId, + timestamp, + event, + provenance: { + ...(messageId ? { messageId } : {}), + role: importedRoleForEvent(event), + targetKind: "external_import", + sourceSessionId: options.externalSessionId, + laneId: options.laneId ?? null, + }, + }; +} + +function systemNoticeEnvelope( + message: string, + options: ExternalChatHistoryImportOptions, + timestamp: string, +): AgentChatEventEnvelope { + return makeEnvelope({ + type: "system_notice", + noticeKind: "info", + severity: "info", + message, + }, options, timestamp); +} + +function byteLimitLabel(bytes: number): string { + if (bytes % (1024 * 1024) === 0) return `${bytes / (1024 * 1024)} MB`; + return `${bytes} bytes`; +} + +function finalizeImportEvents( + envelopes: AgentChatEventEnvelope[], + options: ExternalChatHistoryImportOptions, +): AgentChatEventEnvelope[] { + const importedAt = options.importedAt ?? Date.now(); + let noticeOffset = 0; + const noticeTimestamp = () => new Date(importedAt + noticeOffset++).toISOString(); + const maxContentEvents = Math.max(1, Math.floor(options.maxEvents ?? DEFAULT_MAX_IMPORTED_EVENTS)); + const omitted = Math.max(0, envelopes.length - maxContentEvents); + const contentEvents = omitted > 0 ? envelopes.slice(-maxContentEvents) : envelopes; + const notices = [ + systemNoticeEnvelope( + `Session imported from ${options.provider} CLI (${shortExternalSessionId(options.externalSessionId)})`, + options, + noticeTimestamp(), + ), + ]; + if (options.transcriptBytesTruncated) { + notices.push(systemNoticeEnvelope( + `Imported: earlier transcript bytes truncated to the last ${byteLimitLabel(options.transcriptByteLimit ?? MAX_IMPORT_TRANSCRIPT_BYTES)}`, + options, + noticeTimestamp(), + )); + } + if (omitted > 0) { + notices.push(systemNoticeEnvelope( + `Imported: ${omitted} earlier messages truncated`, + options, + noticeTimestamp(), + )); + } + return [ + ...notices, + ...contentEvents, + ]; +} + +function sourceRecordTimestamp(record: JsonRecord, fallbackMs: number): string { + const message = isRecord(record.message) ? record.message : null; + return timestampFrom( + record.timestamp ?? record.createdAt ?? record.created_at ?? message?.timestamp, + fallbackMs, + ); +} + +function sourceRecordId(record: JsonRecord, fallback: string): string { + return stringOrNull(record.uuid) + ?? stringOrNull(record.id) + ?? stringOrNull(record.messageId) + ?? stringOrNull(record.message_id) + ?? fallback; +} + +function claudeToolResultEvent(block: JsonRecord, fallbackItemId: string): Extract { + const itemId = stringOrNull(block.tool_use_id) + ?? stringOrNull(block.id) + ?? fallbackItemId; + return { + type: "tool_result", + tool: stringOrNull(block.name) ?? "tool", + result: stringifyResult(block.content ?? block.result ?? block.output ?? ""), + itemId, + status: "completed", + }; +} + +function claudeToolCallEvent(block: JsonRecord, fallbackItemId: string): Extract { + const itemId = stringOrNull(block.id) ?? fallbackItemId; + return { + type: "tool_call", + tool: stringOrNull(block.name) ?? stringOrNull(block.tool) ?? "tool", + args: block.input ?? block.arguments ?? {}, + itemId, + }; +} + +function claudeRecordToEvents( + record: JsonRecord, + options: ExternalChatHistoryImportOptions, + index: number, +): AgentChatEventEnvelope[] { + const message = isRecord(record.message) ? record.message : record; + const role = stringOrNull(message.role) ?? stringOrNull(record.type) ?? ""; + const sourceId = sourceRecordId(record, `claude-import:${index}`); + const timestamp = sourceRecordTimestamp(record, (options.importedAt ?? Date.now()) + index); + const content = message.content ?? record.content; + const blocks = Array.isArray(content) ? content : [content]; + const out: AgentChatEventEnvelope[] = []; + + if (role === "user") { + const textParts: string[] = []; + blocks.forEach((block, blockIndex) => { + if (isRecord(block) && stringOrNull(block.type) === "tool_result") { + const itemId = `${sourceId}:tool-result:${blockIndex}`; + out.push(makeEnvelope(claudeToolResultEvent(block, itemId), options, timestamp, itemId)); + return; + } + const text = contentBlockText(block); + if (text.trim().length) textParts.push(text); + }); + const text = textParts.join("\n").trim(); + if (text.length) { + out.unshift(makeEnvelope({ type: "user_message", text, messageId: sourceId }, options, timestamp, sourceId)); + } + return out; + } + + if (role === "assistant") { + blocks.forEach((block, blockIndex) => { + if (isRecord(block)) { + const type = stringOrNull(block.type); + if (type === "thinking" || type === "redacted_thinking") return; + const itemId = stringOrNull(block.id) ?? `${sourceId}:block:${blockIndex}`; + if (type === "tool_use" || type === "server_tool_use") { + out.push(makeEnvelope(claudeToolCallEvent(block, itemId), options, timestamp, itemId)); + return; + } + if (type === "tool_result") { + out.push(makeEnvelope(claudeToolResultEvent(block, itemId), options, timestamp, itemId)); + return; + } + } + const text = contentBlockText(block); + if (text.trim().length) { + out.push(makeEnvelope({ type: "text", text, itemId: `${sourceId}:text:${blockIndex}` }, options, timestamp, sourceId)); + } + }); + } + + return out; +} + +export function claudeJsonlToChatEvents( + lines: readonly string[], + options: ExternalChatHistoryImportOptions, +): AgentChatEventEnvelope[] { + const envelopes: AgentChatEventEnvelope[] = []; + lines.forEach((line, index) => { + const record = parseJsonObject(line); + if (!record) return; + envelopes.push(...claudeRecordToEvents(record, options, index)); + }); + return finalizeImportEvents(envelopes, options); +} + +function mapStatus(value: unknown): "running" | "completed" | "failed" { + const status = typeof value === "string" ? value.toLowerCase() : ""; + if (status === "running" || status === "in_progress" || status === "inprogress") return "running"; + if (status === "failed" || status === "error") return "failed"; + return "completed"; +} + +function mapFileChangeKind(value: unknown): "create" | "modify" | "delete" { + const raw = isRecord(value) ? value.type : value; + const kind = typeof raw === "string" ? raw.toLowerCase() : ""; + if (kind === "create" || kind === "add" || kind === "added") return "create"; + if (kind === "delete" || kind === "remove" || kind === "removed") return "delete"; + return "modify"; +} + +function codexItemId(item: JsonRecord, fallback: string): string { + return stringOrNull(item.id) + ?? stringOrNull(item.itemId) + ?? stringOrNull(item.item_id) + ?? stringOrNull(item.call_id) + ?? fallback; +} + +function codexTurnId(turn: JsonRecord, fallback: string): string { + return stringOrNull(turn.id) + ?? stringOrNull(turn.turnId) + ?? stringOrNull(turn.turn_id) + ?? fallback; +} + +function codexMessageRole(item: JsonRecord): string | null { + return stringOrNull(item.role) + ?? (stringOrNull(item.type) === "user_message" ? "user" : null) + ?? (stringOrNull(item.type) === "agent_message" ? "assistant" : null); +} + +function codexMessageText(item: JsonRecord): string { + return contentToText(item.text ?? item.content ?? item.message ?? item.output ?? item.delta); +} + +function codexThreadItemToEvents( + item: JsonRecord, + turn: JsonRecord, + options: ExternalChatHistoryImportOptions, + turnIndex: number, + itemIndex: number, +): AgentChatEventEnvelope[] { + const turnId = codexTurnId(turn, `codex-turn:${turnIndex}`); + const itemId = codexItemId(item, `codex-item:${turnIndex}:${itemIndex}`); + const itemType = stringOrNull(item.type) ?? ""; + const timestamp = timestampFrom( + item.timestamp ?? item.createdAt ?? item.created_at ?? turn.startedAt ?? turn.createdAt ?? turn.created_at, + (options.importedAt ?? Date.now()) + turnIndex + itemIndex, + ); + const base = (event: AgentChatEvent, messageId: string = itemId): AgentChatEventEnvelope => + makeEnvelope(event, options, timestamp, messageId); + + switch (itemType) { + case "agentMessage": + case "agent_message": { + const text = codexMessageText(item); + return text.trim().length ? [base({ type: "text", text, itemId, turnId })] : []; + } + case "userMessage": + case "user_message": { + const text = codexMessageText(item); + return text.trim().length ? [base({ type: "user_message", text, messageId: itemId, turnId })] : []; + } + case "message": { + const role = codexMessageRole(item); + const text = codexMessageText(item); + if (!text.trim().length) return []; + if (role === "user") return [base({ type: "user_message", text, messageId: itemId, turnId })]; + if (role === "assistant") return [base({ type: "text", text, itemId, turnId })]; + return []; + } + case "reasoning": + case "agent_reasoning": { + const text = [ + contentToText(item.summary), + contentToText(item.content), + contentToText(item.text), + ].filter((part) => part.trim().length > 0).join("\n").trim(); + return text ? [base({ type: "reasoning", text, itemId, turnId })] : []; + } + case "plan": { + const text = codexMessageText(item); + return text.trim().length + ? [base({ type: "plan", steps: [], explanation: text, streamingText: text, state: "complete", itemId, turnId })] + : []; + } + case "commandExecution": + case "command_execution": { + const command = stringOrNull(item.command) ?? stringOrNull(item.cmd) ?? "command"; + const output = contentToText(item.aggregatedOutput ?? item.output ?? item.stdout ?? item.stderr); + const cwd = stringOrNull(item.cwd) ?? ""; + const exitCode = typeof item.exitCode === "number" ? item.exitCode : typeof item.exit_code === "number" ? item.exit_code : null; + const durationMs = typeof item.durationMs === "number" ? item.durationMs : typeof item.duration_ms === "number" ? item.duration_ms : null; + return [base({ + type: "command", + command, + cwd, + output, + itemId, + turnId, + status: mapStatus(item.status), + ...(exitCode !== null ? { exitCode } : {}), + ...(durationMs !== null ? { durationMs } : {}), + })]; + } + case "fileChange": + case "file_change": { + const changes = Array.isArray(item.changes) ? item.changes : [item]; + return changes + .map((change, changeIndex): AgentChatEventEnvelope | null => { + if (!isRecord(change)) return null; + const path = stringOrNull(change.path); + if (!path) return null; + return base({ + type: "file_change", + path, + diff: stringOrNull(change.unifiedDiff) ?? stringOrNull(change.diff) ?? "", + kind: mapFileChangeKind(change.kind ?? change.type), + itemId: changeIndex === 0 ? itemId : `${itemId}:${changeIndex}`, + turnId, + status: mapStatus(item.status), + }, changeIndex === 0 ? itemId : `${itemId}:${changeIndex}`); + }) + .filter((event): event is AgentChatEventEnvelope => event !== null); + } + case "webSearch": + case "web_search": { + const query = stringOrNull(item.query) ?? stringOrNull(item.input) ?? ""; + if (!query) return []; + const action = isRecord(item.action) ? stringOrNull(item.action.kind) : stringOrNull(item.action); + return [base({ type: "web_search", query, itemId, turnId, status: mapStatus(item.status), ...(action ? { action } : {}) })]; + } + case "imageGeneration": + case "image_generation": { + return [base({ + type: "codex_image_generation", + itemId, + turnId, + status: mapStatus(item.status), + prompt: stringOrNull(item.prompt), + revisedPrompt: stringOrNull(item.revisedPrompt) ?? stringOrNull(item.revised_prompt), + result: stringOrNull(item.result), + savedPath: stringOrNull(item.savedPath) ?? stringOrNull(item.saved_path), + })]; + } + case "imageView": + case "image_view": { + return [base({ + type: "codex_image_view", + itemId, + turnId, + status: mapStatus(item.status), + path: stringOrNull(item.path), + url: stringOrNull(item.url), + title: stringOrNull(item.title), + })]; + } + case "mcpToolCall": + case "dynamicToolCall": + case "tool_call": + case "function_call": { + const toolName = stringOrNull(item.tool) ?? stringOrNull(item.name) ?? itemType; + const server = stringOrNull(item.server); + const tool = server ? `${server}:${toolName}` : toolName; + const args = typeof item.arguments === "string" ? maybeParseJson(item.arguments) : item.arguments ?? item.input ?? {}; + return [base({ type: "tool_call", tool, args, itemId, turnId })]; + } + case "tool_result": + case "function_call_output": { + return [base({ + type: "tool_result", + tool: stringOrNull(item.tool) ?? stringOrNull(item.name) ?? "tool", + result: stringifyResult(item.output ?? item.result ?? item.content ?? ""), + itemId, + turnId, + status: mapStatus(item.status), + })]; + } + default: + return []; + } +} + +function codexTurnFallbackEvents( + turn: JsonRecord, + options: ExternalChatHistoryImportOptions, + turnIndex: number, +): AgentChatEventEnvelope[] { + const turnId = codexTurnId(turn, `codex-turn:${turnIndex}`); + const timestamp = timestampFrom(turn.startedAt ?? turn.createdAt ?? turn.created_at, (options.importedAt ?? Date.now()) + turnIndex); + const out: AgentChatEventEnvelope[] = []; + const userText = contentToText(turn.input ?? turn.userInput ?? turn.prompt); + if (userText.trim().length) { + out.push(makeEnvelope({ type: "user_message", text: userText, messageId: `${turnId}:user`, turnId }, options, timestamp, `${turnId}:user`)); + } + const assistantText = contentToText(turn.output ?? turn.response ?? turn.assistantMessage ?? turn.assistant_message); + if (assistantText.trim().length) { + out.push(makeEnvelope({ type: "text", text: assistantText, itemId: `${turnId}:assistant`, turnId }, options, timestamp, `${turnId}:assistant`)); + } + return out; +} + +export function codexTurnsToChatEvents( + turns: readonly unknown[], + options: ExternalChatHistoryImportOptions, +): AgentChatEventEnvelope[] { + const envelopes: AgentChatEventEnvelope[] = []; + turns.forEach((turn, turnIndex) => { + if (!isRecord(turn)) return; + const items = Array.isArray(turn.items) + ? turn.items + : Array.isArray(turn.events) + ? turn.events + : Array.isArray(turn.messages) + ? turn.messages + : []; + if (items.length > 0) { + items.forEach((item, itemIndex) => { + if (!isRecord(item)) return; + envelopes.push(...codexThreadItemToEvents(item, turn, options, turnIndex, itemIndex)); + }); + return; + } + envelopes.push(...codexTurnFallbackEvents(turn, options, turnIndex)); + }); + return finalizeImportEvents(envelopes, options); +} + +export function deriveImportedChatTitle( + envelopes: readonly AgentChatEventEnvelope[], + provider: AgentChatImportProvider, +): string { + const firstUser = envelopes.find((envelope) => envelope.event.type === "user_message"); + const firstAssistant = envelopes.find((envelope) => envelope.event.type === "text"); + const text = firstUser?.event.type === "user_message" + ? firstUser.event.text + : firstAssistant?.event.type === "text" + ? firstAssistant.event.text + : ""; + const normalized = normalizeWhitespace(text); + if (normalized.length) { + return normalized.length <= 72 ? normalized : `${normalized.slice(0, 71).trimEnd()}...`; + } + return provider === "claude" ? "Imported Claude chat" : "Imported Codex chat"; +} diff --git a/apps/desktop/src/main/services/externalSessions/claudeSessionTransplant.ts b/apps/desktop/src/main/services/externalSessions/claudeSessionTransplant.ts new file mode 100644 index 000000000..b4fe72ac9 --- /dev/null +++ b/apps/desktop/src/main/services/externalSessions/claudeSessionTransplant.ts @@ -0,0 +1,152 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import readline from "node:readline"; +import { randomUUID } from "node:crypto"; +import { claudeProjectSlugForCwd, isPathInside } from "./discoveryUtils"; + +function defaultClaudeConfigDir(): string { + const configured = process.env.CLAUDE_CONFIG_DIR?.trim(); + if (configured) return configured; + return path.join(os.homedir(), ".claude"); +} + +function ensureInside(root: string, target: string, label: string): string { + const resolvedRoot = path.resolve(root); + const resolvedTarget = path.resolve(target); + if (!isPathInside(resolvedRoot, resolvedTarget)) { + throw new Error(`${label} escapes Claude projects storage.`); + } + return resolvedTarget; +} + +function isErrno(error: unknown, code: string): boolean { + return Boolean(error && typeof error === "object" && (error as NodeJS.ErrnoException).code === code); +} + +function targetExistsError(targetPath: string): Error { + return new Error(`Claude target session already exists at ${targetPath}.`); +} + +async function linkWithoutClobber(sourcePath: string, targetPath: string): Promise { + try { + await fs.promises.link(sourcePath, targetPath); + } catch (error) { + if (isErrno(error, "EEXIST")) throw targetExistsError(targetPath); + throw error; + } +} + +async function rewriteClaudeSessionFile(args: { + sourcePath: string; + targetPath: string; + newSessionId: string; +}): Promise { + const tempPath = `${args.targetPath}.${process.pid}.${Date.now()}.tmp`; + let input: fs.ReadStream | null = null; + let output: fs.WriteStream | null = null; + try { + await fs.promises.mkdir(path.dirname(args.targetPath), { recursive: true }); + + input = fs.createReadStream(args.sourcePath, { encoding: "utf8" }); + output = fs.createWriteStream(tempPath, { encoding: "utf8", flags: "wx" }); + const writeStream = output; + const outputError = new Promise((_resolve, reject) => { + writeStream.once("error", reject); + }); + + const writeChunk = async (chunk: string): Promise => { + if (writeStream.write(chunk)) return; + await Promise.race([ + new Promise((resolve) => writeStream.once("drain", resolve)), + outputError, + ]); + }; + + const endOutput = async (): Promise => { + await Promise.race([ + new Promise((resolve) => writeStream.end(resolve)), + outputError, + ]); + }; + + await Promise.race([ + (async () => { + const lines = readline.createInterface({ input: input!, crlfDelay: Infinity }); + try { + for await (const line of lines) { + if (!line.trim()) { + await writeChunk("\n"); + continue; + } + let nextLine = line; + try { + const parsed = JSON.parse(line); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + nextLine = JSON.stringify({ ...parsed, sessionId: args.newSessionId }); + } + } catch { + nextLine = line; + } + await writeChunk(`${nextLine}\n`); + } + } finally { + lines.close(); + } + await endOutput(); + })(), + outputError, + ]); + + await linkWithoutClobber(tempPath, args.targetPath); + await fs.promises.unlink(tempPath); + } catch (error) { + input?.destroy(); + output?.destroy(); + throw error; + } finally { + try { + await fs.promises.unlink(tempPath); + } catch { + // Best-effort cleanup: the temp file may not exist or may have been renamed. + } + } +} + +export async function transplantClaudeSession(args: { + sessionId: string; + sourceCwd: string; + targetCwd: string; + fork: boolean; + configDir?: string; +}): Promise<{ newSessionId: string; targetPath: string }> { + const sessionId = args.sessionId.trim(); + const sourceCwd = args.sourceCwd.trim(); + const targetCwd = args.targetCwd.trim(); + if (!sessionId || !sourceCwd || !targetCwd) { + throw new Error("Claude transplant requires sessionId, sourceCwd, and targetCwd."); + } + + const configDir = path.resolve(args.configDir?.trim() || defaultClaudeConfigDir()); + const projectsDir = path.join(configDir, "projects"); + const sourceDir = path.join(projectsDir, claudeProjectSlugForCwd(sourceCwd)); + const targetDir = path.join(projectsDir, claudeProjectSlugForCwd(targetCwd)); + const sourcePath = ensureInside(sourceDir, path.join(sourceDir, `${sessionId}.jsonl`), "Claude source path"); + await fs.promises.access(sourcePath, fs.constants.R_OK); + + const newSessionId = args.fork ? randomUUID() : sessionId; + const targetPath = ensureInside(targetDir, path.join(targetDir, `${newSessionId}.jsonl`), "Claude target path"); + await fs.promises.mkdir(targetDir, { recursive: true }); + + if (args.fork) { + await rewriteClaudeSessionFile({ sourcePath, targetPath, newSessionId }); + return { newSessionId, targetPath }; + } + + if (path.resolve(sourcePath) === path.resolve(targetPath)) { + return { newSessionId, targetPath }; + } + await linkWithoutClobber(sourcePath, targetPath); + await fs.promises.unlink(sourcePath); + return { newSessionId, targetPath }; +} diff --git a/apps/desktop/src/main/services/externalSessions/discoverClaude.ts b/apps/desktop/src/main/services/externalSessions/discoverClaude.ts new file mode 100644 index 000000000..d5f07ac6e --- /dev/null +++ b/apps/desktop/src/main/services/externalSessions/discoverClaude.ts @@ -0,0 +1,106 @@ +import path from "node:path"; +import { + claudeProjectSlugForCwd, + cleanSessionTitle, + countJsonlLinesCheap, + firstUserTextFromRecords, + cwdIsInScope, + isUuidLike, + normalizeExternalSessionLimit, + previewFromRecords, + readJsonlRecords, + recordWithFile, + resolveHomeDir, + safeReadDir, + sessionFileCandidate, + slugMatchesScopeRoots, + sortFileCandidatesByMtime, + sortDiscoveryRecords, + asEpochMs, + asRecord, + asString, + type ExternalSessionDiscoveryArgs, + type ExternalSessionFileCandidate, + type ExternalSessionDiscoveryRecord, +} from "./discoveryUtils"; + +export function claudeConfigDir(args: ExternalSessionDiscoveryArgs = {}): string { + const env = args.env ?? (args.homeDir ? undefined : process.env); + const configured = typeof env?.CLAUDE_CONFIG_DIR === "string" + ? env.CLAUDE_CONFIG_DIR.trim() + : ""; + return configured || path.join(resolveHomeDir(args), ".claude"); +} + +export function claudeSessionPath(args: { + sessionId: string; + cwd: string; + configDir?: string; + homeDir?: string; + env?: NodeJS.ProcessEnv; +}): string { + const configDir = args.configDir?.trim() + || claudeConfigDir({ homeDir: args.homeDir, env: args.env }); + return path.join(configDir, "projects", claudeProjectSlugForCwd(args.cwd), `${args.sessionId}.jsonl`); +} + +function explicitClaudeTitleFromRecords(records: unknown[]): string | null { + for (const item of records) { + const record = asRecord(item); + if (!record) continue; + const title = cleanSessionTitle(asString(record.summary)) ?? cleanSessionTitle(asString(record.title)); + if (title) return title; + } + return null; +} + +export async function discoverClaudeSessions( + args: ExternalSessionDiscoveryArgs = {}, +): Promise { + const limit = normalizeExternalSessionLimit(args.limit); + const projectsDir = path.join(claudeConfigDir(args), "projects"); + const candidates: Array> = []; + + for (const projectEntry of safeReadDir(projectsDir)) { + if (!projectEntry.isDirectory()) continue; + if (!slugMatchesScopeRoots(projectEntry.name, args.scopeRoots, claudeProjectSlugForCwd)) continue; + const projectDir = path.join(projectsDir, projectEntry.name); + for (const entry of safeReadDir(projectDir)) { + if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue; + const id = entry.name.slice(0, -".jsonl".length); + if (!isUuidLike(id)) continue; + const filePath = path.join(projectDir, entry.name); + const candidate = sessionFileCandidate(filePath, { id }); + if (candidate) candidates.push(candidate); + } + } + + const records: ExternalSessionDiscoveryRecord[] = []; + for (const candidate of sortFileCandidatesByMtime(candidates, limit)) { + const jsonl = readJsonlRecords(candidate.filePath); + let cwd: string | null = null; + let createdAt: number | null = null; + for (const item of jsonl) { + const record = asRecord(item); + if (!record) continue; + cwd = cwd ?? asString(record.cwd); + createdAt = createdAt ?? asEpochMs(record.timestamp); + if (cwd && createdAt) break; + } + if (!cwdIsInScope(cwd, args.scopeRoots)) continue; + const firstUserText = firstUserTextFromRecords(jsonl); + records.push(recordWithFile({ + provider: "claude", + id: candidate.id, + cwd, + title: explicitClaudeTitleFromRecords(jsonl), + preview: firstUserText ?? previewFromRecords(jsonl), + createdAt, + messageCount: countJsonlLinesCheap(candidate.filePath), + filePath: candidate.filePath, + sourceMtimeMs: candidate.mtimeMs, + })); + } + + return sortDiscoveryRecords(records, limit); +} diff --git a/apps/desktop/src/main/services/externalSessions/discoverCodex.ts b/apps/desktop/src/main/services/externalSessions/discoverCodex.ts new file mode 100644 index 000000000..a35eef148 --- /dev/null +++ b/apps/desktop/src/main/services/externalSessions/discoverCodex.ts @@ -0,0 +1,242 @@ +import fs from "node:fs"; +import path from "node:path"; +import { + asEpochMs, + asRecord, + asString, + cleanSessionTitle, + countJsonlLinesCheap, + cwdIsInScope, + firstUserTextFromRecords, + normalizeExternalSessionLimit, + previewFromRecords, + readFilePrefix, + readFileSuffix, + readJsonlRecords, + recordWithFile, + resolveHomeDir, + safeParseJson, + safeReadDir, + sessionFileCandidate, + sortFileCandidatesByMtime, + sortDiscoveryRecords, + type ExternalSessionDiscoveryArgs, + type ExternalSessionFileCandidate, + type ExternalSessionDiscoveryRecord, +} from "./discoveryUtils"; + +type CodexIndexEntry = { + id: string; + title: string | null; + updatedAt: number | null; +}; + +type CodexSessionMeta = { + id: string; + cwd: string | null; + createdAt: number | null; + title: string | null; + first: Record; + payload: Record; +}; + +type CodexSessionCandidate = ExternalSessionFileCandidate<{ meta?: CodexSessionMeta | null }>; + +const CODEX_PROJECT_SCOPE_SCAN_CEILING = 2000; + +function readCodexIndex(indexPath: string): Map { + const map = new Map(); + const text = readFileSuffix(indexPath, 2 * 1024 * 1024); + if (!text) return map; + for (const line of text.split(/\r?\n/u)) { + const record = asRecord(safeParseJson(line)); + if (!record) continue; + const id = asString(record.id) ?? asString(record.session_id) ?? asString(record.sessionId); + if (!id) continue; + map.set(id, { + id, + title: cleanSessionTitle(asString(record.thread_name)) + ?? cleanSessionTitle(asString(record.threadName)) + ?? cleanSessionTitle(asString(record.name)) + ?? cleanSessionTitle(asString(record.title)), + updatedAt: asEpochMs(record.updated_at) ?? asEpochMs(record.updatedAt) ?? null, + }); + } + return map; +} + +function titleFromCodexPayload(payload: Record, indexed: CodexIndexEntry | undefined): string | null { + return cleanSessionTitle(asString(payload.thread_name)) + ?? cleanSessionTitle(asString(payload.threadName)) + ?? cleanSessionTitle(asString(payload.name)) + ?? indexed?.title + ?? null; +} + +function readCodexSessionMeta(filePath: string): CodexSessionMeta | null { + const text = readFilePrefix(filePath, 64 * 1024); + const line = text?.split(/\r?\n/u).find((entry) => entry.trim().length > 0); + if (!line) return null; + const first = asRecord(safeParseJson(line)); + const payload = asRecord(first?.payload); + const type = asString(first?.type); + if (type !== "session_meta" || !payload || !first) return null; + const id = asString(payload.id) ?? asString(payload.session_id) ?? asString(payload.sessionId); + if (!id) return null; + return { + id, + cwd: asString(payload.cwd), + createdAt: asEpochMs(payload.timestamp) ?? asEpochMs(first.timestamp), + title: titleFromCodexPayload(payload, undefined), + first, + payload, + }; +} + +function sortedChildDirs(dir: string, pattern: RegExp): string[] { + return safeReadDir(dir) + .filter((entry) => entry.isDirectory() && pattern.test(entry.name)) + .map((entry) => entry.name) + .sort((left, right) => right.localeCompare(left)); +} + +function collectRecentCodexSessionCandidates(root: string, limit: number): CodexSessionCandidate[] { + const candidates: CodexSessionCandidate[] = []; + const years = sortedChildDirs(root, /^\d{4}$/u); + for (const year of years) { + const yearDir = path.join(root, year); + for (const month of sortedChildDirs(yearDir, /^\d{2}$/u)) { + const monthDir = path.join(yearDir, month); + for (const day of sortedChildDirs(monthDir, /^\d{2}$/u)) { + const dayDir = path.join(monthDir, day); + for (const entry of safeReadDir(dayDir)) { + if (!entry.isFile() || (!entry.name.endsWith(".jsonl") && !entry.name.endsWith(".jsonl.zst"))) { + continue; + } + const candidate = sessionFileCandidate(path.join(dayDir, entry.name), {}); + if (candidate) candidates.push(candidate); + } + if (candidates.length >= limit) return sortFileCandidatesByMtime(candidates, limit); + } + } + } + return sortFileCandidatesByMtime(candidates, limit); +} + +function collectProjectScopedCodexSessionCandidates( + root: string, + limit: number, + scopeRoots: string[], + logger: ExternalSessionDiscoveryArgs["logger"], +): CodexSessionCandidate[] { + const candidates: CodexSessionCandidate[] = []; + let scanned = 0; + let ceilingHit = false; + const finish = (): CodexSessionCandidate[] => { + if (ceilingHit && candidates.length < limit) { + logger?.warn?.("external_sessions.codex_project_scope_scan_truncated", { + ceiling: CODEX_PROJECT_SCOPE_SCAN_CEILING, + scanned, + matched: candidates.length, + limit, + }); + } + return sortFileCandidatesByMtime(candidates, limit); + }; + + const years = sortedChildDirs(root, /^\d{4}$/u); + for (const year of years) { + const yearDir = path.join(root, year); + for (const month of sortedChildDirs(yearDir, /^\d{2}$/u)) { + const monthDir = path.join(yearDir, month); + for (const day of sortedChildDirs(monthDir, /^\d{2}$/u)) { + const dayDir = path.join(monthDir, day); + for (const entry of safeReadDir(dayDir)) { + if (!entry.isFile() || (!entry.name.endsWith(".jsonl") && !entry.name.endsWith(".jsonl.zst"))) { + continue; + } + const candidate = sessionFileCandidate(path.join(dayDir, entry.name), {}); + if (!candidate) continue; + if (scanned >= CODEX_PROJECT_SCOPE_SCAN_CEILING) { + ceilingHit = true; + return finish(); + } + scanned += 1; + if (candidate.filePath.endsWith(".jsonl.zst")) continue; + const meta = readCodexSessionMeta(candidate.filePath); + if (!cwdIsInScope(meta?.cwd, scopeRoots)) continue; + candidates.push({ ...candidate, meta }); + if (candidates.length >= limit) return finish(); + } + } + } + } + return finish(); +} + +function idFromCodexFilename(filePath: string): string | null { + const base = path.basename(filePath).replace(/\.jsonl(?:\.zst)?$/u, ""); + const match = base.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/iu); + return match?.[1] ?? null; +} + +export async function discoverCodexSessions( + args: ExternalSessionDiscoveryArgs = {}, +): Promise { + const limit = normalizeExternalSessionLimit(args.limit); + const codexDir = path.join(resolveHomeDir(args), ".codex"); + const sessionsDir = path.join(codexDir, "sessions"); + const index = readCodexIndex(path.join(codexDir, "session_index.jsonl")); + const recordsById = new Map(); + if (!fs.existsSync(sessionsDir)) return []; + + const candidates = args.scopeRoots?.length + ? collectProjectScopedCodexSessionCandidates(sessionsDir, limit, args.scopeRoots, args.logger) + : collectRecentCodexSessionCandidates(sessionsDir, limit); + + for (const candidate of candidates) { + const filePath = candidate.filePath; + const compressed = filePath.endsWith(".jsonl.zst"); + if (compressed) { + const id = idFromCodexFilename(filePath); + if (!id || recordsById.has(id)) continue; + const indexed = index.get(id); + recordsById.set(id, recordWithFile({ + provider: "codex", + id, + cwd: null, + title: indexed?.title ?? null, + preview: null, + updatedAt: indexed?.updatedAt ?? candidate.mtimeMs, + filePath, + sourceMtimeMs: candidate.mtimeMs, + })); + continue; + } + + const jsonl = readJsonlRecords(filePath); + const first = candidate.meta?.first ?? asRecord(jsonl[0]); + const payload = candidate.meta?.payload ?? asRecord(first?.payload); + const type = asString(first?.type); + if (type !== "session_meta" || !payload) continue; + const id = candidate.meta?.id ?? asString(payload.id) ?? asString(payload.session_id) ?? asString(payload.sessionId); + if (!id || recordsById.has(id)) continue; + const indexed = index.get(id); + const firstUserText = firstUserTextFromRecords(jsonl); + const title = candidate.meta?.title ?? titleFromCodexPayload(payload, indexed); + recordsById.set(id, recordWithFile({ + provider: "codex", + id, + cwd: candidate.meta?.cwd ?? asString(payload.cwd), + title, + preview: firstUserText ?? previewFromRecords(jsonl), + createdAt: candidate.meta?.createdAt ?? asEpochMs(payload.timestamp) ?? asEpochMs(first?.timestamp), + updatedAt: indexed?.updatedAt ?? candidate.mtimeMs, + messageCount: countJsonlLinesCheap(filePath), + filePath, + sourceMtimeMs: candidate.mtimeMs, + })); + } + + return sortDiscoveryRecords(Array.from(recordsById.values()), limit); +} diff --git a/apps/desktop/src/main/services/externalSessions/discoverCursor.ts b/apps/desktop/src/main/services/externalSessions/discoverCursor.ts new file mode 100644 index 000000000..5f3f19df9 --- /dev/null +++ b/apps/desktop/src/main/services/externalSessions/discoverCursor.ts @@ -0,0 +1,97 @@ +import path from "node:path"; +import { + asEpochMs, + asRecord, + asString, + countJsonlLinesCheap, + cwdCandidatesIncludeScope, + cwdIsInScope, + firstUserTextFromRecords, + normalizeExternalSessionLimit, + previewFromRecords, + readJsonlRecords, + recordWithFile, + cursorSlugCwdCandidates, + resolveCursorCwdFromSlug, + resolveHomeDir, + safeReadDir, + sessionFileCandidate, + slugMatchesScopeRoots, + sortFileCandidatesByMtime, + sortDiscoveryRecords, + type ExternalSessionDiscoveryArgs, + type ExternalSessionFileCandidate, + type ExternalSessionDiscoveryRecord, +} from "./discoveryUtils"; + +function cursorProjectSlugForCwd(cwd: string): string { + return cwd.replace(/^[/\\]+/u, "").replace(/[\\/]/gu, "-"); +} + +export async function discoverCursorSessions( + args: ExternalSessionDiscoveryArgs = {}, +): Promise { + const limit = normalizeExternalSessionLimit(args.limit); + const projectsDir = path.join(resolveHomeDir(args), ".cursor", "projects"); + const candidates: Array> = []; + + for (const projectEntry of safeReadDir(projectsDir)) { + if (!projectEntry.isDirectory()) continue; + if ( + !slugMatchesScopeRoots(projectEntry.name, args.scopeRoots, cursorProjectSlugForCwd) + && !cwdCandidatesIncludeScope(cursorSlugCwdCandidates(projectEntry.name), args.scopeRoots) + ) { + continue; + } + const transcriptRoot = path.join(projectsDir, projectEntry.name, "agent-transcripts"); + for (const agentEntry of safeReadDir(transcriptRoot)) { + if (!agentEntry.isDirectory()) continue; + const agentId = agentEntry.name; + if (agentId.startsWith("agent-")) continue; + const filePath = path.join(transcriptRoot, agentId, `${agentId}.jsonl`); + const candidate = sessionFileCandidate(filePath, { agentId, projectSlug: projectEntry.name }); + if (candidate) candidates.push(candidate); + } + } + + const records: ExternalSessionDiscoveryRecord[] = []; + for (const candidate of sortFileCandidatesByMtime(candidates, limit)) { + const jsonl = readJsonlRecords(candidate.filePath); + if (!jsonl.length) continue; + const first = asRecord(jsonl[0]); + const transcriptCwd = cursorCwdFromRecords(jsonl); + const cwd = transcriptCwd ?? resolveCursorCwdFromSlug(candidate.projectSlug); + if (!cwdIsInScope(cwd, args.scopeRoots)) continue; + records.push(recordWithFile({ + provider: "cursor", + id: candidate.agentId, + cwd, + title: null, + preview: firstUserTextFromRecords(jsonl) ?? previewFromRecords(jsonl), + createdAt: asEpochMs(first?.timestamp) ?? asEpochMs(asRecord(first?.message)?.timestamp), + messageCount: countJsonlLinesCheap(candidate.filePath), + filePath: candidate.filePath, + sourceMtimeMs: candidate.mtimeMs, + })); + } + + return sortDiscoveryRecords(records, limit); +} + +function cursorCwdFromRecords(records: unknown[]): string | null { + for (const record of records) { + const obj = asRecord(record); + if (!obj) continue; + const message = asRecord(obj.message); + const payload = asRecord(obj.payload); + const cwd = asString(obj.cwd) + ?? asString(obj.workspacePath) + ?? asString(obj.workspace_path) + ?? asString(message?.cwd) + ?? asString(payload?.cwd) + ?? asString(payload?.workspacePath) + ?? asString(payload?.workspace_path); + if (cwd) return cwd; + } + return null; +} diff --git a/apps/desktop/src/main/services/externalSessions/discoverDroid.ts b/apps/desktop/src/main/services/externalSessions/discoverDroid.ts new file mode 100644 index 000000000..d063f3235 --- /dev/null +++ b/apps/desktop/src/main/services/externalSessions/discoverDroid.ts @@ -0,0 +1,74 @@ +import path from "node:path"; +import { + asEpochMs, + asRecord, + asString, + cleanSessionTitle, + countJsonlLinesCheap, + cwdIsInScope, + firstUserTextFromRecords, + normalizeExternalSessionLimit, + previewFromRecords, + readJsonlRecords, + recordWithFile, + resolveHomeDir, + safeReadDir, + sessionFileCandidate, + slashEscapedCwd, + slugMatchesScopeRoots, + sortFileCandidatesByMtime, + sortDiscoveryRecords, + type ExternalSessionDiscoveryArgs, + type ExternalSessionFileCandidate, + type ExternalSessionDiscoveryRecord, +} from "./discoveryUtils"; + +export async function discoverDroidSessions( + args: ExternalSessionDiscoveryArgs = {}, +): Promise { + const limit = normalizeExternalSessionLimit(args.limit); + const sessionsDir = path.join(resolveHomeDir(args), ".factory", "sessions"); + const candidates: ExternalSessionFileCandidate[] = []; + + for (const projectEntry of safeReadDir(sessionsDir)) { + if (!projectEntry.isDirectory()) continue; + if ( + projectEntry.name.startsWith("-") + && !slugMatchesScopeRoots(projectEntry.name, args.scopeRoots, slashEscapedCwd) + ) { + continue; + } + const projectDir = path.join(sessionsDir, projectEntry.name); + for (const entry of safeReadDir(projectDir)) { + if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue; + const filePath = path.join(projectDir, entry.name); + const candidate = sessionFileCandidate(filePath, {}); + if (candidate) candidates.push(candidate); + } + } + + const records: ExternalSessionDiscoveryRecord[] = []; + for (const candidate of sortFileCandidatesByMtime(candidates, limit)) { + const jsonl = readJsonlRecords(candidate.filePath); + const first = asRecord(jsonl[0]); + if (!first || asString(first.type) !== "session_start") continue; + const id = asString(first.id) ?? path.basename(candidate.filePath, ".jsonl"); + if (!id) continue; + const cwd = asString(first.cwd); + if (!cwdIsInScope(cwd, args.scopeRoots)) continue; + const firstUserText = firstUserTextFromRecords(jsonl); + records.push(recordWithFile({ + provider: "droid", + id, + cwd, + title: cleanSessionTitle(asString(first.title)) ?? cleanSessionTitle(asString(first.sessionTitle)), + preview: firstUserText ?? previewFromRecords(jsonl), + createdAt: asEpochMs(first.timestamp), + messageCount: countJsonlLinesCheap(candidate.filePath), + filePath: candidate.filePath, + sourceMtimeMs: candidate.mtimeMs, + })); + } + + return sortDiscoveryRecords(records, limit); +} diff --git a/apps/desktop/src/main/services/externalSessions/discoverOpenCode.ts b/apps/desktop/src/main/services/externalSessions/discoverOpenCode.ts new file mode 100644 index 000000000..b808601af --- /dev/null +++ b/apps/desktop/src/main/services/externalSessions/discoverOpenCode.ts @@ -0,0 +1,84 @@ +import { execFile } from "node:child_process"; +import path from "node:path"; +import { promisify } from "node:util"; +import { resolveOpenCodeBinaryPath } from "../opencode/openCodeBinaryManager"; +import { + asEpochMs, + asRecord, + asString, + cleanSessionTitle, + clipExternalSessionText, + normalizeExternalSessionLimit, + recordWithFile, + resolveHomeDir, + sortDiscoveryRecords, + type ExternalSessionDiscoveryArgs, + type ExternalSessionDiscoveryRecord, +} from "./discoveryUtils"; + +const execFileAsync = promisify(execFile); + +export async function discoverOpenCodeSessions( + args: ExternalSessionDiscoveryArgs = {}, +): Promise { + const limit = normalizeExternalSessionLimit(args.limit); + const executable = resolveOpenCodeBinaryPath(); + if (!executable) return []; + const cwd = args.cwd?.trim() || args.projectRoot?.trim() || resolveHomeDir(args); + const env: NodeJS.ProcessEnv = { ...process.env, ...(args.env ?? {}), NO_COLOR: "1" }; + delete env.FORCE_COLOR; + + let stdout: string; + try { + const result = await execFileAsync( + executable, + ["session", "list", "--format", "json", "--max-count", String(limit)], + { + cwd: path.resolve(cwd), + encoding: "utf8", + timeout: 4000, + killSignal: "SIGTERM", + maxBuffer: 2 * 1024 * 1024, + env, + }, + ); + stdout = String(result.stdout ?? ""); + } catch { + return []; + } + const jsonStart = stdout.indexOf("["); + if (jsonStart < 0) return []; + + let parsed: unknown; + try { + parsed = JSON.parse(stdout.slice(jsonStart)); + } catch { + return []; + } + if (!Array.isArray(parsed)) return []; + + const records: ExternalSessionDiscoveryRecord[] = []; + for (const row of parsed) { + const record = asRecord(row); + const id = asString(record?.id) ?? asString(record?.sessionID) ?? asString(record?.sessionId); + if (!record || !id) continue; + const rowCwd = asString(record.directory) ?? asString(record.cwd) ?? cwd; + const title = cleanSessionTitle(asString(record.title)) ?? cleanSessionTitle(asString(record.name)); + const preview = clipExternalSessionText( + asString(record.summary) ?? asString(record.preview) ?? asString(record.snippet), + ); + records.push(recordWithFile({ + provider: "opencode", + id, + cwd: rowCwd, + title, + preview, + createdAt: asEpochMs(record.created) ?? asEpochMs(record.createdAt), + updatedAt: asEpochMs(record.updated) ?? asEpochMs(record.updatedAt), + messageCount: typeof record.messageCount === "number" ? record.messageCount : null, + filePath: null, + })); + } + + return sortDiscoveryRecords(records, limit); +} diff --git a/apps/desktop/src/main/services/externalSessions/discoverProviders.test.ts b/apps/desktop/src/main/services/externalSessions/discoverProviders.test.ts new file mode 100644 index 000000000..b02408849 --- /dev/null +++ b/apps/desktop/src/main/services/externalSessions/discoverProviders.test.ts @@ -0,0 +1,261 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { clearOpenCodeBinaryCache } from "../opencode/openCodeBinaryManager"; +import { discoverClaudeSessions } from "./discoverClaude"; +import { discoverCodexSessions } from "./discoverCodex"; +import { discoverCursorSessions } from "./discoverCursor"; +import { discoverDroidSessions } from "./discoverDroid"; +import { discoverOpenCodeSessions } from "./discoverOpenCode"; +import { claudeProjectSlugForCwd, slashEscapedCwd } from "./discoveryUtils"; + +let root: string; +let previousHome: string | undefined; +let previousPath: string | undefined; +let previousDisableBundledOpenCode: string | undefined; + +function writeJsonl(filePath: string, rows: unknown[]): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, rows.map((row) => JSON.stringify(row)).join("\n") + "\n", "utf8"); +} + +beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-external-discovery-")); + previousHome = process.env.HOME; + previousPath = process.env.PATH; + previousDisableBundledOpenCode = process.env.ADE_DISABLE_BUNDLED_OPENCODE; +}); + +afterEach(() => { + if (previousHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = previousHome; + } + if (previousPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = previousPath; + } + if (previousDisableBundledOpenCode === undefined) { + delete process.env.ADE_DISABLE_BUNDLED_OPENCODE; + } else { + process.env.ADE_DISABLE_BUNDLED_OPENCODE = previousDisableBundledOpenCode; + } + clearOpenCodeBinaryCache(); + fs.rmSync(root, { recursive: true, force: true }); +}); + +describe("external session provider discovery", () => { + it("discovers Claude sessions, recovers cwd, and uses the first user message as preview", async () => { + const homeDir = path.join(root, "home"); + const cwd = path.join(root, "repo"); + fs.mkdirSync(cwd, { recursive: true }); + const id = "11111111-1111-4111-8111-111111111111"; + writeJsonl(path.join(homeDir, ".claude", "projects", claudeProjectSlugForCwd(cwd), `${id}.jsonl`), [ + { type: "mode", sessionId: id }, + { + type: "message", + sessionId: id, + cwd, + timestamp: "2026-07-06T10:00:00.000Z", + message: { role: "user", content: [{ type: "text", text: "ADE session guidance.\n\nUser request: Fix login redirect" }] }, + }, + ]); + + const sessions = await discoverClaudeSessions({ homeDir, limit: 10 }); + + expect(sessions).toHaveLength(1); + expect(sessions[0]).toMatchObject({ + provider: "claude", + id, + cwd, + title: null, + preview: "Fix login redirect", + createdAt: Date.parse("2026-07-06T10:00:00.000Z"), + }); + expect(sessions[0]?.messageCount).toBe(2); + }); + + it("limits Claude content reads to the newest stat candidates", async () => { + const homeDir = path.join(root, "home"); + const cwd = path.join(root, "repo"); + fs.mkdirSync(cwd, { recursive: true }); + const newerId = "11111111-1111-4111-8111-111111111111"; + const olderId = "22222222-2222-4222-8222-222222222222"; + const projectDir = path.join(homeDir, ".claude", "projects", claudeProjectSlugForCwd(cwd)); + const newerPath = path.join(projectDir, `${newerId}.jsonl`); + const olderPath = path.join(projectDir, `${olderId}.jsonl`); + writeJsonl(newerPath, [ + { type: "message", sessionId: newerId, cwd, message: { role: "user", content: "newer request" } }, + ]); + writeJsonl(olderPath, [ + { type: "message", sessionId: olderId, cwd, message: { role: "user", content: "older request" } }, + ]); + fs.utimesSync(olderPath, new Date("2026-07-05T10:00:00.000Z"), new Date("2026-07-05T10:00:00.000Z")); + fs.utimesSync(newerPath, new Date("2026-07-06T10:00:00.000Z"), new Date("2026-07-06T10:00:00.000Z")); + + const openSync = vi.spyOn(fs, "openSync"); + let openedPaths: string[] = []; + try { + const sessions = await discoverClaudeSessions({ homeDir, limit: 1 }); + expect(sessions.map((session) => session.id)).toEqual([newerId]); + openedPaths = openSync.mock.calls.map((call) => String(call[0])); + } finally { + openSync.mockRestore(); + } + + expect(openedPaths).toContain(newerPath); + expect(openedPaths).not.toContain(olderPath); + }); + + it("discovers Codex rollout files and enriches titles from session_index.jsonl", async () => { + const homeDir = path.join(root, "home"); + const cwd = path.join(root, "repo"); + const id = "22222222-2222-4222-8222-222222222222"; + fs.mkdirSync(cwd, { recursive: true }); + writeJsonl(path.join(homeDir, ".codex", "session_index.jsonl"), [ + { id, thread_name: "Investigate flaky test", updated_at: "2026-07-06T11:00:00.000Z" }, + ]); + writeJsonl(path.join(homeDir, ".codex", "sessions", "2026", "07", "06", `rollout-2026-07-06T10-00-00-${id}.jsonl`), [ + { + timestamp: "2026-07-06T10:00:00.000Z", + type: "session_meta", + payload: { id, session_id: id, cwd, timestamp: "2026-07-06T10:00:00.000Z" }, + }, + { timestamp: "2026-07-06T10:01:00.000Z", type: "event_msg", payload: { type: "message", role: "user", message: { content: "please fix flakes" } } }, + ]); + + const sessions = await discoverCodexSessions({ homeDir, limit: 10 }); + + expect(sessions).toHaveLength(1); + expect(sessions[0]).toMatchObject({ + provider: "codex", + id, + cwd, + title: "Investigate flaky test", + preview: "please fix flakes", + updatedAt: Date.parse("2026-07-06T11:00:00.000Z"), + }); + }); + + it("does not derive Codex titles from the first user message", async () => { + const homeDir = path.join(root, "home"); + const cwd = path.join(root, "repo"); + const id = "55555555-5555-4555-8555-555555555555"; + fs.mkdirSync(cwd, { recursive: true }); + writeJsonl(path.join(homeDir, ".codex", "sessions", "2026", "07", "06", `rollout-2026-07-06T12-00-00-${id}.jsonl`), [ + { + timestamp: "2026-07-06T12:00:00.000Z", + type: "session_meta", + payload: { id, session_id: id, cwd, timestamp: "2026-07-06T12:00:00.000Z" }, + }, + { timestamp: "2026-07-06T12:01:00.000Z", type: "event_msg", payload: { type: "message", role: "user", message: { content: "do not use this as a title" } } }, + ]); + + const sessions = await discoverCodexSessions({ homeDir, limit: 10 }); + + expect(sessions).toHaveLength(1); + expect(sessions[0]).toMatchObject({ + provider: "codex", + id, + title: null, + preview: "do not use this as a title", + }); + }); + + it("discovers Cursor transcripts and resolves cwd from the project slug when possible", async () => { + const homeDir = path.join(root, "home"); + // Cursor discovery de-slugs a project dir back to a real path via `/`, + // so the fixture cwd must actually exist AND be dash-free (a `-` would round-trip to `/`). + // Use a writable temp dir (CI can't mkdir under /private/tmp) and derive the slug from it. + const cwd = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "adecursorcwd"))); + expect(cwd.includes("-"), "temp cwd must be dash-free for the cursor slug round-trip").toBe(false); + const slug = cwd.replace(/^\/+/u, "").replace(/\//gu, "-"); + const agentId = "33333333-3333-4333-8333-333333333333"; + const sdkAgentId = "agent-44444444-4444-4444-8444-444444444444"; + writeJsonl(path.join(homeDir, ".cursor", "projects", slug, "agent-transcripts", agentId, `${agentId}.jsonl`), [ + { role: "user", message: { content: [{ type: "text", text: "Port this session into ADE" }] } }, + ]); + writeJsonl(path.join(homeDir, ".cursor", "projects", slug, "agent-transcripts", sdkAgentId, `${sdkAgentId}.jsonl`), [ + { role: "user", message: { content: [{ type: "text", text: "SDK session should not be resumable" }] } }, + ]); + + const sessions = await discoverCursorSessions({ homeDir, limit: 10 }); + + expect(sessions).toHaveLength(1); + expect(sessions[0]).toMatchObject({ + provider: "cursor", + id: agentId, + cwd, + title: null, + preview: "Port this session into ADE", + }); + fs.rmSync(cwd, { recursive: true, force: true }); + }); + + it("discovers Droid sessions from Factory storage", async () => { + const homeDir = path.join(root, "home"); + const cwd = path.join(root, "droid-repo"); + const id = "44444444-4444-4444-8444-444444444444"; + writeJsonl(path.join(homeDir, ".factory", "sessions", slashEscapedCwd(cwd), `${id}.jsonl`), [ + { type: "session_start", id, title: "New Session", cwd }, + { type: "message", message: { role: "user", content: [{ type: "text", text: "Factory task title" }] } }, + ]); + + const sessions = await discoverDroidSessions({ homeDir, limit: 10 }); + + expect(sessions).toHaveLength(1); + expect(sessions[0]).toMatchObject({ + provider: "droid", + id, + cwd, + title: null, + preview: "Factory task title", + }); + }); + + it("uses the OpenCode CLI list command and skips cleanly when unavailable", async () => { + const homeDir = path.join(root, "home"); + const cwd = path.join(root, "opencode-repo"); + fs.mkdirSync(cwd, { recursive: true }); + process.env.ADE_DISABLE_BUNDLED_OPENCODE = "1"; + process.env.HOME = homeDir; + process.env.PATH = path.join(root, "missing-bin"); + clearOpenCodeBinaryCache(); + await expect(discoverOpenCodeSessions({ homeDir, cwd, limit: 10 })).resolves.toEqual([]); + + const binDir = path.join(root, "bin"); + fs.mkdirSync(binDir, { recursive: true }); + const scriptPath = path.join(binDir, "opencode"); + fs.writeFileSync( + scriptPath, + `#!/bin/sh\nprintf '%s\\n' '[{"id":"open-1","directory":"${cwd}","title":"OpenCode task","summary":"Use OpenCode to map the issue","created":1783332000000,"updated":1783332060000,"messageCount":3},{"id":"open-2","directory":"${cwd}","title":"New session - 2026-05-01T17:02:11.923Z","preview":"Placeholder title should not win","created":1783331000000,"updated":1783331060000,"messageCount":1}]'\n`, + "utf8", + ); + fs.chmodSync(scriptPath, 0o755); + process.env.PATH = `${binDir}${path.delimiter}${previousPath ?? ""}`; + clearOpenCodeBinaryCache(); + + const sessions = await discoverOpenCodeSessions({ homeDir, cwd, limit: 10 }); + + expect(sessions).toHaveLength(2); + expect(sessions[0]).toMatchObject({ + provider: "opencode", + id: "open-1", + cwd, + title: "OpenCode task", + preview: "Use OpenCode to map the issue", + messageCount: 3, + }); + expect(sessions[1]).toMatchObject({ + provider: "opencode", + id: "open-2", + cwd, + title: null, + preview: "Placeholder title should not win", + messageCount: 1, + }); + }); +}); diff --git a/apps/desktop/src/main/services/externalSessions/discoveryUtils.test.ts b/apps/desktop/src/main/services/externalSessions/discoveryUtils.test.ts new file mode 100644 index 000000000..6af83ff5f --- /dev/null +++ b/apps/desktop/src/main/services/externalSessions/discoveryUtils.test.ts @@ -0,0 +1,42 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { firstUserTextFromRecords, resolveCursorCwdFromSlug } from "./discoveryUtils"; + +describe("firstUserTextFromRecords", () => { + it("skips message records with explicit assistant role", () => { + const text = firstUserTextFromRecords([ + { + type: "message", + message: { + role: "assistant", + content: [{ type: "text", text: "Assistant summary should not win." }], + }, + }, + { + type: "message", + message: { + role: "user", + content: [{ type: "text", text: "Use this request as the title." }], + }, + }, + ]); + + expect(text).toBe("Use this request as the title."); + }); +}); + +describe("resolveCursorCwdFromSlug", () => { + it("uses the filesystem to recover hyphenated and dotted path segments", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cursor-slug-")); + const cwd = path.join(root, "Projects", "my-cool.app", ".ade", "worktrees", "lane-with-hyphen"); + fs.mkdirSync(cwd, { recursive: true }); + try { + const slug = cwd.replace(/^\/+/u, "").replace(/[/.]/gu, "-"); + expect(resolveCursorCwdFromSlug(slug)).toBe(fs.realpathSync(cwd)); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/desktop/src/main/services/externalSessions/discoveryUtils.ts b/apps/desktop/src/main/services/externalSessions/discoveryUtils.ts new file mode 100644 index 000000000..e5945d44d --- /dev/null +++ b/apps/desktop/src/main/services/externalSessions/discoveryUtils.ts @@ -0,0 +1,537 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { + ExternalSessionProvider, + ExternalSessionSummary, +} from "../../../shared/types/externalSessions"; + +export type ExternalSessionDiscoveryRecord = Omit< + ExternalSessionSummary, + "alreadyImported" | "possiblyActive" | "cwdMatchesRequestedLane" | "capabilities" +> & { + sourcePath?: string | null; + sourceMtimeMs?: number | null; +}; + +export type ExternalSessionDiscoveryArgs = { + homeDir?: string; + env?: NodeJS.ProcessEnv; + cwd?: string | null; + projectRoot?: string | null; + limit?: number | null; + scopeRoots?: string[] | null; + logger?: { + warn?: (message: string, fields?: Record) => void; + info?: (message: string, fields?: Record) => void; + } | null; +}; + +export type ExternalSessionFileCandidate = Record> = T & { + filePath: string; + mtimeMs: number; + size: number; +}; + +export const DEFAULT_EXTERNAL_SESSION_LIMIT = 50; +export const MAX_EXTERNAL_SESSION_LIMIT = 500; +export const JSONL_SCAN_LINE_LIMIT = 80; +export const JSONL_SCAN_BYTE_LIMIT = 512 * 1024; +export const MESSAGE_COUNT_MAX_BYTES = 768 * 1024; +export const EXTERNAL_SESSION_PREVIEW_MAX_LENGTH = 240; + +const PLACEHOLDER_SESSION_TITLES = new Set([ + "new session", + "untitled", + "new chat", +]); + +export function normalizeExternalSessionLimit(limit: unknown): number { + if (typeof limit !== "number" || !Number.isFinite(limit) || limit <= 0) { + return DEFAULT_EXTERNAL_SESSION_LIMIT; + } + return Math.max(1, Math.min(MAX_EXTERNAL_SESSION_LIMIT, Math.floor(limit))); +} + +export function resolveHomeDir(args?: Pick): string { + const explicit = typeof args?.homeDir === "string" ? args.homeDir.trim() : ""; + if (explicit) return explicit; + const env = args?.env ?? process.env; + const envHome = typeof env.HOME === "string" ? env.HOME.trim() : ""; + return envHome || os.homedir(); +} + +export function safeStat(filePath: string): fs.Stats | null { + try { + return fs.statSync(filePath); + } catch { + return null; + } +} + +export function safeReadDir(dirPath: string): fs.Dirent[] { + try { + return fs.readdirSync(dirPath, { withFileTypes: true }); + } catch { + return []; + } +} + +export function sessionFileCandidate>( + filePath: string, + extra: T, +): ExternalSessionFileCandidate | null { + const stat = safeStat(filePath); + if (!stat?.isFile()) return null; + return { + ...extra, + filePath, + mtimeMs: Math.floor(stat.mtimeMs), + size: stat.size, + }; +} + +export function sortFileCandidatesByMtime( + candidates: T[], + limit: number, +): T[] { + return candidates + .slice() + .sort((left, right) => { + if (right.mtimeMs !== left.mtimeMs) return right.mtimeMs - left.mtimeMs; + return left.filePath.localeCompare(right.filePath); + }) + .slice(0, limit); +} + +export function safeParseJson(line: string): unknown | null { + try { + return JSON.parse(line); + } catch { + return null; + } +} + +export function readFilePrefix(filePath: string, maxBytes = JSONL_SCAN_BYTE_LIMIT): string | null { + let fd: number | null = null; + try { + fd = fs.openSync(filePath, "r"); + const buf = Buffer.alloc(maxBytes); + const bytesRead = fs.readSync(fd, buf, 0, maxBytes, 0); + if (bytesRead <= 0) return null; + return buf.subarray(0, bytesRead).toString("utf8"); + } catch { + return null; + } finally { + if (fd !== null) { + try { + fs.closeSync(fd); + } catch { + // best effort close + } + } + } +} + +export function readFileSuffix(filePath: string, maxBytes = JSONL_SCAN_BYTE_LIMIT): string | null { + let fd: number | null = null; + try { + const stat = fs.statSync(filePath); + const bytesToRead = Math.min(maxBytes, Math.max(0, stat.size)); + if (bytesToRead <= 0) return null; + fd = fs.openSync(filePath, "r"); + const buf = Buffer.alloc(bytesToRead); + const bytesRead = fs.readSync(fd, buf, 0, bytesToRead, Math.max(0, stat.size - bytesToRead)); + if (bytesRead <= 0) return null; + return buf.subarray(0, bytesRead).toString("utf8"); + } catch { + return null; + } finally { + if (fd !== null) { + try { + fs.closeSync(fd); + } catch { + // best effort close + } + } + } +} + +export function readJsonlRecords(filePath: string, maxLines = JSONL_SCAN_LINE_LIMIT): unknown[] { + const text = readFilePrefix(filePath); + if (!text) return []; + const lines = text.split(/\r?\n/u).filter((line) => line.trim().length > 0).slice(0, maxLines); + return lines + .map((line) => safeParseJson(line)) + .filter((record): record is Record => record != null); +} + +export function countJsonlLinesCheap(filePath: string): number | null { + const stat = safeStat(filePath); + if (!stat || stat.size > MESSAGE_COUNT_MAX_BYTES) return null; + const text = readFilePrefix(filePath, MESSAGE_COUNT_MAX_BYTES + 1); + if (!text) return 0; + return text.split(/\r?\n/u).filter((line) => line.trim().length > 0).length; +} + +export function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +export function asString(value: unknown): string | null { + return typeof value === "string" && value.trim().length ? value.trim() : null; +} + +export function cleanSessionTitle(raw: string | null | undefined): string | null { + const title = raw?.replace(/\s+/gu, " ").trim() ?? ""; + if (!title) return null; + const normalized = title.toLowerCase(); + if (PLACEHOLDER_SESSION_TITLES.has(normalized)) return null; + if (/^new (?:session|chat)\s*[-:]\s*\d{4}-\d{2}-\d{2}/u.test(normalized)) return null; + return title; +} + +export function asEpochMs(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) { + return value > 10_000_000_000 ? Math.floor(value) : Math.floor(value * 1000); + } + if (typeof value !== "string" || !value.trim()) return null; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : null; +} + +export function extractText(value: unknown, depth = 0): string | null { + if (depth > 5 || value == null) return null; + if (typeof value === "string") return value; + if (Array.isArray(value)) { + const parts = value + .map((entry) => extractText(entry, depth + 1)) + .filter((entry): entry is string => Boolean(entry?.trim())); + return parts.length ? parts.join("\n") : null; + } + const record = asRecord(value); + if (!record) return null; + for (const key of ["text", "content", "message", "body", "summary", "title", "sessionTitle"]) { + const direct = extractText(record[key], depth + 1); + if (direct?.trim()) return direct; + } + return null; +} + +export function stripAdeGuidance(raw: string): string { + let text = raw + .replace(/[\s\S]*?<\/system-reminder>/giu, " ") + .replace(/\[ADE launch directive\][\s\S]*?(?=\n\n|$)/giu, " ") + .replace(/## ADE CLI primer[\s\S]*?(?=User request:|<\/user_query>|$)/giu, " ") + .replace(/## Project agent rules[\s\S]*?(?=User request:|<\/user_query>|$)/giu, " ") + .replace(/ADE session guidance[\s\S]*?(?=\n\n|$)/giu, " ") + .replace(/# AGENTS\.md instructions[\s\S]*?(?=User request:|<\/user_query>|$)/giu, " "); + + const userRequestIdx = text.lastIndexOf("User request:"); + if (userRequestIdx >= 0) { + text = text.slice(userRequestIdx + "User request:".length); + } + const queryMatch = text.match(/\s*([\s\S]*?)\s*<\/user_query>/iu); + if (queryMatch?.[1]) text = queryMatch[1]; + + return text + .replace(/<\/?[a-z][^>]*>/giu, " ") + .replace(/\s+/gu, " ") + .trim(); +} + +export function clipExternalSessionText( + raw: string | null | undefined, + max = EXTERNAL_SESSION_PREVIEW_MAX_LENGTH, +): string | null { + const stripped = stripAdeGuidance(raw ?? ""); + if (!stripped) return null; + if (stripped.length <= max) return stripped; + return `${stripped.slice(0, Math.max(0, max - 1)).trimEnd()}...`; +} + +export function firstUserTextFromRecords( + records: unknown[], + max = EXTERNAL_SESSION_PREVIEW_MAX_LENGTH, +): string | null { + for (const record of records) { + const obj = asRecord(record); + if (!obj) continue; + const role = (asString(obj.role) + ?? asString(asRecord(obj.message)?.role) + ?? asString(asRecord(obj.payload)?.role) + ?? asString(asRecord(asRecord(obj.payload)?.message)?.role))?.toLowerCase(); + const type = (asString(obj.type) ?? asString(asRecord(obj.payload)?.type))?.toLowerCase(); + const explicitNonUserRole = role === "assistant" || role === "system" || role === "tool"; + const isUser = + role === "user" + || (!explicitNonUserRole && ( + type === "user" + || type === "user_message" + || (!role && type === "message") + )); + if (!isUser) continue; + const payload = asRecord(obj.payload) ?? obj; + const text = + extractText(asRecord(payload.message)?.content) + ?? extractText(payload.message) + ?? extractText(payload.content) + ?? extractText(payload.text) + ?? extractText(payload); + const clipped = clipExternalSessionText(text, max); + if (clipped) return clipped; + } + return null; +} + +export function previewFromRecords( + records: unknown[], + max = EXTERNAL_SESSION_PREVIEW_MAX_LENGTH, +): string | null { + for (const record of records) { + const text = clipExternalSessionText(extractText(record), max); + if (text) return text; + } + return null; +} + +export function claudeProjectSlugForCwd(cwd: string): string { + return cwd.replace(/[^A-Za-z0-9]/gu, "-"); +} + +export function slashEscapedCwd(cwd: string): string { + return cwd.replace(/\//gu, "-"); +} + +export function isUuidLike(value: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu.test(value); +} + +export function commandArrayToLine(parts: string[]): string { + return parts.map((part) => { + if (!part.length) return "''"; + if (/^[A-Za-z0-9_./:@%+=,-]+$/u.test(part)) return part; + return `'${part.replace(/'/gu, "'\\''")}'`; + }).join(" "); +} + +export function directShellLaunchForCommandLine(commandLine: string): { command?: string; args?: string[] } { + const trimmed = commandLine.trim(); + if (!trimmed || process.platform === "win32") return {}; + return { command: "/bin/bash", args: ["--noprofile", "--norc", "-lc", trimmed] }; +} + +export function sortDiscoveryRecords(records: ExternalSessionDiscoveryRecord[], limit: number): ExternalSessionDiscoveryRecord[] { + return records + .slice() + .sort((left, right) => { + const leftUpdated = left.updatedAt ?? left.createdAt ?? 0; + const rightUpdated = right.updatedAt ?? right.createdAt ?? 0; + if (rightUpdated !== leftUpdated) return rightUpdated - leftUpdated; + return `${left.provider}:${left.id}`.localeCompare(`${right.provider}:${right.id}`); + }) + .slice(0, limit); +} + +export function recordWithFile(args: { + provider: ExternalSessionProvider; + id: string; + cwd: string | null; + title?: string | null; + preview?: string | null; + createdAt?: number | null; + updatedAt?: number | null; + messageCount?: number | null; + filePath?: string | null; + sourceMtimeMs?: number | null; +}): ExternalSessionDiscoveryRecord { + const stat = args.filePath ? safeStat(args.filePath) : null; + const sourceMtimeMs = typeof args.sourceMtimeMs === "number" && Number.isFinite(args.sourceMtimeMs) + ? Math.floor(args.sourceMtimeMs) + : stat ? Math.floor(stat.mtimeMs) : null; + return { + provider: args.provider, + id: args.id, + cwd: args.cwd, + title: args.title ?? null, + preview: args.preview ?? null, + createdAt: args.createdAt ?? null, + updatedAt: args.updatedAt ?? sourceMtimeMs, + messageCount: args.messageCount ?? null, + sourcePath: args.filePath ?? null, + sourceMtimeMs, + }; +} + +export function resolveExistingPath(candidate: string): string | null { + try { + return fs.existsSync(candidate) ? fs.realpathSync(candidate) : null; + } catch { + return null; + } +} + +function uniqueNames(names: string[]): string[] { + return names.filter((name, index) => name.length > 0 && names.indexOf(name) === index); +} + +function cursorSlugMixedNames(parts: string[]): string[] { + if (parts.length <= 1 || parts.length > 8) return []; + const separatorSlots = parts.length - 1; + const names: string[] = []; + for (let mask = 0; mask < (1 << separatorSlots); mask += 1) { + let name = parts[0] ?? ""; + for (let index = 1; index < parts.length; index += 1) { + name += ((mask & (1 << (index - 1))) ? "." : "-") + parts[index]; + } + names.push(name); + } + return names; +} + +function cursorSlugSegmentNames(parts: string[]): string[] { + const hyphen = parts.join("-"); + const dotted = parts.join("."); + const mixed = cursorSlugMixedNames(parts); + return uniqueNames([ + hyphen, + dotted, + ...mixed, + `.${hyphen}`, + `.${dotted}`, + ...mixed.map((name) => `.${name}`), + ]); +} + +function greedyCursorSlugCwdCandidate(slug: string): string | null { + const parts = slug.split("-").filter((part) => part.length > 0); + if (!parts.length) return null; + let current = path.parse(path.resolve("/")).root; + let index = 0; + + while (index < parts.length) { + let matched: { dir: string; nextIndex: number } | null = null; + for (let nextIndex = parts.length; nextIndex > index; nextIndex -= 1) { + const segmentParts = parts.slice(index, nextIndex); + for (const name of cursorSlugSegmentNames(segmentParts)) { + const candidate = path.join(current, name); + const stat = safeStat(candidate); + if (stat?.isDirectory()) { + matched = { dir: candidate, nextIndex }; + break; + } + } + if (matched) break; + } + if (!matched) return null; + current = matched.dir; + index = matched.nextIndex; + } + + return current; +} + +export function cursorSlugCwdCandidates(slug: string): string[] { + const candidates: string[] = []; + const add = (candidate: string) => { + if (!candidates.includes(candidate)) candidates.push(candidate); + }; + + const greedy = greedyCursorSlugCwdCandidate(slug); + if (greedy) add(greedy); + + if (slug.startsWith("Users-")) { + const parts = slug.split("-"); + if (parts.length >= 2) { + const username = parts[1]; + const rest = parts.slice(2).join("-"); + if (username && rest) { + const worktreeMarker = "-ade-worktrees-"; + const markerIdx = rest.indexOf(worktreeMarker); + if (markerIdx >= 0) { + const projectPart = rest.slice(0, markerIdx); + const lanePart = rest.slice(markerIdx + worktreeMarker.length); + add(path.join("/Users", username, ...projectPart.split("-"), ".ade", "worktrees", lanePart)); + } + add(path.join("/Users", username, ...rest.split("-"))); + } + } + } + + add(`/${slug.replace(/-/gu, "/")}`); + return candidates; +} + +export function resolveCursorCwdFromSlug(slug: string): string | null { + for (const candidate of cursorSlugCwdCandidates(slug)) { + const resolved = resolveExistingPath(candidate); + if (resolved) return resolved; + } + return null; +} + +export function isPathInside(parent: string, candidate: string): boolean { + const relative = path.relative(parent, candidate); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +export function realishPath(filePath: string): string { + try { + return fs.realpathSync(filePath); + } catch { + return path.resolve(filePath); + } +} + +export function normalizeScopeRoots(scopeRoots: readonly string[] | null | undefined): string[] { + const roots = new Set(); + for (const root of scopeRoots ?? []) { + const clean = root.trim(); + if (clean) roots.add(realishPath(clean)); + } + return Array.from(roots); +} + +function scopeRootPathVariants(scopeRoots: readonly string[] | null | undefined): string[] { + const roots = new Set(); + for (const root of scopeRoots ?? []) { + const clean = root.trim(); + if (!clean) continue; + roots.add(path.resolve(clean)); + roots.add(realishPath(clean)); + } + return Array.from(roots); +} + +export function cwdIsInScope(cwd: string | null | undefined, scopeRoots: readonly string[] | null | undefined): boolean { + const roots = normalizeScopeRoots(scopeRoots); + if (!roots.length) return true; + const clean = cwd?.trim(); + if (!clean) return false; + const resolved = realishPath(clean); + return roots.some((root) => isPathInside(root, resolved)); +} + +export function cwdCandidatesIncludeScope( + candidates: string[], + scopeRoots: readonly string[] | null | undefined, +): boolean { + const roots = normalizeScopeRoots(scopeRoots); + if (!roots.length) return true; + return candidates.some((candidate) => cwdIsInScope(candidate, roots)); +} + +export function slugMatchesScopeRoots( + slug: string, + scopeRoots: readonly string[] | null | undefined, + slugForCwd: (cwd: string) => string, +): boolean { + const roots = scopeRootPathVariants(scopeRoots); + if (!roots.length) return true; + return roots.some((root) => { + const rootSlug = slugForCwd(root); + return slug === rootSlug || slug.startsWith(`${rootSlug}-`); + }); +} diff --git a/apps/desktop/src/main/services/externalSessions/externalSessionsService.test.ts b/apps/desktop/src/main/services/externalSessions/externalSessionsService.test.ts new file mode 100644 index 000000000..7a4126711 --- /dev/null +++ b/apps/desktop/src/main/services/externalSessions/externalSessionsService.test.ts @@ -0,0 +1,866 @@ +import { execFile } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { Writable } from "node:stream"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { PtyCreateArgs, TerminalSessionSummary } from "../../../shared/types"; +import { createExternalSessionsService } from "./externalSessionsService"; +import { transplantClaudeSession } from "./claudeSessionTransplant"; +import { claudeProjectSlugForCwd } from "./discoveryUtils"; + +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, execFile: vi.fn() }; +}); + +const execFileMock = vi.mocked(execFile); +let root: string; + +function writeJsonl(filePath: string, rows: unknown[]): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, rows.map((row) => JSON.stringify(row)).join("\n") + "\n", "utf8"); +} + +function makeLogger() { + return { warn: vi.fn(), info: vi.fn() }; +} + +beforeEach(() => { + execFileMock.mockReset(); + root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-external-service-")); +}); + +afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); +}); + +describe("externalSessionsService", () => { + it("lists sessions with imported flags, active flags, capabilities, and lane cwd matching", async () => { + const homeDir = path.join(root, "home"); + const projectRoot = path.join(root, "repo"); + const laneCwd = path.join(projectRoot, ".ade", "worktrees", "lane-1"); + fs.mkdirSync(laneCwd, { recursive: true }); + const id = "55555555-5555-4555-8555-555555555555"; + const filePath = path.join(homeDir, ".claude", "projects", claudeProjectSlugForCwd(laneCwd), `${id}.jsonl`); + writeJsonl(filePath, [ + { + type: "message", + sessionId: id, + cwd: laneCwd, + timestamp: "2026-07-06T10:00:00.000Z", + message: { role: "user", content: "import me" }, + }, + ]); + fs.utimesSync(filePath, new Date(), new Date()); + + const service = createExternalSessionsService({ + droidForkSupported: true, + projectRoot, + homeDir, + laneService: { getLaneWorktreePath: () => laneCwd }, + sessionService: { + list: () => [ + { + id: "ade-session", + resumeMetadata: { provider: "claude", targetKind: "session", targetId: id, launch: {} }, + } as TerminalSessionSummary, + ], + listClaudeSessionPointers: () => [], + }, + ptyService: { create: vi.fn() }, + logger: makeLogger(), + }); + + const sessions = await service.list({ providers: ["claude"], laneId: "lane-1", scope: "project", limit: 5 }); + + expect(sessions).toHaveLength(1); + expect(sessions[0]).toMatchObject({ + provider: "claude", + id, + cwd: laneCwd, + alreadyImported: true, + importedSessionRef: { kind: "cli", sessionId: "ade-session" }, + possiblyActive: true, + cwdMatchesRequestedLane: true, + capabilities: { + resumeInPlace: true, + resumeInDifferentCwd: false, + fork: true, + forkIntoDifferentCwd: true, + importToChat: true, + }, + }); + }); + + it("returns the existing ADE session ref and prefers Claude chat pointers over CLI rows", async () => { + const homeDir = path.join(root, "home"); + const projectRoot = path.join(root, "repo"); + const laneCwd = path.join(projectRoot, ".ade", "worktrees", "lane-1"); + fs.mkdirSync(laneCwd, { recursive: true }); + const id = "11111111-1111-4111-8111-111111111111"; + writeJsonl(path.join(homeDir, ".claude", "projects", claudeProjectSlugForCwd(laneCwd), `${id}.jsonl`), [ + { + type: "message", + sessionId: id, + cwd: laneCwd, + timestamp: "2026-07-06T10:00:00.000Z", + message: { role: "user", content: "already imported" }, + }, + ]); + + const service = createExternalSessionsService({ + droidForkSupported: true, + projectRoot, + homeDir, + laneService: { getLaneWorktreePath: () => laneCwd }, + sessionService: { + list: () => [ + { + id: "cli-session", + toolType: "claude", + resumeMetadata: { provider: "claude", targetKind: "session", targetId: id, launch: {} }, + } as TerminalSessionSummary, + ], + listClaudeSessionPointers: () => [{ sessionId: id, chatSessionId: "chat-session" }], + }, + ptyService: { create: vi.fn() }, + logger: makeLogger(), + }); + + const sessions = await service.list({ providers: ["claude"], laneId: "lane-1", scope: "project", limit: 5 }); + + expect(sessions).toHaveLength(1); + expect(sessions[0]).toMatchObject({ + alreadyImported: true, + importedSessionRef: { kind: "chat", sessionId: "chat-session" }, + }); + }); + + it("marks chat-imported external sessions as already imported and prefers the chat ref", async () => { + const homeDir = path.join(root, "home"); + const projectRoot = path.join(root, "repo"); + const laneCwd = path.join(projectRoot, ".ade", "worktrees", "lane-1"); + fs.mkdirSync(laneCwd, { recursive: true }); + const id = "22222222-2222-4222-8222-222222222222"; + writeJsonl(path.join(homeDir, ".claude", "projects", claudeProjectSlugForCwd(laneCwd), `${id}.jsonl`), [ + { + type: "message", + sessionId: id, + cwd: laneCwd, + timestamp: "2026-07-06T10:00:00.000Z", + message: { role: "user", content: "imported as chat" }, + }, + ]); + + const service = createExternalSessionsService({ + droidForkSupported: true, + projectRoot, + homeDir, + laneService: { getLaneWorktreePath: () => laneCwd }, + sessionService: { + list: () => [ + { + id: "cli-session", + toolType: "claude", + resumeMetadata: { provider: "claude", targetKind: "session", targetId: id, launch: {} }, + } as TerminalSessionSummary, + ], + listClaudeSessionPointers: () => [], + }, + ptyService: { create: vi.fn() }, + logger: makeLogger(), + chatImportedRefsProvider: () => [ + { provider: "claude", externalId: id, chatSessionId: "chat-import-session" }, + ], + }); + + const sessions = await service.list({ providers: ["claude"], laneId: "lane-1", scope: "project", limit: 5 }); + + expect(sessions).toHaveLength(1); + expect(sessions[0]).toMatchObject({ + alreadyImported: true, + importedSessionRef: { kind: "chat", sessionId: "chat-import-session" }, + }); + }); + + it("fills project-scoped Claude results from in-project sessions beyond the old global cap", async () => { + const homeDir = path.join(root, "home"); + const projectRoot = path.join(root, "repo"); + const laneCwd = path.join(projectRoot, ".ade", "worktrees", "lane-1"); + const otherCwd = path.join(root, "other-repo"); + fs.mkdirSync(laneCwd, { recursive: true }); + fs.mkdirSync(otherCwd, { recursive: true }); + + const claudeProjectsDir = path.join(homeDir, ".claude", "projects"); + const writeSession = (cwd: string, id: string, prompt: string, mtime: Date) => { + const filePath = path.join(claudeProjectsDir, claudeProjectSlugForCwd(cwd), `${id}.jsonl`); + writeJsonl(filePath, [ + { + type: "message", + sessionId: id, + cwd, + timestamp: mtime.toISOString(), + message: { role: "user", content: prompt }, + }, + ]); + fs.utimesSync(filePath, mtime, mtime); + }; + + for (let index = 0; index < 225; index += 1) { + writeSession( + otherCwd, + `10000000-0000-4000-8000-${String(index).padStart(12, "0")}`, + `outside ${index}`, + new Date(Date.UTC(2026, 6, 7, 12, 0, index)), + ); + } + const projectIds = [ + "20000000-0000-4000-8000-000000000001", + "20000000-0000-4000-8000-000000000002", + "20000000-0000-4000-8000-000000000003", + ]; + projectIds.forEach((id, index) => { + writeSession( + laneCwd, + id, + `inside ${index}`, + new Date(Date.UTC(2026, 6, 7, 11, 0, index)), + ); + }); + + const service = createExternalSessionsService({ + droidForkSupported: true, + projectRoot, + homeDir, + laneService: { getLaneWorktreePath: () => laneCwd }, + sessionService: { list: () => [], listClaudeSessionPointers: () => [] }, + ptyService: { create: vi.fn() }, + logger: makeLogger(), + }); + + const sessions = await service.list({ providers: ["claude"], laneId: "lane-1", scope: "project", limit: 5 }); + + expect(sessions.map((session) => session.id)).toEqual(projectIds.slice().reverse()); + expect(sessions.every((session) => session.cwd === laneCwd)).toBe(true); + }); + + it("fills project-scoped Codex results by filtering session metadata before the old global cap", async () => { + const homeDir = path.join(root, "home"); + const projectRoot = path.join(root, "repo"); + const laneCwd = path.join(projectRoot, ".ade", "worktrees", "lane-1"); + const otherCwd = path.join(root, "other-repo"); + fs.mkdirSync(laneCwd, { recursive: true }); + fs.mkdirSync(otherCwd, { recursive: true }); + + const writeCodexSession = (cwd: string, id: string, prompt: string, mtime: Date) => { + const stamp = mtime.toISOString().replace(/[:.]/gu, "-"); + const filePath = path.join(homeDir, ".codex", "sessions", "2026", "07", "07", `rollout-${stamp}-${id}.jsonl`); + writeJsonl(filePath, [ + { + timestamp: mtime.toISOString(), + type: "session_meta", + payload: { id, session_id: id, cwd, timestamp: mtime.toISOString() }, + }, + { + timestamp: mtime.toISOString(), + type: "event_msg", + payload: { type: "message", role: "user", message: { content: prompt } }, + }, + ]); + fs.utimesSync(filePath, mtime, mtime); + }; + + for (let index = 0; index < 225; index += 1) { + writeCodexSession( + otherCwd, + `30000000-0000-4000-8000-${String(index).padStart(12, "0")}`, + `outside codex ${index}`, + new Date(Date.UTC(2026, 6, 7, 12, 0, index)), + ); + } + const projectIds = [ + "40000000-0000-4000-8000-000000000001", + "40000000-0000-4000-8000-000000000002", + "40000000-0000-4000-8000-000000000003", + ]; + projectIds.forEach((id, index) => { + writeCodexSession( + laneCwd, + id, + `inside codex ${index}`, + new Date(Date.UTC(2026, 6, 7, 11, 0, index)), + ); + }); + + const service = createExternalSessionsService({ + droidForkSupported: true, + projectRoot, + homeDir, + laneService: { getLaneWorktreePath: () => laneCwd }, + sessionService: { list: () => [], listClaudeSessionPointers: () => [] }, + ptyService: { create: vi.fn() }, + logger: makeLogger(), + }); + + const sessions = await service.list({ providers: ["codex"], laneId: "lane-1", scope: "project", limit: 5 }); + + expect(sessions.map((session) => session.id)).toEqual(projectIds.slice().reverse()); + expect(sessions.every((session) => session.cwd === laneCwd)).toBe(true); + }); + + it("reports droid fork disabled while the probe is pending and honors the override", async () => { + const homeDir = path.join(root, "home"); + const projectRoot = path.join(root, "repo"); + const laneCwd = path.join(projectRoot, ".ade", "worktrees", "lane-1"); + fs.mkdirSync(laneCwd, { recursive: true }); + const id = "droid-session-1"; + writeJsonl(path.join(homeDir, ".factory", "sessions", "repo", `${id}.jsonl`), [ + { + type: "session_start", + id, + cwd: laneCwd, + timestamp: "2026-07-06T10:00:00.000Z", + }, + ]); + execFileMock.mockImplementation(() => ({ pid: 123 }) as ReturnType); + + const service = createExternalSessionsService({ + projectRoot, + homeDir, + laneService: { getLaneWorktreePath: () => laneCwd }, + sessionService: { list: () => [], listClaudeSessionPointers: () => [] }, + ptyService: { create: vi.fn() }, + logger: makeLogger(), + }); + + const sessions = await service.list({ providers: ["droid"], laneId: "lane-1", scope: "project", limit: 5 }); + + expect(sessions).toHaveLength(1); + expect(sessions[0]!.capabilities).toMatchObject({ fork: false, forkIntoDifferentCwd: false }); + expect(execFileMock).toHaveBeenCalledTimes(1); + + execFileMock.mockClear(); + const overrideService = createExternalSessionsService({ + droidForkSupported: true, + projectRoot, + homeDir, + laneService: { getLaneWorktreePath: () => laneCwd }, + sessionService: { list: () => [], listClaudeSessionPointers: () => [] }, + ptyService: { create: vi.fn() }, + logger: makeLogger(), + }); + + const overrideSessions = await overrideService.list({ providers: ["droid"], laneId: "lane-1", scope: "project", limit: 5 }); + + expect(overrideSessions).toHaveLength(1); + expect(overrideSessions[0]!.capabilities).toMatchObject({ fork: true, forkIntoDifferentCwd: true }); + expect(execFileMock).not.toHaveBeenCalled(); + }); + + it("awaits the droid fork probe before launching fork imports", async () => { + const homeDir = path.join(root, "home"); + const projectRoot = path.join(root, "repo"); + const laneCwd = path.join(projectRoot, ".ade", "worktrees", "lane-1"); + fs.mkdirSync(laneCwd, { recursive: true }); + const id = "droid-session-2"; + writeJsonl(path.join(homeDir, ".factory", "sessions", "repo", `${id}.jsonl`), [ + { + type: "session_start", + id, + cwd: laneCwd, + timestamp: "2026-07-06T10:00:00.000Z", + }, + ]); + execFileMock.mockImplementation((...callArgs: any[]) => { + const callback = callArgs[3] as (error: Error | null, stdout: string, stderr: string) => void; + setTimeout(() => callback(null, "usage: droid --resume --fork", ""), 0); + return { pid: 123 } as ReturnType; + }); + const create = vi.fn(async (_args: PtyCreateArgs) => ({ sessionId: "terminal-droid", ptyId: "pty-droid", pid: 789 })); + const service = createExternalSessionsService({ + projectRoot, + homeDir, + laneService: { getLaneWorktreePath: () => laneCwd }, + sessionService: { list: () => [], listClaudeSessionPointers: () => [] }, + ptyService: { create }, + logger: makeLogger(), + }); + + const result = await service.importExternalSession({ + provider: "droid", + sessionId: id, + laneId: "lane-1", + target: "cli", + mode: "fork", + }); + + expect(result).toEqual({ kind: "cli", sessionId: "terminal-droid", ptyId: "pty-droid", laneId: "lane-1" }); + expect(create).toHaveBeenCalledTimes(1); + expect(create.mock.calls[0]![0].startupCommand).toBe(`droid --fork ${id}`); + }); + + it("rejects droid fork imports when the resolved probe is unsupported", async () => { + const homeDir = path.join(root, "home"); + const projectRoot = path.join(root, "repo"); + const laneCwd = path.join(projectRoot, ".ade", "worktrees", "lane-1"); + fs.mkdirSync(laneCwd, { recursive: true }); + const id = "droid-session-3"; + writeJsonl(path.join(homeDir, ".factory", "sessions", "repo", `${id}.jsonl`), [ + { + type: "session_start", + id, + cwd: laneCwd, + timestamp: "2026-07-06T10:00:00.000Z", + }, + ]); + execFileMock.mockImplementation((...callArgs: any[]) => { + const callback = callArgs[3] as (error: Error | null, stdout: string, stderr: string) => void; + setTimeout(() => callback(null, "usage: droid --resume", ""), 0); + return { pid: 123 } as ReturnType; + }); + const create = vi.fn(); + const service = createExternalSessionsService({ + projectRoot, + homeDir, + laneService: { getLaneWorktreePath: () => laneCwd }, + sessionService: { list: () => [], listClaudeSessionPointers: () => [] }, + ptyService: { create }, + logger: makeLogger(), + }); + + await expect(service.importExternalSession({ + provider: "droid", + sessionId: id, + laneId: "lane-1", + target: "cli", + mode: "fork", + })).rejects.toThrow(/installed droid CLI does not support forking/i); + expect(create).not.toHaveBeenCalled(); + }); + + it("imports a portable Codex session as a tracked CLI PTY in the target lane", async () => { + const homeDir = path.join(root, "home"); + const projectRoot = path.join(root, "repo"); + const laneCwd = path.join(projectRoot, ".ade", "worktrees", "lane-1"); + fs.mkdirSync(laneCwd, { recursive: true }); + const id = "66666666-6666-4666-8666-666666666666"; + writeJsonl(path.join(homeDir, ".codex", "sessions", "2026", "07", "06", `rollout-2026-07-06T10-00-00-${id}.jsonl`), [ + { + timestamp: "2026-07-06T10:00:00.000Z", + type: "session_meta", + payload: { id, cwd: path.join(root, "elsewhere"), timestamp: "2026-07-06T10:00:00.000Z" }, + }, + ]); + const create = vi.fn(async (_args: PtyCreateArgs) => ({ sessionId: "terminal-1", ptyId: "pty-1", pid: 123 })); + const service = createExternalSessionsService({ + droidForkSupported: true, + projectRoot, + homeDir, + laneService: { getLaneWorktreePath: () => laneCwd }, + sessionService: { list: () => [], listClaudeSessionPointers: () => [] }, + ptyService: { create }, + logger: makeLogger(), + }); + + const result = await service.importExternalSession({ + provider: "codex", + sessionId: id, + laneId: "lane-1", + target: "cli", + mode: "resume", + permissionMode: "edit", + }); + + expect(result).toEqual({ kind: "cli", sessionId: "terminal-1", ptyId: "pty-1", laneId: "lane-1" }); + expect(create).toHaveBeenCalledTimes(1); + const args = create.mock.calls[0]![0]; + expect(args.cwd).toBe(fs.realpathSync(laneCwd)); + expect(args.allowExternalCwd).toBe(false); + expect(args.startupCommand).toContain("codex --no-alt-screen"); + expect(args.startupCommand).toContain(`resume ${id}`); + expect(args.resumeMetadata).toMatchObject({ + provider: "codex", + targetKind: "thread", + targetId: id, + importedFrom: { provider: "codex", targetId: id, mode: "resume" }, + }); + }); + + it("forks a same-cwd Claude session with the original id in the launch command", async () => { + const homeDir = path.join(root, "home"); + const projectRoot = path.join(root, "repo"); + const laneCwd = path.join(projectRoot, ".ade", "worktrees", "lane-1"); + fs.mkdirSync(laneCwd, { recursive: true }); + const id = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + writeJsonl(path.join(homeDir, ".claude", "projects", claudeProjectSlugForCwd(laneCwd), `${id}.jsonl`), [ + { + type: "message", + sessionId: id, + cwd: laneCwd, + timestamp: "2026-07-06T10:00:00.000Z", + message: { role: "user", content: "branch this session" }, + }, + ]); + const create = vi.fn(async (_args: PtyCreateArgs) => ({ sessionId: "terminal-2", ptyId: "pty-2", pid: 456 })); + const service = createExternalSessionsService({ + droidForkSupported: true, + projectRoot, + homeDir, + laneService: { getLaneWorktreePath: () => laneCwd }, + sessionService: { list: () => [], listClaudeSessionPointers: () => [] }, + ptyService: { create }, + logger: makeLogger(), + }); + + const result = await service.importExternalSession({ + provider: "claude", + sessionId: id, + laneId: "lane-1", + target: "cli", + mode: "fork", + }); + + expect(result).toEqual({ kind: "cli", sessionId: "terminal-2", ptyId: "pty-2", laneId: "lane-1" }); + const args = create.mock.calls[0]![0]; + expect(args.cwd).toBe(laneCwd); + expect(args.allowExternalCwd).toBe(false); + expect(args.startupCommand).toContain(`--resume ${id}`); + expect(args.startupCommand).toContain("--fork-session"); + expect(args.resumeMetadata).toMatchObject({ + provider: "claude", + targetKind: "session", + targetId: null, + importedFrom: { provider: "claude", targetId: id, mode: "fork" }, + }); + }); + + it("honors CLAUDE_CONFIG_DIR when discovering and transplanting cross-cwd Claude CLI forks", async () => { + const homeDir = path.join(root, "home"); + const claudeConfigDir = path.join(root, "custom-claude"); + const projectRoot = path.join(root, "repo"); + const sourceCwd = path.join(projectRoot, "source"); + const laneCwd = path.join(projectRoot, ".ade", "worktrees", "lane-1"); + fs.mkdirSync(sourceCwd, { recursive: true }); + fs.mkdirSync(laneCwd, { recursive: true }); + const id = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"; + writeJsonl(path.join(claudeConfigDir, "projects", claudeProjectSlugForCwd(sourceCwd), `${id}.jsonl`), [ + { + type: "message", + sessionId: id, + cwd: sourceCwd, + timestamp: "2026-07-06T10:00:00.000Z", + message: { role: "user", content: "fork from custom config" }, + }, + ]); + const create = vi.fn(async (_args: PtyCreateArgs) => ({ sessionId: "terminal-custom-claude", ptyId: "pty-custom-claude", pid: 456 })); + const service = createExternalSessionsService({ + droidForkSupported: true, + projectRoot, + homeDir, + env: { ...process.env, CLAUDE_CONFIG_DIR: claudeConfigDir }, + laneService: { getLaneWorktreePath: () => laneCwd }, + sessionService: { list: () => [], listClaudeSessionPointers: () => [] }, + ptyService: { create }, + logger: makeLogger(), + }); + + await expect(service.importExternalSession({ + provider: "claude", + sessionId: id, + laneId: "lane-1", + target: "cli", + mode: "fork", + })).resolves.toEqual({ kind: "cli", sessionId: "terminal-custom-claude", ptyId: "pty-custom-claude", laneId: "lane-1" }); + + const targetDir = path.join(claudeConfigDir, "projects", claudeProjectSlugForCwd(fs.realpathSync(laneCwd))); + const targetFiles = fs.readdirSync(targetDir).filter((name) => name.endsWith(".jsonl")); + expect(targetFiles).toHaveLength(1); + expect(fs.existsSync(path.join(homeDir, ".claude"))).toBe(false); + expect(create.mock.calls[0]![0].startupCommand).toContain("--resume"); + expect(create.mock.calls[0]![0].startupCommand).not.toContain(id); + }); + + it("throws a clear error when chat import is not wired", async () => { + const projectRoot = path.join(root, "repo"); + const laneCwd = path.join(projectRoot, ".ade", "worktrees", "lane-1"); + fs.mkdirSync(laneCwd, { recursive: true }); + const service = createExternalSessionsService({ + droidForkSupported: true, + projectRoot, + homeDir: path.join(root, "home"), + laneService: { getLaneWorktreePath: () => laneCwd }, + sessionService: { list: () => [], listClaudeSessionPointers: () => [] }, + ptyService: { create: vi.fn() }, + logger: makeLogger(), + }); + + await expect(service.importExternalSession({ + provider: "claude", + sessionId: "77777777-7777-4777-8777-777777777777", + laneId: "lane-1", + target: "chat", + mode: "resume", + })).rejects.toThrow(/chat import unavailable/i); + }); + + it("enforces lane-scoped import source cwd before invoking any import branch", async () => { + const homeDir = path.join(root, "home"); + const projectRoot = path.join(root, "repo"); + const laneCwd = path.join(projectRoot, ".ade", "worktrees", "lane-1"); + const outsideCwd = path.join(root, "outside-project"); + fs.mkdirSync(laneCwd, { recursive: true }); + fs.mkdirSync(outsideCwd, { recursive: true }); + const insideClaudeId = "11111111-1111-4111-8111-111111111111"; + const outsideClaudeId = "22222222-2222-4222-8222-222222222222"; + const insideCodexId = "33333333-3333-4333-8333-333333333333"; + const outsideCodexId = "44444444-4444-4444-8444-444444444444"; + const missingCodexId = "55555555-5555-4555-8555-555555555555"; + writeJsonl(path.join(homeDir, ".claude", "projects", claudeProjectSlugForCwd(laneCwd), `${insideClaudeId}.jsonl`), [ + { type: "message", sessionId: insideClaudeId, cwd: laneCwd, message: { role: "user", content: "inside" } }, + ]); + writeJsonl(path.join(homeDir, ".claude", "projects", claudeProjectSlugForCwd(outsideCwd), `${outsideClaudeId}.jsonl`), [ + { type: "message", sessionId: outsideClaudeId, cwd: outsideCwd, message: { role: "user", content: "outside" } }, + ]); + writeJsonl(path.join(homeDir, ".codex", "sessions", "2026", "07", "06", `rollout-2026-07-06T10-00-00-${insideCodexId}.jsonl`), [ + { + timestamp: "2026-07-06T10:00:00.000Z", + type: "session_meta", + payload: { id: insideCodexId, cwd: laneCwd, timestamp: "2026-07-06T10:00:00.000Z" }, + }, + ]); + writeJsonl(path.join(homeDir, ".codex", "sessions", "2026", "07", "06", `rollout-2026-07-06T10-01-00-${outsideCodexId}.jsonl`), [ + { + timestamp: "2026-07-06T10:01:00.000Z", + type: "session_meta", + payload: { id: outsideCodexId, cwd: outsideCwd, timestamp: "2026-07-06T10:01:00.000Z" }, + }, + ]); + const chatImporter = { + importExternalChatSession: vi.fn(async () => ({ chatSessionId: "chat-import" })), + }; + const create = vi.fn(async (_args: PtyCreateArgs) => ({ sessionId: "terminal-import", ptyId: "pty-import", pid: 456 })); + const service = createExternalSessionsService({ + droidForkSupported: true, + projectRoot, + homeDir, + laneService: { getLaneWorktreePath: () => laneCwd }, + sessionService: { list: () => [], listClaudeSessionPointers: () => [] }, + ptyService: { create }, + logger: makeLogger(), + chatImporter, + }); + + await expect(service.importExternalSession({ + provider: "codex", + sessionId: missingCodexId, + laneId: "lane-1", + target: "cli", + mode: "resume", + enforceLaneScopeCwd: laneCwd, + })).rejects.toThrow(/not permitted/i); + await expect(service.importExternalSession({ + provider: "codex", + sessionId: outsideCodexId, + laneId: "lane-1", + target: "cli", + mode: "resume", + enforceLaneScopeCwd: laneCwd, + })).rejects.toThrow(/not permitted/i); + await expect(service.importExternalSession({ + provider: "claude", + sessionId: outsideClaudeId, + laneId: "lane-1", + target: "cli", + mode: "fork", + enforceLaneScopeCwd: laneCwd, + })).rejects.toThrow(/not permitted/i); + await expect(service.importExternalSession({ + provider: "claude", + sessionId: outsideClaudeId, + laneId: "lane-1", + target: "chat", + mode: "resume", + enforceLaneScopeCwd: laneCwd, + })).rejects.toThrow(/not permitted/i); + expect(create).not.toHaveBeenCalled(); + expect(chatImporter.importExternalChatSession).not.toHaveBeenCalled(); + + await expect(service.importExternalSession({ + provider: "codex", + sessionId: insideCodexId, + laneId: "lane-1", + target: "cli", + mode: "resume", + enforceLaneScopeCwd: laneCwd, + })).resolves.toEqual({ kind: "cli", sessionId: "terminal-import", ptyId: "pty-import", laneId: "lane-1" }); + await expect(service.importExternalSession({ + provider: "claude", + sessionId: insideClaudeId, + laneId: "lane-1", + target: "cli", + mode: "fork", + enforceLaneScopeCwd: laneCwd, + })).resolves.toEqual({ kind: "cli", sessionId: "terminal-import", ptyId: "pty-import", laneId: "lane-1" }); + await expect(service.importExternalSession({ + provider: "claude", + sessionId: insideClaudeId, + laneId: "lane-1", + target: "chat", + mode: "resume", + enforceLaneScopeCwd: laneCwd, + })).resolves.toEqual({ kind: "chat", chatSessionId: "chat-import", laneId: "lane-1" }); + expect(create).toHaveBeenCalledTimes(2); + expect(chatImporter.importExternalChatSession).toHaveBeenCalledWith(expect.objectContaining({ + provider: "claude", + externalSessionId: insideClaudeId, + laneId: "lane-1", + cwd: laneCwd, + fork: false, + })); + }); + + it("rejects invalid external session ids before import", async () => { + const projectRoot = path.join(root, "repo"); + const laneCwd = path.join(projectRoot, ".ade", "worktrees", "lane-1"); + fs.mkdirSync(laneCwd, { recursive: true }); + const create = vi.fn(); + const service = createExternalSessionsService({ + droidForkSupported: true, + projectRoot, + homeDir: path.join(root, "home"), + laneService: { getLaneWorktreePath: () => laneCwd }, + sessionService: { list: () => [], listClaudeSessionPointers: () => [] }, + ptyService: { create }, + logger: makeLogger(), + }); + + await expect(service.importExternalSession({ + provider: "codex", + sessionId: "--help", + laneId: "lane-1", + target: "cli", + mode: "resume", + })).rejects.toThrow(/codex external session id is invalid/i); + await expect(service.importExternalSession({ + provider: "droid", + sessionId: "--help", + laneId: "lane-1", + target: "cli", + mode: "fork", + })).rejects.toThrow(/droid external session id is invalid/i); + await expect(service.importExternalSession({ + provider: "opencode", + sessionId: "ab", + laneId: "lane-1", + target: "cli", + mode: "resume", + })).rejects.toThrow(/opencode external session id is invalid/i); + await expect(service.importExternalSession({ + provider: "cursor", + sessionId: "agent-77777777-7777-4777-8777-777777777777", + laneId: "lane-1", + target: "cli", + mode: "resume", + })).rejects.toThrow(/not resumable/i); + expect(create).not.toHaveBeenCalled(); + }); +}); + +describe("transplantClaudeSession", () => { + it("forks a Claude JSONL into the target cwd under a fresh session id", async () => { + const configDir = path.join(root, "claude"); + const sourceCwd = path.join(root, "source"); + const targetCwd = path.join(root, "target"); + const sessionId = "88888888-8888-4888-8888-888888888888"; + const sourcePath = path.join(configDir, "projects", claudeProjectSlugForCwd(sourceCwd), `${sessionId}.jsonl`); + writeJsonl(sourcePath, [ + { type: "message", sessionId, keep: { nested: true } }, + { type: "summary", sessionId, text: "hello" }, + ]); + + const result = await transplantClaudeSession({ sessionId, sourceCwd, targetCwd, fork: true, configDir }); + + expect(result.newSessionId).not.toBe(sessionId); + expect(fs.existsSync(sourcePath)).toBe(true); + const lines = fs.readFileSync(result.targetPath, "utf8").trim().split(/\r?\n/u).map((line) => JSON.parse(line)); + expect(lines).toEqual([ + { type: "message", sessionId: result.newSessionId, keep: { nested: true } }, + { type: "summary", sessionId: result.newSessionId, text: "hello" }, + ]); + }); + + it("rejects and removes the temp file when the Claude fork rewrite stream fails", async () => { + const configDir = path.join(root, "claude"); + const sourceCwd = path.join(root, "source"); + const targetCwd = path.join(root, "target"); + const sessionId = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"; + const sourcePath = path.join(configDir, "projects", claudeProjectSlugForCwd(sourceCwd), `${sessionId}.jsonl`); + writeJsonl(sourcePath, [ + { type: "message", sessionId, text: "source stays intact" }, + ]); + const sourceBefore = fs.readFileSync(sourcePath, "utf8"); + let tempPath: string | null = null; + const writeFailure = new Error("mock Claude rewrite stream failure"); + const createWriteStream = vi.spyOn(fs, "createWriteStream").mockImplementation((filePath) => { + tempPath = String(filePath); + fs.mkdirSync(path.dirname(tempPath), { recursive: true }); + fs.writeFileSync(tempPath, "partial temp", "utf8"); + return new Writable({ + write(_chunk, _encoding, callback) { + callback(writeFailure); + }, + }) as fs.WriteStream; + }); + + try { + await expect(transplantClaudeSession({ sessionId, sourceCwd, targetCwd, fork: true, configDir })) + .rejects.toThrow("mock Claude rewrite stream failure"); + } finally { + createWriteStream.mockRestore(); + } + + expect(tempPath).toBeTruthy(); + expect(fs.existsSync(tempPath!)).toBe(false); + expect(fs.readFileSync(sourcePath, "utf8")).toBe(sourceBefore); + const targetDir = path.join(configDir, "projects", claudeProjectSlugForCwd(targetCwd)); + const targetFiles = fs.existsSync(targetDir) ? fs.readdirSync(targetDir) : []; + expect(targetFiles.filter((name) => name.endsWith(".tmp"))).toEqual([]); + expect(targetFiles.filter((name) => name.endsWith(".jsonl"))).toEqual([]); + }); + + it("adopt-moves a Claude JSONL without changing its id", async () => { + const configDir = path.join(root, "claude"); + const sourceCwd = path.join(root, "source"); + const targetCwd = path.join(root, "target"); + const sessionId = "99999999-9999-4999-8999-999999999999"; + const sourcePath = path.join(configDir, "projects", claudeProjectSlugForCwd(sourceCwd), `${sessionId}.jsonl`); + writeJsonl(sourcePath, [{ type: "message", sessionId }]); + + const result = await transplantClaudeSession({ sessionId, sourceCwd, targetCwd, fork: false, configDir }); + + expect(result.newSessionId).toBe(sessionId); + expect(fs.existsSync(sourcePath)).toBe(false); + expect(fs.existsSync(result.targetPath)).toBe(true); + expect(result.targetPath).toContain(claudeProjectSlugForCwd(targetCwd)); + }); + + it("rejects adopt-moving onto an existing Claude target without clobbering it", async () => { + const configDir = path.join(root, "claude"); + const sourceCwd = path.join(root, "source"); + const targetCwd = path.join(root, "target"); + const sessionId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; + const sourcePath = path.join(configDir, "projects", claudeProjectSlugForCwd(sourceCwd), `${sessionId}.jsonl`); + const targetPath = path.join(configDir, "projects", claudeProjectSlugForCwd(targetCwd), `${sessionId}.jsonl`); + writeJsonl(sourcePath, [{ type: "message", sessionId, text: "source transcript" }]); + writeJsonl(targetPath, [{ type: "message", sessionId, text: "existing transcript" }]); + const sourceBefore = fs.readFileSync(sourcePath, "utf8"); + const targetBefore = fs.readFileSync(targetPath, "utf8"); + + await expect(transplantClaudeSession({ sessionId, sourceCwd, targetCwd, fork: false, configDir })) + .rejects.toThrow(`Claude target session already exists at ${targetPath}.`); + + expect(fs.readFileSync(sourcePath, "utf8")).toBe(sourceBefore); + expect(fs.readFileSync(targetPath, "utf8")).toBe(targetBefore); + }); +}); diff --git a/apps/desktop/src/main/services/externalSessions/externalSessionsService.ts b/apps/desktop/src/main/services/externalSessions/externalSessionsService.ts new file mode 100644 index 000000000..392f2a072 --- /dev/null +++ b/apps/desktop/src/main/services/externalSessions/externalSessionsService.ts @@ -0,0 +1,628 @@ +import { execFile } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { + buildTrackedCliResumeCommand, + withCodexNoAltScreen, +} from "../../../shared/cliLaunch"; +import type { + ExternalSessionCapabilities, + ExternalSessionImportArgs, + ExternalSessionImportResult, + ExternalSessionListArgs, + ExternalSessionProvider, + ExternalSessionSummary, + PtyCreateArgs, + PtyCreateResult, + TerminalResumeMetadata, + TerminalSessionSummary, + TerminalToolType, +} from "../../../shared/types"; +import { transplantClaudeSession } from "./claudeSessionTransplant"; +import { claudeConfigDir, discoverClaudeSessions } from "./discoverClaude"; +import { discoverCodexSessions } from "./discoverCodex"; +import { discoverCursorSessions } from "./discoverCursor"; +import { discoverDroidSessions } from "./discoverDroid"; +import { discoverOpenCodeSessions } from "./discoverOpenCode"; +import { + commandArrayToLine, + directShellLaunchForCommandLine, + isPathInside, + normalizeExternalSessionLimit, + sortDiscoveryRecords, + type ExternalSessionDiscoveryArgs, + type ExternalSessionDiscoveryRecord, +} from "./discoveryUtils"; + +export interface ExternalChatImporter { + importExternalChatSession(args: { + provider: "claude" | "codex"; + externalSessionId: string; + laneId: string; + cwd: string | null; + fork: boolean; + title?: string; + }): Promise<{ chatSessionId: string }>; +} + +export type ImportedChatSessionRef = { + provider: string; + externalId: string; + chatSessionId: string; +}; + +type ChatImportedRefsProvider = () => + | ImportedChatSessionRef[] + | Promise; + +type LoggerLike = { + warn: (message: string, fields?: Record) => void; + info?: (message: string, fields?: Record) => void; +}; + +type LaneServiceLike = { + getLaneWorktreePath?: (laneId: string) => string; + getLaneBaseAndBranch?: (laneId: string) => { worktreePath?: string | null }; +}; + +type SessionServiceLike = { + list: (args: { limit: number | null }) => TerminalSessionSummary[]; + listClaudeSessionPointers?: (args?: { laneId?: string; limit?: number }) => Array<{ + sessionId: string; + chatSessionId?: string | null; + }>; +}; + +type PtyServiceLike = { + create: (args: PtyCreateArgs) => Promise; +}; + +type ExternalSessionsServiceArgs = { + projectRoot: string; + laneService: LaneServiceLike; + sessionService: SessionServiceLike; + ptyService: PtyServiceLike; + logger: LoggerLike; + chatImporter?: ExternalChatImporter | null; + chatImportedRefsProvider?: ChatImportedRefsProvider | null; + homeDir?: string; + env?: NodeJS.ProcessEnv; + /** Overrides the installed-droid `--fork` probe (tests / callers that already know). */ + droidForkSupported?: boolean; +}; + +type LaneScopedExternalSessionImportArgs = ExternalSessionImportArgs & { + enforceLaneScopeCwd?: string | null; +}; + +const PROVIDERS: ExternalSessionProvider[] = ["claude", "codex", "cursor", "droid", "opencode"]; +const UUIDISH_EXTERNAL_SESSION_ID = /^[0-9a-fA-F-]{8,64}$/u; +const CLI_EXTERNAL_SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{2,127}$/u; +const PROJECT_SCOPE_DISCOVERY_LIMIT = 200; + +const PROVIDER_CAPABILITIES: Record = { + claude: { + resumeInPlace: true, + resumeInDifferentCwd: false, + fork: true, + forkIntoDifferentCwd: true, + importToChat: true, + }, + codex: { + resumeInPlace: true, + resumeInDifferentCwd: true, + fork: true, + forkIntoDifferentCwd: true, + importToChat: true, + }, + cursor: { + resumeInPlace: true, + resumeInDifferentCwd: false, + fork: false, + forkIntoDifferentCwd: false, + importToChat: false, + }, + droid: { + resumeInPlace: true, + resumeInDifferentCwd: false, + fork: true, + forkIntoDifferentCwd: true, + importToChat: false, + }, + opencode: { + resumeInPlace: true, + resumeInDifferentCwd: false, + fork: true, + forkIntoDifferentCwd: false, + importToChat: false, + }, +}; + +type ImportedSessionRef = NonNullable; + +const CHAT_SESSION_TOOL_TYPES = new Set([ + "codex-chat", + "claude-chat", + "opencode-chat", + "cursor", + "droid-chat", +]); + +function isChatToolType(toolType: TerminalToolType | null | undefined): boolean { + return toolType != null && CHAT_SESSION_TOOL_TYPES.has(toolType); +} + +function providerSet(raw: ExternalSessionProvider[] | undefined): ExternalSessionProvider[] { + if (!Array.isArray(raw) || raw.length === 0) return PROVIDERS; + const allowed = new Set(PROVIDERS); + return Array.from(new Set(raw.filter((provider): provider is ExternalSessionProvider => allowed.has(provider)))); +} + +function realish(filePath: string): string { + try { + return fs.realpathSync(filePath); + } catch { + return path.resolve(filePath); + } +} + +function deriveProjectScopeRoots(projectRoot: string): string[] { + const roots = new Set(); + const raw = path.resolve(projectRoot); + const resolved = realish(projectRoot); + roots.add(raw); + roots.add(resolved); + const marker = `${path.sep}.ade${path.sep}worktrees${path.sep}`; + for (const root of [raw, resolved]) { + const markerIdx = root.indexOf(marker); + if (markerIdx >= 0) { + roots.add(root.slice(0, markerIdx)); + } + } + return Array.from(roots); +} + +function resolveLaneCwd(laneService: LaneServiceLike, laneId: string): string { + const clean = laneId.trim(); + if (!clean) throw new Error("laneId is required."); + const direct = laneService.getLaneWorktreePath?.(clean); + const worktreePath = direct ?? laneService.getLaneBaseAndBranch?.(clean)?.worktreePath ?? null; + if (!worktreePath?.trim()) throw new Error(`Lane '${clean}' has no worktree configured.`); + return realish(worktreePath); +} + +function cwdMatches(cwd: string | null, requestedCwd: string | null): boolean | null { + if (!requestedCwd) return null; + if (!cwd) return null; + return realish(cwd) === realish(requestedCwd); +} + +function isInProjectScope(cwd: string | null, scopeRoots: string[]): boolean { + if (!cwd) return false; + const resolved = realish(cwd); + return scopeRoots.some((root) => isPathInside(root, resolved)); +} + +function refRank(ref: ImportedSessionRef | null): number { + if (ref?.kind === "chat") return 2; + if (ref?.kind === "cli") return 1; + return 0; +} + +function putImportedRef( + refs: Map, + key: string | null | undefined, + ref: ImportedSessionRef | null, +): void { + const cleanKey = key?.trim(); + if (!cleanKey) return; + if (!refs.has(cleanKey) || refRank(ref) > refRank(refs.get(cleanKey) ?? null)) { + refs.set(cleanKey, ref); + } +} + +async function importedSessionRefs( + sessionService: SessionServiceLike, + chatImportedRefsProvider: ChatImportedRefsProvider | null | undefined, + logger: LoggerLike, +): Promise> { + const refs = new Map(); + for (const session of sessionService.list({ limit: null })) { + const metadata = session.resumeMetadata as (TerminalResumeMetadata & { + importedFrom?: { provider?: ExternalSessionProvider; targetId?: string | null }; + }) | null | undefined; + const ref: ImportedSessionRef = { + kind: isChatToolType(session.toolType) ? "chat" : "cli", + sessionId: session.id, + }; + if (metadata?.provider && metadata.targetId) { + putImportedRef(refs, `${metadata.provider}:${metadata.targetId}`, ref); + } + if (metadata?.importedFrom?.provider && metadata.importedFrom.targetId) { + putImportedRef(refs, `${metadata.importedFrom.provider}:${metadata.importedFrom.targetId}`, ref); + } + } + for (const pointer of sessionService.listClaudeSessionPointers?.({ limit: 5000 }) ?? []) { + const sessionId = pointer.sessionId?.trim(); + if (!sessionId) continue; + const chatSessionId = pointer.chatSessionId?.trim(); + putImportedRef( + refs, + `claude:${sessionId}`, + chatSessionId ? { kind: "chat", sessionId: chatSessionId } : null, + ); + } + if (chatImportedRefsProvider) { + try { + for (const chatRef of await chatImportedRefsProvider()) { + const provider = chatRef.provider.trim(); + const externalId = chatRef.externalId.trim(); + const chatSessionId = chatRef.chatSessionId.trim(); + if (!provider || !externalId || !chatSessionId) continue; + putImportedRef(refs, `${provider}:${externalId}`, { kind: "chat", sessionId: chatSessionId }); + } + } catch (error) { + logger.warn("external_sessions.chat_import_refs_failed", { + error: error instanceof Error ? error.message : String(error), + }); + } + } + return refs; +} + +function toolTypeForProvider(provider: ExternalSessionProvider): TerminalToolType { + if (provider === "cursor") return "cursor-cli"; + return provider; +} + +export function validateExternalSessionId(provider: ExternalSessionProvider, id: string): string { + const trimmed = id.trim(); + if (provider === "cursor" && trimmed.startsWith("agent-")) { + throw new Error( + "cursor external session id is not resumable by cursor-agent; refusing to import SDK-origin transcript.", + ); + } + const pattern = provider === "claude" || provider === "codex" + ? UUIDISH_EXTERNAL_SESSION_ID + : CLI_EXTERNAL_SESSION_ID; + if (!pattern.test(trimmed)) { + throw new Error( + `${provider} external session id is invalid; refusing to import '${trimmed || "(empty)"}'.`, + ); + } + return trimmed; +} + +function targetKindForProvider(provider: ExternalSessionProvider): TerminalResumeMetadata["targetKind"] { + return provider === "codex" ? "thread" : "session"; +} + +function metadataForImport(args: { + provider: ExternalSessionProvider; + targetId: string | null; + originalTargetId: string; + mode: "resume" | "fork"; + model?: string | null; + permissionMode?: string | null; +}): TerminalResumeMetadata { + const launch = { + ...(args.model ? { model: args.model } : {}), + ...(args.permissionMode ? { permissionMode: args.permissionMode as TerminalResumeMetadata["launch"]["permissionMode"] } : {}), + }; + return { + provider: args.provider, + targetKind: targetKindForProvider(args.provider), + targetId: args.targetId, + launch, + importedFrom: { + provider: args.provider, + targetId: args.originalTargetId, + mode: args.mode, + importedAt: new Date().toISOString(), + }, + } as TerminalResumeMetadata; +} + +function forkCommandFor(args: { + provider: ExternalSessionProvider; + metadata: TerminalResumeMetadata; + targetId: string; + model?: string | null; + permissionMode?: string | null; + transplantedClaude: boolean; +}): string { + if (args.provider === "claude") { + const command = buildTrackedCliResumeCommand( + { ...args.metadata, targetId: args.targetId }, + { + model: args.model, + permissionMode: args.permissionMode as TerminalResumeMetadata["launch"]["permissionMode"], + }, + ); + return args.transplantedClaude ? command : `${command} --fork-session`; + } + + if (args.provider === "codex") { + const resume = buildTrackedCliResumeCommand( + { ...args.metadata, targetId: args.targetId }, + { + model: args.model, + permissionMode: args.permissionMode as TerminalResumeMetadata["launch"]["permissionMode"], + }, + ); + return withCodexNoAltScreen(resume.replace(/\bresume\b/u, "fork")); + } + + if (args.provider === "droid") { + return commandArrayToLine(["droid", "--fork", validateExternalSessionId("droid", args.targetId)]); + } + + if (args.provider === "opencode") { + const resume = buildTrackedCliResumeCommand( + { ...args.metadata, targetId: args.targetId }, + { + model: args.model, + permissionMode: args.permissionMode as TerminalResumeMetadata["launch"]["permissionMode"], + }, + ); + return `${resume} --fork`; + } + + throw new Error("Cursor sessions cannot be forked."); +} + +export function createExternalSessionsService(args: ExternalSessionsServiceArgs) { + // Factory's docs describe `droid --fork`, but installed CLIs predating it only + // expose `--resume`; offering fork against such a binary launches a failing command. + let droidForkProbe: boolean | null = typeof args.droidForkSupported === "boolean" ? args.droidForkSupported : null; + let droidForkProbePromise: Promise | null = null; + const startDroidForkProbe = (): Promise => { + if (droidForkProbe !== null) return Promise.resolve(droidForkProbe); + if (droidForkProbePromise) return droidForkProbePromise; + droidForkProbePromise = new Promise((resolve) => { + execFile("droid", ["--help"], { + timeout: 1500, + encoding: "utf8", + env: args.env ?? process.env, + }, (_error, stdout, stderr) => { + droidForkProbe = /(^|\s)--fork\b/u.test(`${stdout ?? ""}\n${stderr ?? ""}`); + resolve(droidForkProbe); + }); + }); + return droidForkProbePromise; + }; + const droidForkAvailable = (): boolean => { + if (droidForkProbe !== null) return droidForkProbe; + void startDroidForkProbe(); + return false; + }; + const resolveDroidForkAvailable = async (): Promise => { + if (droidForkProbe !== null) return droidForkProbe; + return startDroidForkProbe(); + }; + void startDroidForkProbe(); + const capabilitiesFor = (provider: ExternalSessionProvider): ExternalSessionCapabilities => { + if (provider !== "droid") return PROVIDER_CAPABILITIES[provider]; + const fork = droidForkAvailable(); + return { ...PROVIDER_CAPABILITIES.droid, fork, forkIntoDifferentCwd: fork }; + }; + + const discoverByProvider: Record Promise> = { + claude: discoverClaudeSessions, + codex: discoverCodexSessions, + cursor: discoverCursorSessions, + droid: discoverDroidSessions, + opencode: discoverOpenCodeSessions, + }; + + const list = async (rawArgs: ExternalSessionListArgs = {}): Promise => { + const limit = normalizeExternalSessionLimit(rawArgs.limit); + const projectScoped = rawArgs.scope !== "all"; + const discoveryLimit = projectScoped + ? Math.max(limit, PROJECT_SCOPE_DISCOVERY_LIMIT) + : limit; + const providers = providerSet(rawArgs.providers); + const requestedLaneCwd = rawArgs.laneId ? resolveLaneCwd(args.laneService, rawArgs.laneId) : null; + const requestedCwd = rawArgs.cwd?.trim() ? realish(rawArgs.cwd) : requestedLaneCwd; + const scopeRoots = projectScoped ? deriveProjectScopeRoots(args.projectRoot) : []; + const discoveryArgs: ExternalSessionDiscoveryArgs = { + homeDir: args.homeDir, + env: args.env, + cwd: requestedCwd, + projectRoot: args.projectRoot, + limit: discoveryLimit, + scopeRoots: projectScoped ? scopeRoots : null, + logger: args.logger, + }; + + const settled = await Promise.all(providers.map(async (provider) => { + try { + return await discoverByProvider[provider](discoveryArgs); + } catch (error) { + args.logger.warn("external_sessions.discovery_failed", { + provider, + error: error instanceof Error ? error.message : String(error), + }); + return [] as ExternalSessionDiscoveryRecord[]; + } + })); + + const imported = await importedSessionRefs(args.sessionService, args.chatImportedRefsProvider, args.logger); + const activeCutoffMs = Date.now() - 2 * 60_000; + const discovered = sortDiscoveryRecords(settled.flat(), discoveryLimit * providers.length); + const scoped = projectScoped + ? discovered.filter((session) => isInProjectScope(session.cwd, scopeRoots)) + : discovered; + + return scoped + .map((session): ExternalSessionSummary => { + const importKey = `${session.provider}:${session.id}`; + return { + provider: session.provider, + id: session.id, + cwd: session.cwd, + title: session.title, + preview: session.preview, + createdAt: session.createdAt, + updatedAt: session.updatedAt, + messageCount: session.messageCount, + alreadyImported: imported.has(importKey), + importedSessionRef: imported.get(importKey) ?? null, + possiblyActive: typeof session.sourceMtimeMs === "number" && session.sourceMtimeMs >= activeCutoffMs, + cwdMatchesRequestedLane: cwdMatches(session.cwd, requestedCwd), + capabilities: capabilitiesFor(session.provider), + }; + }) + .sort((left, right) => (right.updatedAt ?? 0) - (left.updatedAt ?? 0)) + .slice(0, projectScoped ? limit : scoped.length); + }; + + const findExternalSummary = async (provider: ExternalSessionProvider, sessionId: string): Promise => { + const sessions = await list({ providers: [provider], scope: "all", limit: MAX_FIND_LIMIT }); + return sessions.find((session) => session.id === sessionId) ?? null; + }; + + const importExternalSession = async ( + importArgs: LaneScopedExternalSessionImportArgs, + ): Promise => { + const provider = importArgs.provider; + if (!PROVIDERS.includes(provider)) throw new Error(`Unsupported external session provider '${provider}'.`); + const sessionId = validateExternalSessionId(provider, importArgs.sessionId); + const laneId = importArgs.laneId.trim(); + const laneCwd = resolveLaneCwd(args.laneService, laneId); + const summary = await findExternalSummary(provider, sessionId); + const sourceCwd = summary?.cwd ?? null; + const enforceLaneScopeCwd = importArgs.enforceLaneScopeCwd?.trim(); + if (enforceLaneScopeCwd) { + const scopeRoot = realish(enforceLaneScopeCwd); + if (!sourceCwd || !isPathInside(scopeRoot, realish(sourceCwd))) { + throw new Error("External session import is not permitted for this lane."); + } + } + + if (importArgs.target === "chat") { + if (provider !== "claude" && provider !== "codex") { + throw new Error(`Chat import is only available for Claude and Codex sessions, not ${provider}.`); + } + const importer = args.chatImporter; + if (!importer) throw new Error("Chat import unavailable: external chat importer is not configured."); + const result = await importer.importExternalChatSession({ + provider, + externalSessionId: sessionId, + laneId, + cwd: sourceCwd, + fork: importArgs.mode === "fork", + ...(summary?.title ? { title: summary.title } : {}), + }); + return { kind: "chat", chatSessionId: result.chatSessionId, laneId }; + } + + if (provider === "cursor" && importArgs.mode === "fork") { + throw new Error("Cursor external sessions do not support fork import."); + } + + let runCwd = laneCwd; + let launchTargetId = sessionId; + let metadataTargetId: string | null = sessionId; + let transplantedClaude = false; + + if (importArgs.mode === "resume") { + if (provider === "codex") { + runCwd = laneCwd; + } else { + if (!sourceCwd) throw new Error(`${provider} session '${sessionId}' has no recoverable cwd and cannot be resumed in place.`); + runCwd = sourceCwd; + } + } else { + const canFork = provider === "droid" ? await resolveDroidForkAvailable() : capabilitiesFor(provider).fork; + if (!canFork) { + throw new Error(provider === "droid" + ? "The installed droid CLI does not support forking (--fork unavailable) — resume the session in its original folder or update droid." + : `${provider} sessions do not support fork import.`); + } + if (provider === "claude") { + if (!sourceCwd) throw new Error("Claude fork import requires the source session cwd."); + if (realish(sourceCwd) !== realish(laneCwd)) { + const transplant = await transplantClaudeSession({ + sessionId, + sourceCwd, + targetCwd: laneCwd, + fork: true, + configDir: claudeConfigDir({ env: args.env, homeDir: args.homeDir }), + }); + launchTargetId = transplant.newSessionId; + metadataTargetId = transplant.newSessionId; + transplantedClaude = true; + runCwd = laneCwd; + } else { + metadataTargetId = null; + runCwd = sourceCwd; + } + } else if (provider === "droid" || provider === "codex") { + metadataTargetId = null; + runCwd = laneCwd; + } else if (provider === "opencode") { + if (!sourceCwd) throw new Error("OpenCode fork import requires the source session cwd."); + metadataTargetId = null; + runCwd = sourceCwd; + } + } + + const metadata = metadataForImport({ + provider, + targetId: metadataTargetId, + originalTargetId: sessionId, + mode: importArgs.mode, + model: importArgs.model, + permissionMode: importArgs.permissionMode, + }); + const startupCommand = importArgs.mode === "resume" + ? buildTrackedCliResumeCommand(metadata, { + model: importArgs.model, + permissionMode: importArgs.permissionMode as TerminalResumeMetadata["launch"]["permissionMode"], + }) + : forkCommandFor({ + provider, + metadata, + targetId: launchTargetId, + model: importArgs.model, + permissionMode: importArgs.permissionMode, + transplantedClaude, + }); + + const ptyArgs: PtyCreateArgs = { + laneId, + cwd: runCwd, + allowExternalCwd: realish(runCwd) !== realish(laneCwd), + cols: 120, + rows: 36, + title: summary?.title ?? `${provider} ${importArgs.mode} ${sessionId.slice(0, 8)}`, + tracked: true, + toolType: toolTypeForProvider(provider), + startupCommand, + resumeMetadata: metadata, + ...directShellLaunchForCommandLine(startupCommand), + }; + + const created = await args.ptyService.create(ptyArgs); + args.logger.info?.("external_sessions.imported_cli", { + provider, + sessionId, + laneId, + mode: importArgs.mode, + ptyId: created.ptyId, + adeSessionId: created.sessionId, + }); + return { kind: "cli", sessionId: created.sessionId, ptyId: created.ptyId, laneId }; + }; + + return { + list, + importExternalSession, + import: importExternalSession, + }; +} + +const MAX_FIND_LIMIT = 500; + +export type ExternalSessionsService = ReturnType; diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index e4996de80..83fc5829f 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -488,6 +488,10 @@ import type { CursorCloudOpenChatResult, CursorCloudStreamRunResult, UpdateInstallImpact, + ExternalSessionListArgs, + ExternalSessionImportArgs, + ExternalSessionImportResult, + ExternalSessionSummary, } from "../../../shared/types"; import type { Logger } from "../logging/logger"; import type { AdeDb } from "../state/kvDb"; @@ -532,6 +536,7 @@ import type { createQueueLandingService } from "../prs/queueLandingService"; import type { createPrSummaryService } from "../prs/prSummaryService"; import type { createReviewService } from "../review/reviewService"; import type { createSearchService } from "../search/searchService"; +import type { createExternalSessionsService } from "../externalSessions/externalSessionsService"; import type { createAgentChatService } from "../chat/agentChatService"; import type { createComputerUseArtifactBrokerService } from "../computerUse/computerUseArtifactBrokerService"; import { buildComputerUseOwnerSnapshot } from "../computerUse/controlPlane"; @@ -883,6 +888,7 @@ export type AppContext = { prSummaryService: ReturnType | null; reviewService: ReturnType | null; searchService?: ReturnType | null; + externalSessionsService?: ReturnType | null; jobEngine: ReturnType | null; automationService: ReturnType | null; automationPlannerService: ReturnType | null; @@ -4747,6 +4753,51 @@ export function registerIpc({ return entries; }); + const normalizeExternalSessionListArgs = (arg: unknown): ExternalSessionListArgs => { + const record = isRecord(arg) ? arg as Record : {}; + return { + ...(Array.isArray(record.providers) ? { providers: record.providers as ExternalSessionListArgs["providers"] } : {}), + ...(typeof record.laneId === "string" || record.laneId === null ? { laneId: record.laneId } : {}), + ...(typeof record.cwd === "string" || record.cwd === null ? { cwd: record.cwd } : {}), + ...(record.scope === "all" || record.scope === "project" ? { scope: record.scope } : {}), + ...(typeof record.limit === "number" ? { limit: record.limit } : {}), + }; + }; + + const normalizeExternalSessionImportArgs = (arg: unknown): ExternalSessionImportArgs => { + if (!isRecord(arg)) throw new Error("external session import expects an object payload."); + const record = arg as Record; + const { provider, target, mode } = record; + if (provider !== "claude" && provider !== "codex" && provider !== "cursor" && provider !== "droid" && provider !== "opencode") { + throw new Error("external session import provider is invalid."); + } + if (target !== "cli" && target !== "chat") throw new Error("external session import target must be cli or chat."); + if (mode !== "resume" && mode !== "fork") throw new Error("external session import mode must be resume or fork."); + if (typeof record.sessionId !== "string") throw new Error("external session import sessionId must be a string."); + if (typeof record.laneId !== "string") throw new Error("external session import laneId must be a string."); + return { + provider, + sessionId: record.sessionId, + laneId: record.laneId, + target, + mode, + ...(typeof record.model === "string" ? { model: record.model } : {}), + ...(typeof record.permissionMode === "string" ? { permissionMode: record.permissionMode } : {}), + }; + }; + + ipcMain.handle(IPC.externalSessionsList, async (_event, arg: unknown): Promise => { + const ctx = getCtx(); + requireAppContextServices(ctx, ["externalSessionsService"]); + return ctx.externalSessionsService.list(normalizeExternalSessionListArgs(arg)); + }); + + ipcMain.handle(IPC.externalSessionsImport, async (_event, arg: unknown): Promise => { + const ctx = getCtx(); + requireAppContextServices(ctx, ["externalSessionsService"]); + return ctx.externalSessionsService.importExternalSession(normalizeExternalSessionImportArgs(arg)); + }); + // ── Usage tracking + budget cap IPC ────────────────────────── ipcMain.handle(IPC.usageGetAdeStats, async (_event, arg: GetAdeUsageStatsArgs | undefined): Promise => { diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index 6697ead5e..13f6a9b91 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -3587,11 +3587,15 @@ export function createPtyService({ const toolTypeHint = normalizeToolType(args.toolType ?? existingSession?.toolType ?? null); const requestedStartupCommand = typeof args.startupCommand === "string" ? args.startupCommand.trim() : ""; const requestedInitialInput = typeof args.initialInput === "string" ? args.initialInput : ""; - let initialResumeCommand = existingSession?.resumeCommand ?? defaultResumeCommandForTool(toolTypeHint); - let initialResumeMetadata = existingSession?.resumeMetadata ?? buildInitialResumeMetadata({ - toolType: toolTypeHint, - startupCommand: requestedStartupCommand, - }); + const requestedResumeMetadata = args.resumeMetadata ?? null; + let initialResumeMetadata = existingSession?.resumeMetadata + ?? requestedResumeMetadata + ?? buildInitialResumeMetadata({ + toolType: toolTypeHint, + startupCommand: requestedStartupCommand, + }); + let initialResumeCommand = existingSession?.resumeCommand + ?? (requestedResumeMetadata ? buildTrackedCliResumeCommand(requestedResumeMetadata) : defaultResumeCommandForTool(toolTypeHint)); const transcriptPath = tracked ? (existingSession?.transcriptPath?.trim() || safeTranscriptPathFor(sessionId)) : ""; diff --git a/apps/desktop/src/main/services/sessions/sessionService.ts b/apps/desktop/src/main/services/sessions/sessionService.ts index 578de9a6c..f973dbc3c 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.ts @@ -138,6 +138,19 @@ function normalizeResumeMetadata(raw: unknown): TerminalResumeMetadata | null { const codexApprovalPolicy = typeof launchRecord.codexApprovalPolicy === "string" ? launchRecord.codexApprovalPolicy : null; const codexSandbox = typeof launchRecord.codexSandbox === "string" ? launchRecord.codexSandbox : null; const codexConfigSource = typeof launchRecord.codexConfigSource === "string" ? launchRecord.codexConfigSource : null; + const importedFromRecord = record.importedFrom != null && typeof record.importedFrom === "object" && !Array.isArray(record.importedFrom) + ? record.importedFrom as Record + : null; + const importedFromProvider = isResumeProvider(importedFromRecord?.provider) + ? importedFromRecord.provider + : null; + const importedFromTargetId = sanitizeResumeTargetId( + typeof importedFromRecord?.targetId === "string" ? importedFromRecord.targetId : null, + ); + const importedFromMode = importedFromRecord?.mode === "fork" ? "fork" : importedFromRecord?.mode === "resume" ? "resume" : null; + const importedFromAt = typeof importedFromRecord?.importedAt === "string" && importedFromRecord.importedAt.trim().length + ? importedFromRecord.importedAt.trim() + : null; if (!provider || !targetKind) return null; return { provider, @@ -152,6 +165,16 @@ function normalizeResumeMetadata(raw: unknown): TerminalResumeMetadata | null { ...(codexSandbox ? { codexSandbox: codexSandbox as TerminalResumeMetadata["launch"]["codexSandbox"] } : {}), ...(codexConfigSource ? { codexConfigSource: codexConfigSource as TerminalResumeMetadata["launch"]["codexConfigSource"] } : {}), }, + ...(importedFromProvider && importedFromTargetId && importedFromMode + ? { + importedFrom: { + provider: importedFromProvider, + targetId: importedFromTargetId, + mode: importedFromMode, + ...(importedFromAt ? { importedAt: importedFromAt } : {}), + }, + } + : {}), ...(legacyTarget ? { target: legacyTarget } : {}), ...(permissionMode ? { permissionMode } : {}), }; diff --git a/apps/desktop/src/main/utils/terminalSessionSignals.test.ts b/apps/desktop/src/main/utils/terminalSessionSignals.test.ts index e51361cce..294bac2b4 100644 --- a/apps/desktop/src/main/utils/terminalSessionSignals.test.ts +++ b/apps/desktop/src/main/utils/terminalSessionSignals.test.ts @@ -113,6 +113,9 @@ describe("terminalSessionSignals", () => { permissionMode: "plan", model: "auto", }); + expect(parseTrackedCliLaunchConfig("cursor-agent --mode ask", "cursor-cli")).toEqual({ + permissionMode: "plan", + }); expect(parseTrackedCliLaunchConfig( "claude --permission-mode default --model claude-opus-4-8 --settings '{\"fastMode\":true}'", "claude", @@ -192,7 +195,7 @@ describe("terminalSessionSignals", () => { targetKind: "session", targetId: "chat-1", launch: { permissionMode: "edit" }, - })).toBe("cursor-agent --mode ask --model auto --resume chat-1"); + })).toBe("cursor-agent --model auto --resume chat-1"); expect(buildTrackedCliResumeCommand({ provider: "cursor", diff --git a/apps/desktop/src/main/utils/terminalSessionSignals.ts b/apps/desktop/src/main/utils/terminalSessionSignals.ts index 1ab49013c..f8c0027b7 100644 --- a/apps/desktop/src/main/utils/terminalSessionSignals.ts +++ b/apps/desktop/src/main/utils/terminalSessionSignals.ts @@ -201,7 +201,6 @@ function permissionModeToCodexFlags(permissionMode: AgentChatPermissionMode | nu function permissionModeToCursorFlags(permissionMode: AgentChatPermissionMode | null | undefined): string[] { if (permissionMode === "full-auto") return ["--force"]; if (permissionMode === "plan") return ["--mode", "plan"]; - if (permissionMode === "edit") return ["--mode", "ask"]; return []; } @@ -376,7 +375,7 @@ function extractTrackedCliPermissionMode(command: string, provider: TerminalResu if (provider === "cursor") { if (normalized.includes("--force") || normalized.includes("--yolo")) return "full-auto"; if (normalized.includes("--mode plan") || normalized.includes("--plan")) return "plan"; - if (normalized.includes("--mode ask")) return "edit"; + if (normalized.includes("--mode ask")) return "plan"; return "default"; } diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 29fcb4c58..ffc143382 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -78,6 +78,10 @@ import type { FilesWatchArgs, FilesWorkspace, FilesWriteTextArgs, + ExternalSessionImportArgs, + ExternalSessionImportResult, + ExternalSessionListArgs, + ExternalSessionSummary, GetLaneConflictStatusArgs, GetDiffChangesArgs, GetFileDiffArgs, @@ -1577,6 +1581,10 @@ declare global { indexStatus: () => Promise; rebuildIndex: () => Promise; }; + externalSessions: { + list: (args?: ExternalSessionListArgs) => Promise; + import: (args: ExternalSessionImportArgs) => Promise; + }; pty: { create: (args: PtyCreateArgs, pin?: OpenProjectBinding | null) => Promise; resumeSession: ( diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 5f9db7833..05081bf01 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -166,6 +166,10 @@ import type { FilesWatchArgs, FilesWorkspace, FilesWriteTextArgs, + ExternalSessionImportArgs, + ExternalSessionImportResult, + ExternalSessionListArgs, + ExternalSessionSummary, GitActionResult, GitCherryPickArgs, GitCommitArgs, @@ -6093,6 +6097,28 @@ contextBridge.exposeInMainWorld("ade", { return { started: false }; }, }, + externalSessions: { + list: async (args: ExternalSessionListArgs = {}): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "external-sessions", + "list", + { args: { ...args } }, + ); + return runtime.handled + ? runtime.result ?? [] + : ipcRenderer.invoke(IPC.externalSessionsList, args); + }, + import: async (args: ExternalSessionImportArgs): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "external-sessions", + "import", + { args: { ...args } }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.externalSessionsImport, args); + }, + }, pty: { create: async (args: PtyCreateArgs, pin?: OpenProjectBinding | null): Promise => { if (pin) { diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 39317b166..9fb7ef76c 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -4953,6 +4953,10 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { }, }), }, + externalSessions: { + list: async () => [], + import: async () => ({ kind: "cli" as const, sessionId: "mock-session", ptyId: "mock", laneId: "mock-lane" }), + }, pty: { create: resolvedArg({ ptyId: "mock", diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index e3c58d9d1..2565e33a7 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { AnimatePresence, motion } from "motion/react"; -import { CircleNotch, Cube, Desktop, DeviceMobile, ArrowBendUpRight, Lightning, Plus, Terminal, TreeStructure, X } from "@phosphor-icons/react"; +import { CircleNotch, Cube, Desktop, DeviceMobile, ArrowBendUpRight, DownloadSimple, Lightning, Plus, Terminal, TreeStructure, X } from "@phosphor-icons/react"; import { inferAttachmentType, mergeAttachments, @@ -99,6 +99,13 @@ import { shouldRefreshSessionListForChatEvent, } from "../../lib/chatSessionEvents"; import { SmartTooltip } from "../ui/SmartTooltip"; +import { ImportSessionBrowser } from "../terminals/importSessions/ImportSessionBrowser"; +import { + readImportedFrom, + providerDisplayName as externalProviderDisplayName, + type ExternalSessionImportResult, + type ExternalSessionSummary, +} from "../terminals/importSessions/contract"; import { CHAT_SHELL_HEADER_CLASS, ChatSurfaceShell } from "./ChatSurfaceShell"; import { OrchestratorLeadFrame } from "./OrchestratorLeadFrame"; import { OrchestrationPanel } from "../orchestration/OrchestrationPanel"; @@ -2770,6 +2777,8 @@ export function AgentChatPane({ orchestratorEnabled = false, onLaunchCliSession, onOpenShellSession, + onImportedSession, + onOpenExistingImportedSession, availableLanes, onLaneChange, onToggleSessionsPane, @@ -2825,6 +2834,16 @@ export function AgentChatPane({ orchestratorEnabled?: boolean; onLaunchCliSession?: (args: WorkPtyLaunchArgs) => Promise; onOpenShellSession?: (laneId: string) => void | Promise; + /** + * Work draft surface: route the result of importing an external CLI session. + * Presence of this callback is what enables the "Import session" affordance. + */ + onImportedSession?: ( + summary: ExternalSessionSummary, + result: ExternalSessionImportResult, + ) => void; + /** Work draft surface: focus an already-imported ADE session instead of re-importing. */ + onOpenExistingImportedSession?: (ref: { kind: "chat" | "cli"; sessionId: string }) => void; /** Available lanes for the lane selector in empty state (full `LaneSummary` includes `branchRef` for branch sublines in the menu). */ availableLanes?: Array<{ id: string; name: string; color?: string | null; branchRef?: string | null; laneType?: string | null }>; /** Callback when lane selection changes in empty state */ @@ -3067,6 +3086,7 @@ export function AgentChatPane({ } | null>(null); const [busy, setBusy] = useState(false); const [shellLaunchBusy, setShellLaunchBusy] = useState(false); + const [importBrowserOpen, setImportBrowserOpen] = useState(false); const [loading, setLoading] = useState(false); const [preferencesReady, setPreferencesReady] = useState(false); const [error, setError] = useState(null); @@ -4487,19 +4507,23 @@ export function AgentChatPane({ : messagePlaceholder; const chipsJson = JSON.stringify(presentation?.chips ?? []); const resolvedChips = useMemo(() => JSON.parse(chipsJson) as ChatSurfaceChip[], [chipsJson]); + const selectedSessionImportedProvider = readImportedFrom(selectedSession)?.provider ?? null; const headerChips = useMemo(() => { + const chips = [...resolvedChips]; + if (selectedSessionImportedProvider) { + chips.push({ + label: `Imported · ${externalProviderDisplayName(selectedSessionImportedProvider)}`, + tone: "muted", + }); + } const hasServiceTier = selectedSession?.provider === "codex" && Object.prototype.hasOwnProperty.call(selectedSession, "codexServiceTier"); - if (!hasServiceTier) return resolvedChips; - const serviceTier = selectedSession?.codexServiceTier?.trim().toLowerCase() || "default"; - return [ - ...resolvedChips, - { - label: `Tier: ${serviceTier}`, - tone: serviceTier === "fast" ? "info" : "muted", - }, - ]; - }, [resolvedChips, selectedSession?.codexServiceTier, selectedSession?.provider]); + if (hasServiceTier) { + const serviceTier = selectedSession?.codexServiceTier?.trim().toLowerCase() || "default"; + chips.push({ label: `Tier: ${serviceTier}`, tone: serviceTier === "fast" ? "info" : "muted" }); + } + return chips; + }, [resolvedChips, selectedSession?.codexServiceTier, selectedSession?.provider, selectedSessionImportedProvider]); // Keep configured models selectable unless a caller explicitly constrains // this surface. Unconstrained sessions keep their active model visible even @@ -10751,34 +10775,60 @@ export function AgentChatPane({ className="flex justify-center" exit={{ opacity: 0, transition: { duration: 0.15 } }} > -
-
- - {onOpenShellSession ? ( - - + + ) : null} + {onImportedSession ? ( + - - - - ) : null} -
+ + + ) : null} +
+ ) : null} {draftLaunchTargetIsAutoCreate && autoCreateToolsLane ? (
Tools use {autoCreateToolsLane.name} until the lane is created. @@ -10838,6 +10888,18 @@ export function AgentChatPane({ onConfirm={confirmRewindDialog} /> + {onImportedSession && laneId ? ( + lane.id === laneId)?.name ?? laneDisplayLabel ?? laneId + } + onImported={onImportedSession} + onOpenExisting={onOpenExistingImportedSession} + /> + ) : null} ); } diff --git a/apps/desktop/src/renderer/components/lanes/LaneDialogShell.tsx b/apps/desktop/src/renderer/components/lanes/LaneDialogShell.tsx index b58824a55..0c6b4afea 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneDialogShell.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneDialogShell.tsx @@ -42,9 +42,16 @@ export function LaneDialogShell({ className={`fixed left-1/2 top-1/2 z-50 flex ${maxHeight} ${height} ${width} -translate-x-1/2 -translate-y-1/2 overflow-hidden focus:outline-none`} onCloseAutoFocus={onCloseAutoFocus} > - +
diff --git a/apps/desktop/src/renderer/components/terminals/SessionCard.tsx b/apps/desktop/src/renderer/components/terminals/SessionCard.tsx index e75fff5e9..b80cb9b9f 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionCard.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionCard.tsx @@ -22,6 +22,7 @@ import { useSessionDelta } from "./useSessionDelta"; import { cn } from "../ui/cn"; import { MONO_FONT } from "../lanes/laneDesignTokens"; import { ToolLogo } from "./ToolLogos"; +import { readImportedFrom, providerDisplayName } from "./importSessions/contract"; import { ClaudeCacheTtlBadge } from "../shared/ClaudeCacheTtlBadge"; import { shouldShowClaudeCacheTtl } from "../../lib/claudeCacheTtl"; @@ -222,6 +223,7 @@ export const SessionCard = React.memo(function SessionCard({ return () => clearTimeout(timer); }, [primaryText]); const staleAgeHours = getStaleRunningCliSessionAgeHours(session); + const importedFrom = readImportedFrom(session); const isHighlighted = isSelected || isMultiSelected; const highlightedBorder = isHighlighted ? "1px solid rgba(255,255,255,0.08)" @@ -314,6 +316,18 @@ export const SessionCard = React.memo(function SessionCard({ {attentionBadge ? : null}
+ {importedFrom ? ( + + Imported + + ) : null} {session.orchestrationRole ? ( <> {orchestrationLabel} diff --git a/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx b/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx index 08f89a5bb..e477e2add 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx @@ -889,6 +889,8 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { onCloseItem={work.closeTab} onOpenChatSession={handleOpenChatSession} onLaunchPtySession={work.launchPtySession} + onImportedSession={work.adoptImportedSession} + onOpenExistingImportedSession={work.openExistingImportedSession} onDraftLaneChange={work.setDraftLaneId} onShowDraftKind={work.showDraftKind} closingPtyIds={work.closingPtyIds} @@ -931,6 +933,8 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { work.setActiveItemId, work.closeTab, work.launchPtySession, + work.adoptImportedSession, + work.openExistingImportedSession, work.closingPtyIds, work.filtered.length, work.workFocusSessionsHidden, diff --git a/apps/desktop/src/renderer/components/terminals/WorkStartSurface.tsx b/apps/desktop/src/renderer/components/terminals/WorkStartSurface.tsx index 97dd5dbfa..c1d460571 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkStartSurface.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkStartSurface.tsx @@ -9,6 +9,7 @@ import type { WorkDraftKind } from "../../state/appStore"; import { useAppStore } from "../../state/appStore"; import { AgentChatPane, type AgentChatSessionCreatedOptions } from "../chat/AgentChatPane"; import type { WorkPtyLaunchArgs, WorkPtyLaunchResult } from "./cliLaunch"; +import type { ExternalSessionImportResult, ExternalSessionSummary } from "./importSessions/contract"; type WorkStartSurfaceProps = { draftKind: WorkDraftKind; @@ -18,6 +19,8 @@ type WorkStartSurfaceProps = { lanes: LaneSummary[]; onOpenChatSession: (session: AgentChatSession, options?: AgentChatSessionCreatedOptions) => void | Promise; onLaunchPtySession: (args: WorkPtyLaunchArgs) => Promise; + onImportedSession?: (summary: ExternalSessionSummary, result: ExternalSessionImportResult) => void; + onOpenExistingImportedSession?: (ref: { kind: "chat" | "cli"; sessionId: string }) => void; onDraftLaneChange?: (laneId: string) => void; initialLinearIssueContext?: LaneLinearIssue | null; initialLinearIssueContextSource?: "manual" | "lane_link"; @@ -34,6 +37,8 @@ export function WorkStartSurface({ lanes, onOpenChatSession, onLaunchPtySession, + onImportedSession, + onOpenExistingImportedSession, onDraftLaneChange, initialLinearIssueContext = null, initialLinearIssueContextSource = "lane_link", @@ -142,6 +147,8 @@ export function WorkStartSurface({ onInitialLinearIssueContextConsumed={onInitialLinearIssueContextConsumed} onSessionCreated={onOpenChatSession} onLaunchCliSession={onLaunchPtySession} + onImportedSession={onImportedSession} + onOpenExistingImportedSession={onOpenExistingImportedSession} onOpenShellSession={launchShell} availableLanes={lanes} onLaneChange={setLaneAndSync} diff --git a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx index e28d7ce24..33348a279 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx @@ -45,6 +45,7 @@ import { isChatToolType, primarySessionLabel, stripTerminalLabelControls, format import { SmartTooltip } from "../ui/SmartTooltip"; import { cn } from "../ui/cn"; import { launchProfileForTerminalSession, type WorkPtyLaunchArgs, type WorkPtyLaunchResult } from "./cliLaunch"; +import type { ExternalSessionImportResult, ExternalSessionSummary } from "./importSessions/contract"; import { useWorkLaneContextMenu } from "./useWorkLaneContextMenu"; import { copyLaunchPromptToClipboard } from "../../lib/launchPromptClipboard"; @@ -991,6 +992,8 @@ export function WorkViewArea({ onCloseItem: _onCloseItem, onOpenChatSession, onLaunchPtySession, + onImportedSession, + onOpenExistingImportedSession, onDraftLaneChange, onShowDraftKind, closingPtyIds, @@ -1030,6 +1033,8 @@ export function WorkViewArea({ onCloseItem: (sessionId: string) => void; onOpenChatSession: (session: AgentChatSession, options?: AgentChatSessionCreatedOptions) => void | Promise; onLaunchPtySession: (args: WorkPtyLaunchArgs) => Promise; + onImportedSession?: (summary: ExternalSessionSummary, result: ExternalSessionImportResult) => void; + onOpenExistingImportedSession?: (ref: { kind: "chat" | "cli"; sessionId: string }) => void; onDraftLaneChange?: (laneId: string) => void; onShowDraftKind: (kind: WorkDraftKind) => void; closingPtyIds: Set; @@ -1167,6 +1172,8 @@ export function WorkViewArea({ lanes={lanes} onOpenChatSession={onOpenChatSession} onLaunchPtySession={onLaunchPtySession} + onImportedSession={onImportedSession} + onOpenExistingImportedSession={onOpenExistingImportedSession} onDraftLaneChange={onDraftLaneChange} initialLinearIssueContext={initialLinearIssueContext} initialLinearIssueContextSource={initialLinearIssueContextSource} diff --git a/apps/desktop/src/renderer/components/terminals/cliLaunch.test.ts b/apps/desktop/src/renderer/components/terminals/cliLaunch.test.ts index 62e8b5b13..86579acef 100644 --- a/apps/desktop/src/renderer/components/terminals/cliLaunch.test.ts +++ b/apps/desktop/src/renderer/components/terminals/cliLaunch.test.ts @@ -494,6 +494,13 @@ describe("buildTrackedCliStartupCommand", () => { expect(launch.initialInputDelayMs).toBeUndefined(); }); + it("launches Cursor edit mode through the default edit-capable agent mode", () => { + const launch = buildTrackedCliLaunchCommand({ provider: "cursor", permissionMode: "edit", model: "cursor-fast" }); + expect(launch.command).toBe("cursor-agent"); + expect(launch.args).toEqual(["--model", "cursor-fast"]); + expect(launch.startupCommand).toBe("cursor-agent --model cursor-fast"); + }); + it("normalizes Cursor registry model ids and forces full-auto interactive workspaces", () => { const launch = buildTrackedCliLaunchCommand({ provider: "cursor", @@ -707,6 +714,13 @@ describe("tracked CLI resume helpers", () => { launch: { permissionMode: "edit" }, })).toBe("codex --no-alt-screen --sandbox workspace-write --ask-for-approval untrusted resume thread-99"); + expect(buildTrackedCliResumeCommand({ + provider: "cursor", + targetKind: "session", + targetId: "chat-99", + launch: { permissionMode: "edit" }, + })).toBe("cursor-agent --model auto --resume chat-99"); + expect(buildTrackedCliResumeCommand({ provider: "cursor", targetKind: "session", diff --git a/apps/desktop/src/renderer/components/terminals/importSessions/ImportSessionBrowser.tsx b/apps/desktop/src/renderer/components/terminals/importSessions/ImportSessionBrowser.tsx new file mode 100644 index 000000000..82983b51f --- /dev/null +++ b/apps/desktop/src/renderer/components/terminals/importSessions/ImportSessionBrowser.tsx @@ -0,0 +1,633 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + ArrowClockwise, + CaretRight, + CircleNotch, + DownloadSimple, + MagnifyingGlass, + Warning, +} from "@phosphor-icons/react"; +import { relativeWhen } from "../../../lib/format"; +import { cn } from "../../ui/cn"; + +/** Backend timestamps arrive as epoch millis (nullable); format defensively. */ +function formatUpdatedAt(ms: number | null | undefined): string { + if (ms == null || !Number.isFinite(ms)) return ""; + return relativeWhen(new Date(ms).toISOString()); +} + +/** Last path segment of a cwd (e.g. the repo/folder name), null when missing. */ +function lastPathSegment(cwd: string | null | undefined): string | null { + if (!cwd) return null; + const segments = cwd.split("/").filter(Boolean); + return segments.length ? (segments[segments.length - 1] ?? null) : null; +} + +/** + * Heading fallback for sessions the provider couldn't title: the folder name + * (or a shortened path) plus the relative time, so the row is still + * identifiable and never collapses onto the preview snippet. + */ +function fallbackHeading(summary: ExternalSessionSummary): string { + const where = lastPathSegment(summary.cwd) ?? shortenCwd(summary.cwd); + const when = formatUpdatedAt(summary.updatedAt); + return when ? `${where} · ${when}` : where; +} +import { LaneDialogShell } from "../../lanes/LaneDialogShell"; +import { SmartTooltip } from "../../ui/SmartTooltip"; +import { ToolLogo } from "../ToolLogos"; +import { + getExternalSessionsApi, + normalizeListResult, + providerDisplayName, + PROVIDER_TOOL_TYPE, + type ExternalSessionImportResult, + type ExternalSessionProvider, + type ExternalSessionSummary, +} from "./contract"; +import { + importAffordancesFor, + shortenCwd, + type ImportAffordance, +} from "./affordances"; + +const PROVIDER_FILTERS: Array<{ id: ExternalSessionProvider | "all"; label: string }> = [ + { id: "all", label: "All" }, + { id: "claude", label: "Claude" }, + { id: "codex", label: "Codex" }, + { id: "cursor", label: "Cursor" }, + { id: "droid", label: "Droid" }, + { id: "opencode", label: "OpenCode" }, +]; + +/** Every provider we scan when no specific filter is applied. */ +const ALL_PROVIDERS: ExternalSessionProvider[] = ["claude", "codex", "cursor", "droid", "opencode"]; + +type ProviderFilter = ExternalSessionProvider | "all"; + +/** + * Reference to an already-imported ADE session, populated by the backend on + * summaries whose external session has been imported before. Read defensively: + * the field is being added to {@link ExternalSessionSummary} concurrently and + * may not be declared on the type yet. + */ +type ImportedSessionRef = { kind: "chat" | "cli"; sessionId: string }; + +function readImportedSessionRef(summary: ExternalSessionSummary): ImportedSessionRef | null { + const raw = (summary as { importedSessionRef?: unknown }).importedSessionRef; + if (!raw || typeof raw !== "object") return null; + const kind = (raw as { kind?: unknown }).kind; + const sessionId = (raw as { sessionId?: unknown }).sessionId; + if ((kind !== "chat" && kind !== "cli") || typeof sessionId !== "string" || !sessionId) { + return null; + } + return { kind, sessionId }; +} + +/** Merge freshly-resolved rows into the running list, de-duped by provider+id. */ +function mergeSessions( + prev: ExternalSessionSummary[], + rows: ExternalSessionSummary[], +): ExternalSessionSummary[] { + const byKey = new Map(prev.map((s) => [`${s.provider}:${s.id}`, s])); + for (const row of rows) byKey.set(`${row.provider}:${row.id}`, row); + return Array.from(byKey.values()); +} + +export type ImportSessionBrowserProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + laneId: string; + laneName: string; + onImported: (summary: ExternalSessionSummary, result: ExternalSessionImportResult) => void; + /** Navigate to an already-imported ADE session instead of re-importing it. */ + onOpenExisting?: (ref: ImportedSessionRef) => void; +}; + +export function ImportSessionBrowser({ + open, + onOpenChange, + laneId, + onImported, + onOpenExisting, +}: ImportSessionBrowserProps) { + const [sessions, setSessions] = useState([]); + const [pendingProviders, setPendingProviders] = useState([]); + const [loadError, setLoadError] = useState(null); + const [providerFilter, setProviderFilter] = useState("all"); + const [query, setQuery] = useState(""); + const [showAllFolders, setShowAllFolders] = useState(false); + const [importing, setImporting] = useState(null); + const [importError, setImportError] = useState(null); + const [activeIndex, setActiveIndex] = useState(0); + const requestSeq = useRef(0); + + const scope: "project" | "all" = showAllFolders ? "all" : "project"; + const loading = pendingProviders.length > 0; + + // Progressive scan: fire one list() per provider in parallel and stream each + // provider's rows into the list as it resolves, so results appear as they + // come in instead of blocking on the slowest provider. A specific provider + // filter narrows the scan to just that provider. + const load = useCallback(async () => { + const api = getExternalSessionsApi(); + if (!api) { + setLoadError("Importing sessions isn't available in this window."); + setSessions([]); + setPendingProviders([]); + return; + } + const seq = ++requestSeq.current; + const providers = providerFilter === "all" ? ALL_PROVIDERS : [providerFilter]; + setSessions([]); + setLoadError(null); + setPendingProviders(providers); + let failures = 0; + await Promise.all( + providers.map(async (provider) => { + try { + const result = await api.list({ providers: [provider], scope, laneId }); + if (seq !== requestSeq.current) return; + setSessions((prev) => mergeSessions(prev, normalizeListResult(result))); + } catch { + if (seq !== requestSeq.current) return; + failures += 1; + } finally { + if (seq === requestSeq.current) { + setPendingProviders((prev) => prev.filter((p) => p !== provider)); + } + } + }), + ); + if (seq !== requestSeq.current) return; + // Only surface a blocking error when the entire scan failed; a single + // provider throwing shouldn't hide the others' results. + if (failures === providers.length) { + setLoadError("Couldn't load external sessions."); + } + }, [laneId, scope, providerFilter]); + + // One-shot load whenever the browser opens or the scope changes; no polling. + useEffect(() => { + if (!open) return; + void load(); + }, [open, load]); + + // Reset transient state each time the browser is opened. + useEffect(() => { + if (!open) return; + setImporting(null); + setImportError(null); + setActiveIndex(0); + }, [open]); + + const visible = useMemo(() => { + const q = query.trim().toLowerCase(); + return sessions + .filter((s) => (providerFilter === "all" ? true : s.provider === providerFilter)) + .filter((s) => + q + ? (s.title ?? "").toLowerCase().includes(q) || (s.preview ?? "").toLowerCase().includes(q) + : true, + ) + .slice() + .sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)); + }, [sessions, providerFilter, query]); + + useEffect(() => { + setActiveIndex((idx) => Math.min(idx, Math.max(0, visible.length - 1))); + }, [visible.length]); + + const runImport = useCallback( + async (summary: ExternalSessionSummary, affordance: ImportAffordance) => { + if (!affordance.enabled || importing) return; + const api = getExternalSessionsApi(); + if (!api) { + setImportError("Importing sessions isn't available in this window."); + return; + } + const key = `${summary.id}:${affordance.kind}`; + setImporting(key); + setImportError(null); + try { + const result = await api.import({ + provider: summary.provider, + sessionId: summary.id, + laneId, + target: affordance.target, + mode: affordance.mode, + }); + onImported(summary, result); + onOpenChange(false); + } catch (err) { + setImportError( + err instanceof Error ? err.message : `Couldn't ${affordance.label.toLowerCase()}.`, + ); + } finally { + setImporting(null); + } + }, + [importing, laneId, onImported, onOpenChange], + ); + + const handleOpenExisting = useCallback( + (ref: ImportedSessionRef) => { + onOpenExisting?.(ref); + onOpenChange(false); + }, + [onOpenExisting, onOpenChange], + ); + + const onListKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (!visible.length) return; + if (event.key === "ArrowDown") { + event.preventDefault(); + setActiveIndex((idx) => Math.min(idx + 1, visible.length - 1)); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + setActiveIndex((idx) => Math.max(idx - 1, 0)); + } else if (event.key === "Enter") { + const summary = visible[activeIndex]; + if (!summary) return; + const primary = importAffordancesFor(summary).find((a) => a.enabled); + if (primary) { + event.preventDefault(); + void runImport(summary, primary); + } + } + }, + [activeIndex, runImport, visible], + ); + + return ( + +
+ {/* Filters */} +
+
+ {PROVIDER_FILTERS.map((filter) => { + const selected = providerFilter === filter.id; + const isAll = filter.id === "all"; + return ( + + ); + })} +
+ {loading && sessions.length ? ( + + + Scanning {pendingProviders.map(providerDisplayName).join(", ")}… + + ) : null} + + + +
+
+
+
+ + setQuery(e.target.value)} + placeholder="Search by title" + className="h-8 w-full rounded-lg border border-white/[0.06] bg-white/[0.02] pl-8 pr-3 text-[12px] text-fg placeholder:text-muted-fg/50 focus:border-white/[0.14] focus:outline-none" + /> +
+ +
+
+ + {importError ? ( +
+ + {importError} +
+ ) : null} + + {/* List */} +
+ {loading && !sessions.length ? ( + } title="Scanning sessions" /> + ) : loadError ? ( + } + title="Couldn't load sessions" + detail={loadError} + action={ + + } + /> + ) : !visible.length ? ( + } + title="No external sessions found" + detail={ + showAllFolders + ? "No Claude, Codex, Cursor, Droid, or OpenCode sessions on this machine." + : "No sessions from this project's folder. Try showing sessions from other folders." + } + /> + ) : ( +
    + {visible.map((summary, index) => ( + setActiveIndex(index)} + onImport={(aff) => void runImport(summary, aff)} + onOpenExisting={onOpenExisting ? handleOpenExisting : undefined} + /> + ))} +
+ )} +
+
+
+ ); +} + +function CenterState({ + icon, + title, + detail, + action, +}: { + icon: React.ReactNode; + title: string; + detail?: string; + action?: React.ReactNode; +}) { + return ( +
+
{icon}
+
{title}
+ {detail ?
{detail}
: null} + {action} +
+ ); +} + +function ImportSessionRow({ + summary, + active, + importingKey, + onActivate, + onImport, + onOpenExisting, +}: { + summary: ExternalSessionSummary; + active: boolean; + importingKey: string | null; + onActivate: () => void; + onImport: (affordance: ImportAffordance) => void; + onOpenExisting?: (ref: ImportedSessionRef) => void; +}) { + const [previewOpen, setPreviewOpen] = useState(false); + const affordances = useMemo(() => importAffordancesFor(summary), [summary]); + // When the backend knows this external session was already imported, offer a + // single "Open in ADE" that jumps to the existing session rather than the + // per-target "Open as …" re-import actions; the fork actions still stand so + // the user can branch a fresh copy. + const importedRef = + summary.alreadyImported && onOpenExisting ? readImportedSessionRef(summary) : null; + // One obvious default per row: the hero chat action if present, otherwise the + // first runnable action (the most-likely CLI path for chat-less providers). + // For an imported row the "Open in ADE" button is the primary, so no import + // affordance should also render as primary. + const primaryKind = useMemo( + () => + importedRef + ? null + : (affordances.find((a) => a.hero) ?? affordances.find((a) => a.enabled))?.kind ?? null, + [affordances, importedRef], + ); + const chatActions = affordances.filter((a) => a.target === "chat"); + const cliActions = affordances.filter((a) => a.target === "cli"); + // For imported rows we drop the "Open" (resume) actions in favor of the single + // "Open in ADE" and keep only the fork actions. + const forkChatActions = chatActions.filter((a) => a.mode === "fork"); + const forkCliActions = cliActions.filter((a) => a.mode === "fork"); + + // Real provider title when there is one; otherwise a path+time fallback so the + // heading is always distinct from the preview snippet. + const title = summary.title?.trim(); + const hasTitle = Boolean(title); + const heading = hasTitle ? (title as string) : fallbackHeading(summary); + const preview = summary.preview?.trim(); + + // Labels are self-evident (the 2×2 {ADE chat | CLI session} × {Open | Fork}), + // so buttons carry no hover-only tooltip — meaning stays visible. + const renderAction = (aff: ImportAffordance) => { + const busy = importingKey === `${summary.id}:${aff.kind}`; + const isPrimary = aff.enabled && aff.kind === primaryKind; + return ( + + ); + }; + + return ( +
  • +
    + +
    +
    + {heading} + {summary.alreadyImported ? ( + + Imported + + ) : null} + {summary.possiblyActive ? ( + + May be open elsewhere + + ) : null} +
    +
    + {providerDisplayName(summary.provider)} + {formatUpdatedAt(summary.updatedAt) ? ( + <> + · + {formatUpdatedAt(summary.updatedAt)} + + ) : null} + {summary.messageCount != null ? ( + <> + · + + {summary.messageCount} msg{summary.messageCount === 1 ? "" : "s"} + + + ) : null} + {/* The path-fallback heading already carries the folder, so only add + the cwd chip when a real title occupies the heading. */} + {hasTitle && summary.cwd ? ( + + {summary.cwd} + + ) : null} +
    + + {preview ? ( +
    + + {previewOpen ? ( +
    + {preview} +
    + ) : null} +
    + ) : null} + + {/* Actions — grouped "open as chat" vs "continue as terminal" so the + chat/terminal and continue/fork distinctions read at a glance. For + already-imported rows a single "Open in ADE" replaces the re-import + "Open" actions, with the fork actions still available alongside. */} + {importedRef ? ( +
    + + {forkChatActions.length || forkCliActions.length ? ( + + ) : null} + {forkChatActions.length ? ( +
    {forkChatActions.map(renderAction)}
    + ) : null} + {forkCliActions.length ? ( +
    {forkCliActions.map(renderAction)}
    + ) : null} +
    + ) : ( +
    + {chatActions.length ? ( +
    {chatActions.map(renderAction)}
    + ) : null} + {chatActions.length && cliActions.length ? ( + + ) : null} + {cliActions.length ? ( +
    {cliActions.map(renderAction)}
    + ) : null} +
    + )} +
    +
    +
  • + ); +} diff --git a/apps/desktop/src/renderer/components/terminals/importSessions/affordances.test.ts b/apps/desktop/src/renderer/components/terminals/importSessions/affordances.test.ts new file mode 100644 index 000000000..7ef6fba5f --- /dev/null +++ b/apps/desktop/src/renderer/components/terminals/importSessions/affordances.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, it } from "vitest"; +import { importAffordancesFor, shortenCwd } from "./affordances"; +import type { ExternalSessionCapabilities, ExternalSessionSummary } from "./contract"; + +const NO_CAPS: ExternalSessionCapabilities = { + resumeInPlace: false, + resumeInDifferentCwd: false, + fork: false, + forkIntoDifferentCwd: false, + importToChat: false, +}; + +function session( + overrides: Partial> & { + capabilities?: Partial; + } = {}, +): ExternalSessionSummary { + const { capabilities, ...rest } = overrides; + return { + provider: "claude", + id: "s1", + cwd: "/Users/dev/project", + title: "Fix login", + preview: "…", + createdAt: Date.parse("2026-07-01T00:00:00.000Z"), + updatedAt: Date.parse("2026-07-01T00:00:00.000Z"), + messageCount: 12, + alreadyImported: false, + possiblyActive: false, + cwdMatchesRequestedLane: true, + ...rest, + capabilities: { ...NO_CAPS, ...capabilities }, + }; +} + +function kinds(summary: ExternalSessionSummary) { + return importAffordancesFor(summary).map((a) => a.kind); +} + +describe("importAffordancesFor", () => { + it("cwd matches: offers resume-here + fork when both caps present", () => { + const affs = importAffordancesFor( + session({ cwdMatchesRequestedLane: true, capabilities: { resumeInPlace: true, fork: true } }), + ); + expect(affs.map((a) => a.kind)).toEqual(["resume-here", "fork-into-lane"]); + expect(affs.every((a) => a.enabled)).toBe(true); + expect(affs.every((a) => a.target === "cli")).toBe(true); + }); + + it("cwd matches but no resumeInPlace: omits resume-here", () => { + expect(kinds(session({ cwdMatchesRequestedLane: true, capabilities: { fork: true } }))).toEqual([ + "fork-into-lane", + ]); + }); + + it("chat-capable session leads with the hero open-as-chat then fork-as-chat", () => { + const affs = importAffordancesFor( + session({ cwdMatchesRequestedLane: true, capabilities: { importToChat: true, resumeInPlace: true, fork: true } }), + ); + expect(affs.map((a) => a.kind)).toEqual([ + "open-as-chat", + "fork-as-chat", + "resume-here", + "fork-into-lane", + ]); + const hero = affs.find((a) => a.hero); + expect(hero?.kind).toBe("open-as-chat"); + expect(hero?.target).toBe("chat"); + expect(hero?.mode).toBe("resume"); + expect(affs.find((a) => a.kind === "fork-as-chat")).toMatchObject({ target: "chat", mode: "fork", hero: false }); + }); + + it("only open-as-chat is ever flagged hero", () => { + const affs = importAffordancesFor( + session({ capabilities: { importToChat: true, resumeInPlace: true, fork: true, resumeInDifferentCwd: true } }), + ); + expect(affs.filter((a) => a.hero)).toHaveLength(1); + }); + + it("foreign cwd + cross-cwd resume (codex): resume-here enabled, fork added when forkAcross", () => { + const affs = importAffordancesFor( + session({ + provider: "codex", + cwdMatchesRequestedLane: false, + capabilities: { resumeInDifferentCwd: true, forkIntoDifferentCwd: true }, + }), + ); + expect(affs.map((a) => a.kind)).toEqual(["resume-here", "fork-into-lane"]); + expect(affs[0]).toMatchObject({ enabled: true, mode: "resume" }); + expect(affs[1]?.hint).toBeUndefined(); + }); + + it("foreign cwd + cross-cwd resume only: no fork row", () => { + expect( + kinds(session({ cwdMatchesRequestedLane: false, capabilities: { resumeInDifferentCwd: true } })), + ).toEqual(["resume-here"]); + }); + + it("foreign cwd, no cross-cwd resume but forkIntoDifferentCwd: fork substitutes with hint", () => { + const affs = importAffordancesFor( + session({ cwdMatchesRequestedLane: false, capabilities: { forkIntoDifferentCwd: true } }), + ); + expect(affs.map((a) => a.kind)).toEqual(["fork-into-lane"]); + expect(affs[0]).toMatchObject({ enabled: true, mode: "fork" }); + expect(affs[0]?.hint).toMatch(/another folder/i); + }); + + it("foreign cwd with forkIntoDifferentCwd and resumeInPlace offers both in priority order", () => { + const affs = importAffordancesFor( + session({ + cwdMatchesRequestedLane: false, + cwd: "/Users/dev/other-project", + capabilities: { resumeInPlace: true, forkIntoDifferentCwd: true }, + }), + ); + expect(affs.map((a) => a.kind)).toEqual(["fork-into-lane", "resume-in-place"]); + expect(affs[0]).toMatchObject({ mode: "fork", hint: expect.stringMatching(/another folder/i) }); + // The foreign folder is carried on `foreignCwd` (rendered as an inline + // caption), not baked into the now-consistent "Open as CLI session" label. + expect(affs[1]).toMatchObject({ + mode: "resume", + label: "Open as CLI session", + foreignCwd: "/Users/dev/other-project", + }); + }); + + it("foreign cwd, only resumeInPlace: offers resume-in-place carrying the foreign path", () => { + const affs = importAffordancesFor( + session({ + cwdMatchesRequestedLane: false, + cwd: "/Users/dev/other-project", + capabilities: { resumeInPlace: true }, + }), + ); + expect(affs.map((a) => a.kind)).toEqual(["resume-in-place"]); + expect(affs[0]?.foreignCwd).toBe("/Users/dev/other-project"); + expect(affs[0]?.label).toBe("Open as CLI session"); + expect(affs[0]?.mode).toBe("resume"); + }); + + it("resume-in-place appears alongside fork but remains hidden behind cross-cwd resume-here", () => { + expect( + kinds(session({ cwdMatchesRequestedLane: false, capabilities: { resumeInPlace: true, forkIntoDifferentCwd: true } })), + ).toEqual(["fork-into-lane", "resume-in-place"]); + // resumeAcross wins over resume-in-place + expect( + kinds(session({ cwdMatchesRequestedLane: false, capabilities: { resumeInPlace: true, resumeInDifferentCwd: true } })), + ).toEqual(["resume-here"]); + }); + + it("foreign cwd, no CLI caps: resume-here disabled with a provider-specific reason", () => { + const affs = importAffordancesFor( + session({ provider: "cursor", cwdMatchesRequestedLane: false, capabilities: {} }), + ); + expect(affs.map((a) => a.kind)).toEqual(["resume-here"]); + expect(affs[0]?.enabled).toBe(false); + expect(affs[0]?.disabledReason).toContain("Cursor"); + }); + + it("chat import works regardless of cwd, alongside a disabled cli resume", () => { + const affs = importAffordancesFor( + session({ provider: "claude", cwdMatchesRequestedLane: false, capabilities: { importToChat: true } }), + ); + expect(affs.map((a) => a.kind)).toEqual(["open-as-chat", "fork-as-chat", "resume-here"]); + expect(affs.find((a) => a.kind === "resume-here")?.enabled).toBe(false); + }); + + it("no capabilities at all, cwd matches: yields nothing", () => { + expect(importAffordancesFor(session({ cwdMatchesRequestedLane: true, capabilities: {} }))).toEqual([]); + }); + + it("labels are drawn only from the consistent {ADE chat | CLI session} × {Open | Fork} set", () => { + const summaries = [ + session({ cwdMatchesRequestedLane: true, capabilities: { importToChat: true, resumeInPlace: true, fork: true } }), + session({ cwdMatchesRequestedLane: false, cwd: "/Users/dev/other", capabilities: { resumeInPlace: true, forkIntoDifferentCwd: true } }), + session({ provider: "cursor", cwdMatchesRequestedLane: false, capabilities: {} }), + ]; + const allowed = new Set([ + "Open as ADE chat", + "Fork as ADE chat", + "Open as CLI session", + "Fork as CLI session", + ]); + for (const summary of summaries) { + for (const aff of importAffordancesFor(summary)) { + expect(allowed.has(aff.label)).toBe(true); + } + } + }); + + it("every emitted affordance carries a non-empty meaning description", () => { + const summaries = [ + session({ cwdMatchesRequestedLane: true, capabilities: { importToChat: true, resumeInPlace: true, fork: true } }), + session({ cwdMatchesRequestedLane: false, capabilities: { resumeInDifferentCwd: true, forkIntoDifferentCwd: true } }), + session({ cwdMatchesRequestedLane: false, cwd: "/Users/dev/other", capabilities: { resumeInPlace: true, forkIntoDifferentCwd: true } }), + session({ provider: "cursor", cwdMatchesRequestedLane: false, capabilities: {} }), + ]; + for (const summary of summaries) { + for (const aff of importAffordancesFor(summary)) { + expect(aff.description.length).toBeGreaterThan(0); + } + } + }); + + it("the disabled resume-here description explains the cross-folder block", () => { + const [aff] = importAffordancesFor( + session({ provider: "cursor", cwdMatchesRequestedLane: false, capabilities: {} }), + ); + expect(aff?.enabled).toBe(false); + expect(aff?.description).toBe(aff?.disabledReason); + expect(aff?.description).toContain("Cursor"); + }); +}); + +describe("shortenCwd", () => { + it("keeps short paths intact", () => { + expect(shortenCwd("/a/b")).toBe("/a/b"); + }); + + it("truncates deep paths to a recognizable tail", () => { + expect(shortenCwd("/Users/dev/work/repo/packages/app")).toBe("…/repo/packages/app"); + }); + + it("falls back to a readable label when the path is missing", () => { + expect(shortenCwd("")).toBe("its original folder"); + expect(shortenCwd(null)).toBe("its original folder"); + }); +}); diff --git a/apps/desktop/src/renderer/components/terminals/importSessions/affordances.ts b/apps/desktop/src/renderer/components/terminals/importSessions/affordances.ts new file mode 100644 index 000000000..472203e4e --- /dev/null +++ b/apps/desktop/src/renderer/components/terminals/importSessions/affordances.ts @@ -0,0 +1,205 @@ +/** + * Pure capability -> affordance mapping for the import browser. + * + * Given one external session summary (its capability flags + how its cwd + * relates to the target lane), produce exactly the row actions the user should + * see — no more, no fewer. Kept free of React so it can be unit-tested in + * isolation (see affordances.test.ts). + */ +import { + providerDisplayName, + type ExternalSessionSummary, +} from "./contract"; + +export type ImportTarget = "cli" | "chat"; +export type ImportMode = "resume" | "fork"; + +export type ImportAffordanceKind = + | "resume-here" + | "resume-in-place" + | "fork-into-lane" + | "open-as-chat" + | "fork-as-chat"; + +export type ImportAffordance = { + kind: ImportAffordanceKind; + label: string; + /** + * Plain-English meaning of this action. Kept as structured data (asserted in + * tests); the two axes it spells out — Open (continue the original) vs Fork + * (start a copy), and ADE chat vs CLI session — are surfaced to the user by + * the shared inline helper in the browser header, not a per-button tooltip. + */ + description: string; + target: ImportTarget; + mode: ImportMode; + /** True only for the hero "Open as ADE chat" action. */ + hero: boolean; + /** When false, render disabled and show {@link disabledReason}. */ + enabled: boolean; + disabledReason?: string; + /** Inline explanatory hint shown under the action (e.g. cross-folder fork). */ + hint?: string; + /** The session's own cwd, set on resume-in-place so callers can label it. */ + foreignCwd?: string | null; +}; + +/** Copy shared across the branches that can emit the same action. */ +const OPEN_AS_CLI_DESCRIPTION = + "Continues the same session as a CLI terminal in this lane. Takes over the session — don't run it elsewhere at the same time."; +const FORK_AS_CLI_DESCRIPTION = + "Starts a copy of this session as a CLI terminal in this lane. The original session is left untouched."; + +/** Collapses `$HOME` to `~` and keeps a recognizable tail of the path. */ +export function shortenCwd(cwd: string | null | undefined, maxSegments = 3): string { + if (!cwd) return "its original folder"; + const home = + (typeof process !== "undefined" && process.env?.HOME) || + (globalThis as { __ADE_HOME__?: string }).__ADE_HOME__ || + ""; + let path = cwd; + if (home && path.startsWith(home)) { + path = `~${path.slice(home.length)}`; + } + const segments = path.split("/").filter(Boolean); + if (segments.length <= maxSegments) return path; + const tail = segments.slice(-maxSegments).join("/"); + return `…/${tail}`; +} + +/** + * Returns the ordered list of import actions for a session against the lane the + * user is importing into. Order: chat actions first (the hero), then CLI. + */ +export function importAffordancesFor( + summary: ExternalSessionSummary, +): ImportAffordance[] { + const cap = summary.capabilities; + // `cwdMatchesRequestedLane` is nullable — treat "unknown" as a non-match so + // we never offer an in-place resume we can't guarantee is same-folder. + const cwdMatches = summary.cwdMatchesRequestedLane === true; + const provider = providerDisplayName(summary.provider); + const out: ImportAffordance[] = []; + + // --- Chat import (the hero): full history opens in a native ADE chat. --- + if (cap.importToChat) { + out.push({ + kind: "open-as-chat", + label: "Open as ADE chat", + description: + "Continues this session as a native ADE chat — the full conversation history opens in a chat pane and you keep going here. (Claude/Codex)", + target: "chat", + mode: "resume", + hero: true, + enabled: true, + }); + out.push({ + kind: "fork-as-chat", + label: "Fork as ADE chat", + description: + "Opens a COPY of this session as an ADE chat. The original session stays untouched; you continue on a branch.", + target: "chat", + mode: "fork", + hero: false, + enabled: true, + }); + } + + // --- CLI import: resume/fork into (or at) a folder. --- + if (cwdMatches) { + // Session already lives in this lane's folder: resume-here IS in-place. + if (cap.resumeInPlace) { + out.push({ + kind: "resume-here", + label: "Open as CLI session", + description: OPEN_AS_CLI_DESCRIPTION, + target: "cli", + mode: "resume", + hero: false, + enabled: true, + }); + } + if (cap.fork) { + out.push({ + kind: "fork-into-lane", + label: "Fork as CLI session", + description: FORK_AS_CLI_DESCRIPTION, + target: "cli", + mode: "fork", + hero: false, + enabled: true, + }); + } + } else { + const resumeAcross = cap.resumeInDifferentCwd; + const forkAcross = cap.forkIntoDifferentCwd; + if (resumeAcross) { + // Provider can point the resumed session at this lane's folder. + out.push({ + kind: "resume-here", + label: "Open as CLI session", + description: OPEN_AS_CLI_DESCRIPTION, + target: "cli", + mode: "resume", + hero: false, + enabled: true, + }); + if (forkAcross) { + out.push({ + kind: "fork-into-lane", + label: "Fork as CLI session", + description: FORK_AS_CLI_DESCRIPTION, + target: "cli", + mode: "fork", + hero: false, + enabled: true, + }); + } + } else { + // No cross-folder resume; surface every safe option in priority order. + // Fork keeps work in this lane, while resume-in-place runs in the + // original folder. + if (forkAcross) { + out.push({ + kind: "fork-into-lane", + label: "Fork as CLI session", + description: FORK_AS_CLI_DESCRIPTION, + target: "cli", + mode: "fork", + hero: false, + enabled: true, + hint: "This session lives in another folder — fork it into this lane instead", + }); + } + if (cap.resumeInPlace) { + out.push({ + kind: "resume-in-place", + label: "Open as CLI session", + description: + "Continues the same session in its original folder (not this lane) — this provider can only continue a session where it was created.", + target: "cli", + mode: "resume", + hero: false, + enabled: true, + foreignCwd: summary.cwd, + }); + } + if (!forkAcross && !cap.resumeInPlace) { + // No CLI path into or at this lane — surface why resume is blocked. + const blockedReason = `This session lives in another folder and ${provider} can't resume across folders.`; + out.push({ + kind: "resume-here", + label: "Open as CLI session", + description: blockedReason, + target: "cli", + mode: "resume", + hero: false, + enabled: false, + disabledReason: blockedReason, + }); + } + } + } + + return out; +} diff --git a/apps/desktop/src/renderer/components/terminals/importSessions/contract.ts b/apps/desktop/src/renderer/components/terminals/importSessions/contract.ts new file mode 100644 index 000000000..96e34f769 --- /dev/null +++ b/apps/desktop/src/renderer/components/terminals/importSessions/contract.ts @@ -0,0 +1,95 @@ +/** + * Renderer-side contract helpers for the "Import external CLI session" feature. + * + * The canonical types now live in the backend-owned shared module; we re-export + * them here so the rest of the renderer imports the feature's surface from one + * place. This file only adds renderer glue (bridge accessor, provenance + * reader, display helpers). + */ +import type { TerminalToolType } from "../../../../shared/types"; + +export type { + ExternalSessionProvider, + ExternalSessionCapabilities, + ExternalSessionSummary, + ExternalSessionListArgs, + ExternalSessionImportArgs, + ExternalSessionImportResult, +} from "../../../../shared/types/externalSessions"; + +import type { + ExternalSessionProvider, + ExternalSessionImportArgs, + ExternalSessionImportResult, + ExternalSessionListArgs, + ExternalSessionSummary, +} from "../../../../shared/types/externalSessions"; + +/** + * Provenance marker stamped by the backend onto imported terminal/chat + * sessions. Read via {@link readImportedFrom} with type-safe optional access — + * the field may not be declared on `TerminalSessionSummary` / + * `AgentChatSessionSummary` yet. + */ +export type ImportedFrom = { + provider: ExternalSessionProvider; + sessionId?: string; +}; + +export type ExternalSessionsApi = { + list: (args?: ExternalSessionListArgs) => Promise; + import: (args: ExternalSessionImportArgs) => Promise; +}; + +/** Typed accessor for the runtime-routed bridge; null when unavailable. */ +export function getExternalSessionsApi(): ExternalSessionsApi | null { + const api = (window as unknown as { ade?: { externalSessions?: ExternalSessionsApi } }) + .ade?.externalSessions; + return api ?? null; +} + +/** Normalizes the `list` response, tolerating both an array and `{ sessions }`. */ +export function normalizeListResult( + result: ExternalSessionSummary[] | { sessions: ExternalSessionSummary[] } | null | undefined, +): ExternalSessionSummary[] { + if (Array.isArray(result)) return result; + return result?.sessions ?? []; +} + +/** + * Reads the optional `importedFrom` provenance marker from a session summary + * without depending on the field being declared on the summary type yet. + */ +export function readImportedFrom(session: unknown): ImportedFrom | null { + if (!session || typeof session !== "object") return null; + const raw = (session as { importedFrom?: unknown }).importedFrom; + if (!raw || typeof raw !== "object") return null; + const provider = (raw as { provider?: unknown }).provider; + if (typeof provider !== "string") return null; + const sessionId = (raw as { sessionId?: unknown }).sessionId; + return { + provider: provider as ExternalSessionProvider, + ...(typeof sessionId === "string" ? { sessionId } : {}), + }; +} + +const PROVIDER_LABELS: Record = { + claude: "Claude", + codex: "Codex", + cursor: "Cursor", + droid: "Droid", + opencode: "OpenCode", +}; + +export function providerDisplayName(provider: ExternalSessionProvider): string { + return PROVIDER_LABELS[provider] ?? provider; +} + +/** Maps an external provider to the terminal toolType used for its CLI runtime. */ +export const PROVIDER_TOOL_TYPE: Record = { + claude: "claude", + codex: "codex", + cursor: "cursor-cli", + droid: "droid", + opencode: "opencode", +}; diff --git a/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts b/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts index d5d3e354f..4c719c29b 100644 --- a/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts +++ b/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts @@ -1,6 +1,11 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useLocation, useNavigate, useSearchParams } from "react-router-dom"; import type { AgentChatSession, LaneSummary, TerminalSessionSummary } from "../../../shared/types"; +import { + PROVIDER_TOOL_TYPE, + type ExternalSessionImportResult, + type ExternalSessionSummary, +} from "./importSessions/contract"; import { selectActiveProjectRoot, useAppStore, @@ -1531,6 +1536,80 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) setSessions((prev) => prev.filter((session) => session.id !== sessionId)); }, []); + /** + * Route an imported external session into the Work surface. Mirrors the + * optimistic-inject + focus path of {@link launchPtySession} for the CLI + * case (the backend already created the session/pty, so we skip pty.create + * and adopt the returned ids) and the {@link upsertOptimisticChatSession} + * focus path for the chat case. + */ + const adoptImportedSession = useCallback( + (summary: ExternalSessionSummary, result: ExternalSessionImportResult) => { + invalidateSessionListCache(); + if (result.kind === "cli") { + const startedAt = new Date().toISOString(); + const optimisticSession: TerminalSessionSummary = { + id: result.sessionId, + laneId: result.laneId, + laneName: lanes.find((lane) => lane.id === result.laneId)?.name ?? result.laneId, + ptyId: result.ptyId, + tracked: true, + pinned: false, + manuallyNamed: false, + goal: null, + toolType: PROVIDER_TOOL_TYPE[summary.provider], + title: summary.title || "Imported session", + status: "running", + startedAt, + endedAt: null, + archivedAt: null, + exitCode: null, + transcriptPath: "", + headShaStart: null, + headShaEnd: null, + lastOutputPreview: null, + lastActivityAt: null, + summary: null, + runtimeState: "running", + resumeCommand: null, + resumeMetadata: null, + chatSessionId: null, + }; + pendingOptimisticSessionsRef.current.set(result.sessionId, { + session: optimisticSession, + createdAtMs: Date.now(), + }); + setSessions((prev) => upsertSessionByStartedAt(prev, optimisticSession)); + selectLane(result.laneId); + focusSession(result.sessionId); + openSessionTab(result.sessionId); + } else { + selectLane(result.laneId); + focusSession(result.chatSessionId); + openSessionTab(result.chatSessionId); + setSelectedSessionId(result.chatSessionId); + } + void refresh({ showLoading: false, force: true }).catch(() => {}); + }, + [focusSession, lanes, openSessionTab, refresh, selectLane, setSelectedSessionId], + ); + + /** + * Focus an already-imported session (chat or CLI) that lives in the Work + * surface, without re-importing. Mirrors the focus path of + * {@link adoptImportedSession}; the session already exists in the list, so we + * only select/open its tab. Lane selection is resolved from the session id by + * {@link openSessionTab}. + */ + const openExistingImportedSession = useCallback( + (ref: { kind: "chat" | "cli"; sessionId: string }) => { + focusSession(ref.sessionId); + openSessionTab(ref.sessionId); + if (ref.kind === "chat") setSelectedSessionId(ref.sessionId); + }, + [focusSession, openSessionTab, setSelectedSessionId], + ); + return { sessions, lanes, @@ -1601,6 +1680,8 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) stopRuntime, stopAllRuntimes, launchPtySession, + adoptImportedSession, + openExistingImportedSession, navigate, selectLane, diff --git a/apps/desktop/src/shared/cliLaunch.ts b/apps/desktop/src/shared/cliLaunch.ts index 9ab466755..21556315c 100644 --- a/apps/desktop/src/shared/cliLaunch.ts +++ b/apps/desktop/src/shared/cliLaunch.ts @@ -618,7 +618,6 @@ function permissionModeToCodexFlags(permissionMode: AgentChatPermissionMode | nu function permissionModeToCursorFlags(permissionMode: AgentChatPermissionMode | null | undefined): string[] { if (permissionMode === "full-auto") return ["--force"]; if (permissionMode === "plan") return ["--mode", "plan"]; - if (permissionMode === "edit") return ["--mode", "ask"]; return []; } diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index 030717e55..812579683 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -330,6 +330,8 @@ export const IPC = { terminalSignal: "ade.terminal.signal", terminalActiveForChat: "ade.terminal.activeForChat", terminalReattachChatCli: "ade.terminal.reattachChatCli", + externalSessionsList: "ade.externalSessions.list", + externalSessionsImport: "ade.externalSessions.import", diffGetChanges: "ade.diff.getChanges", diffGetFile: "ade.diff.getFile", diffGetFilePatch: "ade.diff.getFilePatch", diff --git a/apps/desktop/src/shared/types/chat.ts b/apps/desktop/src/shared/types/chat.ts index 23ba58f64..06a165b34 100644 --- a/apps/desktop/src/shared/types/chat.ts +++ b/apps/desktop/src/shared/types/chat.ts @@ -338,6 +338,14 @@ export type AgentChatCompletionReport = { export type AgentChatRuntime = "local" | "cloud"; +export type AgentChatImportProvider = "claude" | "codex"; + +export type AgentChatImportedFrom = { + provider: string; + sessionId: string; + importedAt: number; +}; + export type AgentChatCloudRunStatus = | "creating" | "running" @@ -1010,6 +1018,7 @@ export type AgentChatSession = { idleSinceAt?: string | null; archivedAt?: string | null; threadId?: string; + importedFrom?: AgentChatImportedFrom; /** Subdirectory or absolute path under the lane worktree used as cwd; persisted for relaunch/resume. */ requestedCwd?: string | null; createdAt: string; @@ -1064,6 +1073,7 @@ export type AgentChatSessionSummary = { awaitingInput?: boolean; pendingInputItemId?: string | null; threadId?: string; + importedFrom?: AgentChatImportedFrom; requestedCwd?: string | null; /** * Linear issues attached to this session (chat or CLI), independent of any @@ -1417,6 +1427,19 @@ export type AgentChatCreateArgs = { orchestrationBundlePath?: string; }; +export type AgentChatImportExternalSessionArgs = { + provider: AgentChatImportProvider; + externalSessionId: string; + laneId: string; + cwd: string | null; + fork: boolean; + title?: string; +}; + +export type AgentChatImportExternalSessionResult = { + chatSessionId: string; +}; + /** * Args for headlessly launching an agent in a lane with no mounted chat pane. * Creates the session and fires the first turn off without awaiting it, so a diff --git a/apps/desktop/src/shared/types/externalSessions.ts b/apps/desktop/src/shared/types/externalSessions.ts new file mode 100644 index 000000000..5946b616d --- /dev/null +++ b/apps/desktop/src/shared/types/externalSessions.ts @@ -0,0 +1,47 @@ +export type ExternalSessionProvider = "claude" | "codex" | "cursor" | "droid" | "opencode"; + +export interface ExternalSessionCapabilities { + resumeInPlace: boolean; + resumeInDifferentCwd: boolean; + fork: boolean; + forkIntoDifferentCwd: boolean; + importToChat: boolean; +} + +export interface ExternalSessionSummary { + provider: ExternalSessionProvider; + id: string; + cwd: string | null; + title: string | null; + preview: string | null; + createdAt: number | null; + updatedAt: number | null; + messageCount: number | null; + alreadyImported: boolean; + importedSessionRef?: { kind: "chat" | "cli"; sessionId: string } | null; + possiblyActive: boolean; + cwdMatchesRequestedLane: boolean | null; + capabilities: ExternalSessionCapabilities; +} + +export interface ExternalSessionListArgs { + providers?: ExternalSessionProvider[]; + laneId?: string | null; + cwd?: string | null; + scope?: "project" | "all"; + limit?: number; +} + +export interface ExternalSessionImportArgs { + provider: ExternalSessionProvider; + sessionId: string; + laneId: string; + target: "cli" | "chat"; + mode: "resume" | "fork"; + model?: string; + permissionMode?: string; +} + +export type ExternalSessionImportResult = + | { kind: "cli"; sessionId: string; ptyId: string; laneId: string } + | { kind: "chat"; chatSessionId: string; laneId: string }; diff --git a/apps/desktop/src/shared/types/index.ts b/apps/desktop/src/shared/types/index.ts index 9ff945e6a..6c3a6b4e2 100644 --- a/apps/desktop/src/shared/types/index.ts +++ b/apps/desktop/src/shared/types/index.ts @@ -32,3 +32,4 @@ export * from "./projectSecrets"; export * from "./linearSync"; export * from "./feedback"; export * from "./search"; +export * from "./externalSessions"; diff --git a/apps/desktop/src/shared/types/sessions.ts b/apps/desktop/src/shared/types/sessions.ts index e3eb42a8d..79d03fd96 100644 --- a/apps/desktop/src/shared/types/sessions.ts +++ b/apps/desktop/src/shared/types/sessions.ts @@ -58,6 +58,12 @@ export type TerminalResumeMetadata = { targetKind: TerminalResumeTargetKind; targetId: string | null; launch: TerminalResumeLaunchConfig; + importedFrom?: { + provider: TerminalResumeProvider; + targetId: string; + mode: "resume" | "fork"; + importedAt?: string | null; + } | null; // Legacy aliases kept for compatibility with existing helpers and stored rows. target?: string | null; permissionMode?: AgentChatPermissionMode | null; @@ -156,6 +162,8 @@ export type PtyCreateArgs = { command?: string; args?: string[]; env?: Record; + /** Optional provider continuation metadata to persist for externally imported sessions. */ + resumeMetadata?: TerminalResumeMetadata | null; /** * Linear issues to attach to this session before the process spawns, so the * CLI agent inherits `ADE_LINEAR_ISSUE_IDS` + `ADE_LINEAR_CONTEXT_FILE` and diff --git a/apps/desktop/src/shared/types/sync.ts b/apps/desktop/src/shared/types/sync.ts index 8f9443ab4..b13eab4e3 100644 --- a/apps/desktop/src/shared/types/sync.ts +++ b/apps/desktop/src/shared/types/sync.ts @@ -7,6 +7,12 @@ import type { ProjectBrowseInput, ProjectBrowseResult, } from "./core"; +import type { + ExternalSessionImportArgs, + ExternalSessionImportResult, + ExternalSessionListArgs, + ExternalSessionSummary, +} from "./externalSessions"; import type { PtySendToSessionResult, TerminalSessionSummary } from "./sessions"; export type SyncScalarBytes = { @@ -887,6 +893,12 @@ export type SyncStartCliSessionResult = { session: TerminalSessionSummary | null; }; +export type SyncListExternalSessionsArgs = ExternalSessionListArgs; +export type SyncListExternalSessionsResult = ExternalSessionSummary[]; + +export type SyncImportExternalSessionArgs = ExternalSessionImportArgs; +export type SyncImportExternalSessionResult = ExternalSessionImportResult; + export type SyncSendToSessionArgs = { sessionId: string; text: string; @@ -938,6 +950,8 @@ export type SyncRemoteCommandAction = | "work.runQuickCommand" | "work.startCliSession" | "work.resumeCliSession" + | "work.listExternalSessions" + | "work.importExternalSession" | "work.sendToSession" | "work.stopRuntime" | "processes.listDefinitions" diff --git a/apps/ios/ADE.xcodeproj/project.pbxproj b/apps/ios/ADE.xcodeproj/project.pbxproj index 9afc1370a..35056ce5d 100644 --- a/apps/ios/ADE.xcodeproj/project.pbxproj +++ b/apps/ios/ADE.xcodeproj/project.pbxproj @@ -203,6 +203,7 @@ E1000000000000000000003F /* WorkModelCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000000000000000000003F /* WorkModelCatalog.swift */; }; E10000000000000000000040 /* WorkModelPickerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000040 /* WorkModelPickerSheet.swift */; }; E10000000000000000000041 /* WorkNewChatScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000041 /* WorkNewChatScreen.swift */; }; + E10000000000000000000056 /* WorkImportSessionScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000056 /* WorkImportSessionScreen.swift */; }; E10000000000000000000051 /* WorkLanePickerDropdown.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000051 /* WorkLanePickerDropdown.swift */; }; E10000000000000000000042 /* WorkPreviews.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000042 /* WorkPreviews.swift */; }; E20000000000000000000090 /* WorkComposerTypedTriggers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D20000000000000000000090 /* WorkComposerTypedTriggers.swift */; }; @@ -314,6 +315,7 @@ D1000000000000000000003F /* WorkModelCatalog.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkModelCatalog.swift; path = ADE/Views/Work/WorkModelCatalog.swift; sourceTree = ""; }; D10000000000000000000040 /* WorkModelPickerSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkModelPickerSheet.swift; path = ADE/Views/Work/WorkModelPickerSheet.swift; sourceTree = ""; }; D10000000000000000000041 /* WorkNewChatScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkNewChatScreen.swift; path = ADE/Views/Work/WorkNewChatScreen.swift; sourceTree = ""; }; + D10000000000000000000056 /* WorkImportSessionScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkImportSessionScreen.swift; path = ADE/Views/Work/WorkImportSessionScreen.swift; sourceTree = ""; }; D10000000000000000000051 /* WorkLanePickerDropdown.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkLanePickerDropdown.swift; path = ADE/Views/Work/WorkLanePickerDropdown.swift; sourceTree = ""; }; D10000000000000000000042 /* WorkPreviews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkPreviews.swift; path = ADE/Views/Work/WorkPreviews.swift; sourceTree = ""; }; D20000000000000000000090 /* WorkComposerTypedTriggers.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkComposerTypedTriggers.swift; path = ADE/Views/Work/WorkComposerTypedTriggers.swift; sourceTree = ""; }; @@ -673,6 +675,7 @@ D1000000000000000000003F /* WorkModelCatalog.swift */, D10000000000000000000040 /* WorkModelPickerSheet.swift */, D10000000000000000000041 /* WorkNewChatScreen.swift */, + D10000000000000000000056 /* WorkImportSessionScreen.swift */, D10000000000000000000051 /* WorkLanePickerDropdown.swift */, D10000000000000000000042 /* WorkPreviews.swift */, D20000000000000000000090 /* WorkComposerTypedTriggers.swift */, @@ -1238,6 +1241,7 @@ E1000000000000000000003F /* WorkModelCatalog.swift in Sources */, E10000000000000000000040 /* WorkModelPickerSheet.swift in Sources */, E10000000000000000000041 /* WorkNewChatScreen.swift in Sources */, + E10000000000000000000056 /* WorkImportSessionScreen.swift in Sources */, E10000000000000000000051 /* WorkLanePickerDropdown.swift in Sources */, E10000000000000000000042 /* WorkPreviews.swift in Sources */, E20000000000000000000090 /* WorkComposerTypedTriggers.swift in Sources */, diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index fcbbce1eb..f63e0b626 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -3464,6 +3464,175 @@ struct StartCliSessionResult: Codable, Equatable { var session: TerminalSessionSummary? } +struct ExternalSessionCapabilities: Codable, Equatable { + var resumeInPlace: Bool + var resumeInDifferentCwd: Bool + var fork: Bool + var forkIntoDifferentCwd: Bool + var importToChat: Bool + + init( + resumeInPlace: Bool = false, + resumeInDifferentCwd: Bool = false, + fork: Bool = false, + forkIntoDifferentCwd: Bool = false, + importToChat: Bool = false + ) { + self.resumeInPlace = resumeInPlace + self.resumeInDifferentCwd = resumeInDifferentCwd + self.fork = fork + self.forkIntoDifferentCwd = forkIntoDifferentCwd + self.importToChat = importToChat + } + + private enum CodingKeys: String, CodingKey { + case resumeInPlace + case resumeInDifferentCwd + case fork + case forkIntoDifferentCwd + case importToChat + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + resumeInPlace = try container.decodeIfPresent(Bool.self, forKey: .resumeInPlace) ?? false + resumeInDifferentCwd = try container.decodeIfPresent(Bool.self, forKey: .resumeInDifferentCwd) ?? false + fork = try container.decodeIfPresent(Bool.self, forKey: .fork) ?? false + forkIntoDifferentCwd = try container.decodeIfPresent(Bool.self, forKey: .forkIntoDifferentCwd) ?? false + importToChat = try container.decodeIfPresent(Bool.self, forKey: .importToChat) ?? false + } +} + +struct ExternalSessionImportedRef: Codable, Equatable { + var kind: String + var sessionId: String +} + +struct ExternalSessionSummary: Codable, Identifiable, Equatable { + var provider: String + var id: String + var cwd: String? + var title: String? + var preview: String? + var createdAt: Double? + var updatedAt: Double? + var messageCount: Int? + var alreadyImported: Bool + var importedSessionRef: ExternalSessionImportedRef? + var possiblyActive: Bool + var cwdMatchesRequestedLane: Bool? + var capabilities: ExternalSessionCapabilities + + private enum CodingKeys: String, CodingKey { + case provider + case id + case cwd + case title + case preview + case createdAt + case updatedAt + case messageCount + case alreadyImported + case importedSessionRef + case possiblyActive + case cwdMatchesRequestedLane + case capabilities + } + + init( + provider: String, + id: String, + cwd: String? = nil, + title: String? = nil, + preview: String? = nil, + createdAt: Double? = nil, + updatedAt: Double? = nil, + messageCount: Int? = nil, + alreadyImported: Bool = false, + importedSessionRef: ExternalSessionImportedRef? = nil, + possiblyActive: Bool = false, + cwdMatchesRequestedLane: Bool? = nil, + capabilities: ExternalSessionCapabilities = ExternalSessionCapabilities() + ) { + self.provider = provider + self.id = id + self.cwd = cwd + self.title = title + self.preview = preview + self.createdAt = createdAt + self.updatedAt = updatedAt + self.messageCount = messageCount + self.alreadyImported = alreadyImported + self.importedSessionRef = importedSessionRef + self.possiblyActive = possiblyActive + self.cwdMatchesRequestedLane = cwdMatchesRequestedLane + self.capabilities = capabilities + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + provider = try container.decodeIfPresent(String.self, forKey: .provider) ?? "unknown" + id = try container.decode(String.self, forKey: .id) + cwd = try container.decodeIfPresent(String.self, forKey: .cwd) + title = try container.decodeIfPresent(String.self, forKey: .title) + preview = try container.decodeIfPresent(String.self, forKey: .preview) + createdAt = try container.decodeIfPresent(Double.self, forKey: .createdAt) + updatedAt = try container.decodeIfPresent(Double.self, forKey: .updatedAt) + messageCount = try container.decodeIfPresent(Int.self, forKey: .messageCount) + alreadyImported = try container.decodeIfPresent(Bool.self, forKey: .alreadyImported) ?? false + importedSessionRef = try? container.decodeIfPresent( + ExternalSessionImportedRef.self, + forKey: .importedSessionRef + ) + possiblyActive = try container.decodeIfPresent(Bool.self, forKey: .possiblyActive) ?? false + cwdMatchesRequestedLane = try container.decodeIfPresent(Bool.self, forKey: .cwdMatchesRequestedLane) + capabilities = try container.decodeIfPresent(ExternalSessionCapabilities.self, forKey: .capabilities) + ?? ExternalSessionCapabilities() + } +} + +struct ExternalSessionListResult: Decodable, Equatable { + var sessions: [ExternalSessionSummary] + + private enum CodingKeys: String, CodingKey { + case sessions + } + + init(sessions: [ExternalSessionSummary]) { + self.sessions = sessions + } + + init(from decoder: Decoder) throws { + if var array = try? decoder.unkeyedContainer() { + var decoded: [ExternalSessionSummary] = [] + while !array.isAtEnd { + if let session = try? array.decode(ExternalSessionSummary.self) { + decoded.append(session) + } else if (try? array.decode(RemoteJSONValue.self)) == nil { + break + } + } + sessions = decoded + return + } + + let container = try decoder.container(keyedBy: CodingKeys.self) + if let lossy = try? container.decode(ADELossyArray.self, forKey: .sessions) { + sessions = lossy.wrappedValue + } else { + sessions = [] + } + } +} + +struct ExternalSessionImportResult: Codable, Equatable { + var kind: String + var sessionId: String? + var ptyId: String? + var laneId: String? + var chatSessionId: String? +} + struct SyncScalarBytes: Codable, Equatable { var type: String var base64: String diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index a2ae9e390..517549f78 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -5198,6 +5198,63 @@ final class SyncService: ObservableObject { return try await sendDecodableCommand(action: "work.resumeCliSession", args: args, as: StartCliSessionResult.self) } + func listExternalSessions( + providers: [String]? = nil, + laneId: String? = nil, + cwd: String? = nil, + scope: String = "project", + limit: Int? = nil + ) async throws -> [ExternalSessionSummary] { + var args: [String: Any] = ["scope": scope] + if let providers, !providers.isEmpty { + args["providers"] = providers + } + if let laneId, !laneId.isEmpty { + args["laneId"] = laneId + } + if let cwd, !cwd.isEmpty { + args["cwd"] = cwd + } + if let limit, limit > 0 { + args["limit"] = limit + } + let result = try await sendDecodableCommand( + action: "work.listExternalSessions", + args: args, + as: ExternalSessionListResult.self + ) + return result.sessions + } + + func importExternalSession( + provider: String, + sessionId: String, + laneId: String, + target: String, + mode: String, + model: String? = nil, + permissionMode: String? = nil + ) async throws -> ExternalSessionImportResult { + var args: [String: Any] = [ + "provider": provider, + "sessionId": sessionId, + "laneId": laneId, + "target": target, + "mode": mode, + ] + if let model, !model.isEmpty { + args["model"] = model + } + if let permissionMode, !permissionMode.isEmpty { + args["permissionMode"] = permissionMode + } + return try await sendDecodableCommand( + action: "work.importExternalSession", + args: args, + as: ExternalSessionImportResult.self + ) + } + func unsubscribeTerminal(sessionId: String) async throws { let trimmedSessionId = sessionId.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedSessionId.isEmpty else { return } diff --git a/apps/ios/ADE/Views/Work/WorkImportSessionScreen.swift b/apps/ios/ADE/Views/Work/WorkImportSessionScreen.swift new file mode 100644 index 000000000..d12bff1a1 --- /dev/null +++ b/apps/ios/ADE/Views/Work/WorkImportSessionScreen.swift @@ -0,0 +1,834 @@ +import Foundation +import SwiftUI + +private let workImportSessionProviders = ["all", "claude", "codex", "cursor", "droid", "opencode"] + +private struct WorkExternalSessionAction: Identifiable { + let id: String + let title: String + let systemImage: String + let tint: Color + let target: String + let mode: String + var isPrimary = false + var enabled = true + let importedSessionRef: ExternalSessionImportedRef? + + init( + id: String, + title: String, + systemImage: String, + tint: Color, + target: String, + mode: String, + isPrimary: Bool = false, + enabled: Bool = true, + importedSessionRef: ExternalSessionImportedRef? = nil + ) { + self.id = id + self.title = title + self.systemImage = systemImage + self.tint = tint + self.target = target + self.mode = mode + self.isPrimary = isPrimary + self.enabled = enabled + self.importedSessionRef = importedSessionRef + } +} + +struct WorkImportSessionScreen: View { + @EnvironmentObject var syncService: SyncService + + let lane: LaneSummary + let onCliImported: @MainActor (TerminalSessionSummary) async -> Void + let onChatImported: @MainActor (String) async -> Void + + @State private var sessions: [ExternalSessionSummary] = [] + @State private var providerFilter = "all" + @State private var scope = "project" + @State private var loading = false + @State private var hasLoaded = false + @State private var errorMessage: String? + @State private var importingSessionId: String? + + private var queryKey: String { + "\(providerFilter)|\(scope)|\(lane.id)" + } + + private var sortedSessions: [ExternalSessionSummary] { + sessions.sorted { left, right in + (left.updatedAt ?? left.createdAt ?? 0) > (right.updatedAt ?? right.createdAt ?? 0) + } + } + + var body: some View { + VStack(spacing: 0) { + controls + .padding(.horizontal, 16) + .padding(.top, 12) + .padding(.bottom, 8) + + if let errorMessage { + Text(errorMessage) + .font(.caption) + .foregroundStyle(ADEColor.danger) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.bottom, 8) + } + + content + } + .adeScreenBackground() + .adeNavigationGlass() + .navigationTitle("Import session") + .navigationBarTitleDisplayMode(.inline) + .toolbar(.hidden, for: .tabBar) + .adeRootTabBarHidden() + .task(id: queryKey) { + await loadSessions() + } + } + + @ViewBuilder + private var controls: some View { + VStack(alignment: .leading, spacing: 12) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(workImportSessionProviders, id: \.self) { provider in + WorkImportProviderChip( + provider: provider, + selected: providerFilter == provider, + action: { providerFilter = provider } + ) + } + } + .padding(.vertical, 1) + } + + Picker("Scope", selection: $scope) { + Text("This project only").tag("project") + Text("All folders").tag("all") + } + .pickerStyle(.segmented) + } + } + + @ViewBuilder + private var content: some View { + if loading && !hasLoaded { + Spacer() + ProgressView() + .controlSize(.regular) + Spacer() + } else if sortedSessions.isEmpty { + Spacer() + VStack(spacing: 8) { + Image(systemName: "tray") + .font(.title3) + .foregroundStyle(ADEColor.textMuted) + Text("No sessions found") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + Text("Pull to refresh") + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + } + Spacer() + } else { + List { + ForEach(sortedSessions, id: \.importIdentity) { session in + WorkImportSessionRow( + session: session, + actions: actions(for: session), + importing: importingSessionId == session.importIdentity, + importDisabled: importingSessionId != nil, + onSelectAction: { action in + Task { await importSession(session, action: action) } + } + ) + .listRowInsets(EdgeInsets(top: 10, leading: 16, bottom: 10, trailing: 16)) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .refreshable { + await loadSessions() + } + } + } + + private func loadSessions() async { + loading = true + errorMessage = nil + do { + let providers = providerFilter == "all" ? nil : [providerFilter] + sessions = try await syncService.listExternalSessions( + providers: providers, + laneId: lane.id, + scope: scope, + limit: 100 + ) + hasLoaded = true + } catch is CancellationError { + } catch { + errorMessage = error.localizedDescription + hasLoaded = true + } + loading = false + } + + private func actions(for session: ExternalSessionSummary) -> [WorkExternalSessionAction] { + var result: [WorkExternalSessionAction] = [] + let caps = session.capabilities + let cwdMatchesLane = session.cwdMatchesRequestedLane == true + + if caps.importToChat { + result.append(WorkExternalSessionAction( + id: "open-as-chat", + title: "Open as ADE chat", + systemImage: "bubble.left.and.bubble.right.fill", + tint: ADEColor.accent, + target: "chat", + mode: "resume", + isPrimary: true + )) + result.append(WorkExternalSessionAction( + id: "fork-as-chat", + title: "Fork as ADE chat", + systemImage: "arrow.triangle.branch", + tint: ADEColor.textPrimary, + target: "chat", + mode: "fork" + )) + } + + if cwdMatchesLane { + if caps.resumeInPlace { + result.append(WorkExternalSessionAction( + id: "resume-here", + title: "Open as CLI session", + systemImage: "terminal.fill", + tint: ADEColor.textPrimary, + target: "cli", + mode: "resume" + )) + } + if caps.fork { + result.append(WorkExternalSessionAction( + id: "fork-into-lane", + title: "Fork as CLI session", + systemImage: "arrow.triangle.branch", + tint: ADEColor.textPrimary, + target: "cli", + mode: "fork" + )) + } + } else if caps.resumeInDifferentCwd { + result.append(WorkExternalSessionAction( + id: "resume-here", + title: "Open as CLI session", + systemImage: "terminal.fill", + tint: ADEColor.textPrimary, + target: "cli", + mode: "resume" + )) + if caps.forkIntoDifferentCwd { + result.append(WorkExternalSessionAction( + id: "fork-into-lane", + title: "Fork as CLI session", + systemImage: "arrow.triangle.branch", + tint: ADEColor.textPrimary, + target: "cli", + mode: "fork" + )) + } + } else { + if caps.forkIntoDifferentCwd { + result.append(WorkExternalSessionAction( + id: "fork-into-lane", + title: "Fork as CLI session", + systemImage: "arrow.triangle.branch", + tint: ADEColor.textPrimary, + target: "cli", + mode: "fork" + )) + } + if caps.resumeInPlace { + result.append(WorkExternalSessionAction( + id: "resume-in-place", + title: "Open as CLI session", + systemImage: "terminal.fill", + tint: ADEColor.textPrimary, + target: "cli", + mode: "resume" + )) + } + if !caps.forkIntoDifferentCwd && !caps.resumeInPlace { + result.append(WorkExternalSessionAction( + id: "resume-here", + title: "Open as CLI session", + systemImage: "terminal.fill", + tint: ADEColor.textMuted, + target: "cli", + mode: "resume", + enabled: false + )) + } + } + + if let importedRef = importedSessionRef(for: session) { + result = result.filter { $0.mode == "fork" } + result.insert(WorkExternalSessionAction( + id: "open-existing", + title: "Open in ADE", + systemImage: "arrow.right.circle.fill", + tint: ADEColor.accent, + target: "existing", + mode: "open", + isPrimary: true, + enabled: true, + importedSessionRef: importedRef + ), at: 0) + return result + } + + if !result.contains(where: { $0.isPrimary }), let index = result.firstIndex(where: { $0.enabled }) { + result[index].isPrimary = true + } + + return result + } + + private func importedSessionRef(for session: ExternalSessionSummary) -> ExternalSessionImportedRef? { + guard session.alreadyImported, let ref = session.importedSessionRef else { return nil } + guard let kind = normalizedImportedSessionKind(ref.kind) else { return nil } + let sessionId = ref.sessionId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !sessionId.isEmpty else { return nil } + return ExternalSessionImportedRef(kind: kind, sessionId: sessionId) + } + + private func normalizedImportedSessionKind(_ kind: String) -> String? { + let normalized = kind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + switch normalized { + case "chat", "cli": + return normalized + default: + return nil + } + } + + @MainActor + private func importSession(_ session: ExternalSessionSummary, action: WorkExternalSessionAction) async { + guard importingSessionId == nil else { return } + importingSessionId = session.importIdentity + errorMessage = nil + if let importedSessionRef = action.importedSessionRef { + await openExistingSession(session, ref: importedSessionRef, action: action) + importingSessionId = nil + return + } + do { + let result = try await syncService.importExternalSession( + provider: session.provider, + sessionId: session.id, + laneId: lane.id, + target: action.target, + mode: action.mode + ) + if result.kind == "chat", let chatSessionId = result.chatSessionId { + ADEHaptics.medium() + await onChatImported(chatSessionId) + } else if result.kind == "cli", let sessionId = result.sessionId { + ADEHaptics.medium() + await onCliImported(makeTerminalSessionSummary( + sessionId: sessionId, + ptyId: result.ptyId, + imported: session, + action: action + )) + } else { + throw NSError( + domain: "ADE", + code: 31, + userInfo: [NSLocalizedDescriptionKey: "The machine returned an incomplete import result."] + ) + } + } catch { + ADEHaptics.error() + errorMessage = error.localizedDescription + } + importingSessionId = nil + } + + @MainActor + private func openExistingSession( + _ session: ExternalSessionSummary, + ref: ExternalSessionImportedRef, + action: WorkExternalSessionAction + ) async { + guard let kind = normalizedImportedSessionKind(ref.kind) else { + ADEHaptics.error() + errorMessage = "The imported session reference is not supported." + return + } + let sessionId = ref.sessionId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !sessionId.isEmpty else { + ADEHaptics.error() + errorMessage = "The imported session reference is missing a session ID." + return + } + + ADEHaptics.medium() + if kind == "chat" { + await onChatImported(sessionId) + } else { + await onCliImported(makeTerminalSessionSummary( + sessionId: sessionId, + ptyId: nil, + imported: session, + action: action + )) + } + } + + private func makeTerminalSessionSummary( + sessionId: String, + ptyId: String?, + imported session: ExternalSessionSummary, + action: WorkExternalSessionAction + ) -> TerminalSessionSummary { + TerminalSessionSummary( + id: sessionId, + laneId: lane.id, + laneName: lane.name, + ptyId: ptyId, + tracked: true, + pinned: false, + manuallyNamed: nil, + goal: session.preview, + toolType: workImportToolType(provider: session.provider), + title: session.rowHeading, + status: "running", + startedAt: workDateFormatter.string(from: Date()), + endedAt: nil, + exitCode: nil, + transcriptPath: "", + headShaStart: nil, + headShaEnd: nil, + lastOutputPreview: session.preview, + summary: action.title, + runtimeState: "running", + resumeCommand: nil, + resumeMetadata: nil, + chatIdleSinceAt: nil + ) + } +} + +private struct WorkImportProviderChip: View { + let provider: String + let selected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 6) { + if provider != "all" { + providerLogo + } + Text(provider == "all" ? "All" : providerDisplayName(provider)) + .font(.caption.weight(.semibold)) + } + .foregroundStyle(selected ? ADEColor.textPrimary : ADEColor.textSecondary) + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background(selected ? ADEColor.surfaceBackground.opacity(0.88) : ADEColor.recessedBackground.opacity(0.5), in: Capsule()) + .overlay { + Capsule() + .stroke(selected ? ADEColor.glassBorder : Color.clear, lineWidth: 0.6) + } + } + .buttonStyle(.plain) + } + + @ViewBuilder + private var providerLogo: some View { + WorkProviderBareLogo( + provider: provider, + fallbackSymbol: providerIcon(provider), + tint: ADEColor.providerChatAccent(for: provider), + size: 18 + ) + } +} + +private struct WorkImportSessionRow: View { + let session: ExternalSessionSummary + let actions: [WorkExternalSessionAction] + let importing: Bool + let importDisabled: Bool + let onSelectAction: (WorkExternalSessionAction) -> Void + + @State private var previewExpanded = false + + private var actionColumns: [GridItem] { + [ + GridItem(.flexible(), spacing: 8), + GridItem(.flexible(), spacing: 8) + ] + } + + private var existingActions: [WorkExternalSessionAction] { + actions.filter { $0.importedSessionRef != nil } + } + + private var chatActions: [WorkExternalSessionAction] { + actions.filter { $0.importedSessionRef == nil && $0.target == "chat" } + } + + private var cliActions: [WorkExternalSessionAction] { + actions.filter { $0.importedSessionRef == nil && $0.target == "cli" } + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .top, spacing: 10) { + VStack(alignment: .leading, spacing: 7) { + Text(session.rowHeading) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(2) + statusBadges + metaLine + } + + Spacer(minLength: 0) + + if importing { + ProgressView() + .controlSize(.small) + .padding(.top, 2) + } + } + + previewDisclosure + + if !actions.isEmpty { + VStack(spacing: 8) { + actionGrid(existingActions) + if !existingActions.isEmpty && (!chatActions.isEmpty || !cliActions.isEmpty) { + Divider() + .overlay(ADEColor.glassBorder.opacity(0.7)) + } + actionGrid(chatActions) + if !chatActions.isEmpty && !cliActions.isEmpty { + Divider() + .overlay(ADEColor.glassBorder.opacity(0.7)) + } + actionGrid(cliActions) + } + } + } + .padding(16) + .background(ADEColor.cardBackground.opacity(0.72), in: RoundedRectangle(cornerRadius: ADEListRowMetrics.cornerRadius, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: ADEListRowMetrics.cornerRadius, style: .continuous) + .stroke(ADEColor.glassBorder, lineWidth: 0.6) + } + .contentShape(Rectangle()) + } + + @ViewBuilder + private var metaLine: some View { + HStack(spacing: 5) { + WorkProviderBareLogo( + provider: session.provider, + fallbackSymbol: providerIcon(session.provider), + tint: ADEColor.providerChatAccent(for: session.provider), + size: 16 + ) + + Text(providerDisplayName(session.provider)) + + if !session.relativeUpdatedAt.isEmpty { + Text("·") + .foregroundStyle(ADEColor.textMuted.opacity(0.7)) + Text(session.relativeUpdatedAt) + } + + if let messageCount = session.messageCount { + Text("·") + .foregroundStyle(ADEColor.textMuted.opacity(0.7)) + Text("\(messageCount) \(messageCount == 1 ? "msg" : "msgs")") + } + + if session.hasRealTitle, let cwd = session.trimmedCwd { + Text(session.cwdDisplayName) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(ADEColor.textMuted) + .lineLimit(1) + .truncationMode(.middle) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(ADEColor.textPrimary.opacity(0.035), in: RoundedRectangle(cornerRadius: 6, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(ADEColor.glassBorder.opacity(0.55), lineWidth: 0.6) + } + .accessibilityLabel(cwd) + } + } + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + .lineLimit(1) + } + + @ViewBuilder + private var previewDisclosure: some View { + if let preview = session.previewSnippet { + VStack(alignment: .leading, spacing: 6) { + Button { + withAnimation(.easeInOut(duration: 0.18)) { + previewExpanded.toggle() + } + } label: { + HStack(spacing: 4) { + Image(systemName: "chevron.right") + .font(.system(size: 10, weight: .bold)) + .rotationEffect(.degrees(previewExpanded ? 90 : 0)) + Text("Preview") + .font(.caption.weight(.semibold)) + } + .foregroundStyle(ADEColor.textMuted) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if previewExpanded { + Text(preview) + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(ADEColor.textPrimary.opacity(0.025), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(ADEColor.glassBorder.opacity(0.55), lineWidth: 0.6) + } + } + } + } + } + + @ViewBuilder + private func actionGrid(_ groupedActions: [WorkExternalSessionAction]) -> some View { + if !groupedActions.isEmpty { + LazyVGrid(columns: actionColumns, alignment: .leading, spacing: 8) { + ForEach(groupedActions) { action in + WorkImportActionButton( + action: action, + importDisabled: importDisabled, + onSelect: onSelectAction + ) + } + } + } + } + + @ViewBuilder + private var statusBadges: some View { + if session.alreadyImported || session.possiblyActive { + HStack(spacing: 6) { + if session.alreadyImported { + WorkImportBadge(text: "Imported", tint: ADEColor.success) + } + if session.possiblyActive { + WorkImportBadge(text: "May be open elsewhere", tint: ADEColor.warning) + } + } + } + } +} + +private struct WorkImportBadge: View { + let text: String + let tint: Color + + var body: some View { + Text(text) + .font(.caption2.weight(.semibold)) + .foregroundStyle(tint) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background(tint.opacity(0.12), in: Capsule()) + } +} + +private struct WorkImportActionButton: View { + let action: WorkExternalSessionAction + let importDisabled: Bool + let onSelect: (WorkExternalSessionAction) -> Void + + private var isEnabled: Bool { + action.enabled && !importDisabled + } + + var body: some View { + Button { + guard action.enabled else { return } + onSelect(action) + } label: { + HStack(alignment: .center, spacing: 7) { + Image(systemName: action.systemImage) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(iconColor) + .frame(width: 14, height: 16) + + Text(action.title) + .font(.caption.weight(.semibold)) + .foregroundStyle(titleColor) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + + Spacer(minLength: 0) + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, minHeight: 40, alignment: .leading) + .background(backgroundStyle, in: RoundedRectangle(cornerRadius: 11, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 11, style: .continuous) + .stroke(borderColor, lineWidth: 0.7) + } + .opacity(isEnabled ? 1 : 0.58) + } + .buttonStyle(.plain) + .disabled(!isEnabled) + } + + private var backgroundStyle: Color { + if action.isPrimary && action.enabled { + return ADEColor.accent + } + return ADEColor.textPrimary.opacity(action.enabled ? 0.04 : 0.025) + } + + private var borderColor: Color { + if action.isPrimary && action.enabled { + return Color.white.opacity(0.18) + } + return action.enabled ? ADEColor.glassBorder : ADEColor.glassBorder.opacity(0.55) + } + + private var iconColor: Color { + if action.isPrimary && action.enabled { + return .white + } + return action.enabled ? action.tint : ADEColor.textMuted + } + + private var titleColor: Color { + if action.isPrimary && action.enabled { + return .white + } + return action.enabled ? ADEColor.textPrimary : ADEColor.textMuted + } +} + +private extension ExternalSessionSummary { + var importIdentity: String { + "\(provider):\(id)" + } + + var rowHeading: String { + if let realTitle { return realTitle } + let whereText = cwdLastPathSegment ?? cwdDisplayName + guard !relativeUpdatedAt.isEmpty else { return whereText } + return "\(whereText) · \(relativeUpdatedAt)" + } + + var hasRealTitle: Bool { + realTitle != nil + } + + var realTitle: String? { + let trimmedTitle = title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmedTitle.isEmpty ? nil : trimmedTitle + } + + var previewSnippet: String? { + let trimmedPreview = preview?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmedPreview.isEmpty ? nil : trimmedPreview + } + + var trimmedCwd: String? { + let trimmed = cwd?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } + + var cwdLastPathSegment: String? { + guard let trimmedCwd else { return nil } + let last = (trimmedCwd as NSString).lastPathComponent + .trimmingCharacters(in: .whitespacesAndNewlines) + return last.isEmpty || last == "/" ? nil : last + } + + var cwdDisplayName: String { + guard let cwd = trimmedCwd else { return "its original folder" } + let home = NSHomeDirectory() + var display = cwd + if cwd == home { display = "~" } + if cwd.hasPrefix(home + "/") { + display = "~" + cwd.dropFirst(home.count) + } + let segments = display.split(separator: "/").map(String.init) + guard segments.count > 3 else { return display } + return ".../" + segments.suffix(3).joined(separator: "/") + } + + var relativeUpdatedAt: String { + guard let timestamp = updatedAt ?? createdAt, timestamp > 0 else { return "" } + let seconds = timestamp > 10_000_000_000 ? timestamp / 1000 : timestamp + return WorkImportSessionFormatters.relative.localizedString(for: Date(timeIntervalSince1970: seconds), relativeTo: Date()) + } +} + +private enum WorkImportSessionFormatters { + static let relative: RelativeDateTimeFormatter = { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .short + return formatter + }() +} + +private func providerDisplayName(_ provider: String) -> String { + switch provider.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "claude": return "Claude" + case "codex": return "Codex" + case "cursor": return "Cursor" + case "droid": return "Droid" + case "opencode": return "OpenCode" + default: + let trimmed = provider.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? "Unknown" : trimmed + } +} + +private func workImportToolType(provider: String) -> String { + switch provider.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "claude": return "claude" + case "codex": return "codex" + // "cursor-cli" (not "cursor") so isWorkChatToolType classifies an imported + // Cursor session as a CLI terminal, matching the desktop import mapping. + case "cursor": return "cursor-cli" + case "droid": return "droid" + case "opencode": return "opencode" + default: return provider + } +} diff --git a/apps/ios/ADE/Views/Work/WorkLanePickerDropdown.swift b/apps/ios/ADE/Views/Work/WorkLanePickerDropdown.swift index 3e462ef06..31544e2be 100644 --- a/apps/ios/ADE/Views/Work/WorkLanePickerDropdown.swift +++ b/apps/ios/ADE/Views/Work/WorkLanePickerDropdown.swift @@ -117,9 +117,9 @@ struct WorkLanePickerDropdown: View { .padding(.trailing, 10) } } - .padding(.leading, 12) + .padding(.leading, 14) .padding(.trailing, 4) - .padding(.vertical, triggerBranchLabel == nil ? 6 : 5) + .padding(.vertical, triggerBranchLabel == nil ? 10 : 9) .background(surface.background, in: Capsule(style: .continuous)) .overlay( Capsule(style: .continuous) @@ -135,10 +135,10 @@ struct WorkLanePickerDropdown: View { triggerTitleRow HStack(spacing: 4) { Image(systemName: "arrow.branch") - .font(.system(size: 9, weight: .regular)) + .font(.system(size: 10, weight: .regular)) .foregroundStyle(ADEColor.textMuted.opacity(0.55)) Text(branch) - .font(.system(size: 10)) + .font(.system(size: 11)) .foregroundStyle(ADEColor.textMuted.opacity(0.92)) .lineLimit(1) } @@ -153,17 +153,17 @@ struct WorkLanePickerDropdown: View { @ViewBuilder private var triggerTitleRow: some View { - HStack(spacing: 5) { + HStack(spacing: 6) { if let triggerLaneColor, !isAutoCreateSelected { - WorkLaneLogoMark(color: triggerLaneColor, laneIcon: selectedLane?.icon, size: 11) + WorkLaneLogoMark(color: triggerLaneColor, laneIcon: selectedLane?.icon, size: 13) } else if isAutoCreateSelected { Image(systemName: "sparkles") - .font(.system(size: 11, weight: .bold)) + .font(.system(size: 13, weight: .bold)) .foregroundStyle(ADEColor.accent) } Text(triggerTitle) - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(Color.white.opacity(isAutoCreateSelected ? 0.85 : 0.85)) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(Color.white.opacity(0.9)) .lineLimit(1) } } diff --git a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift index 68d5961e5..1ad353c88 100644 --- a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift @@ -488,6 +488,7 @@ struct WorkNewChatScreen: View { let activeProjectId: String? let onStarted: @MainActor (AgentChatSessionSummary, String) async -> Void let onCliStarted: @MainActor (TerminalSessionSummary) async -> Void + let onChatImported: @MainActor (String) async -> Void let onRefreshLanes: @MainActor () async -> Void @State private var selectedLaneId: String = "" @@ -515,6 +516,7 @@ struct WorkNewChatScreen: View { activeProjectId: String?, onStarted: @escaping @MainActor (AgentChatSessionSummary, String) async -> Void, onCliStarted: @escaping @MainActor (TerminalSessionSummary) async -> Void, + onChatImported: @escaping @MainActor (String) async -> Void = { _ in }, onRefreshLanes: @escaping @MainActor () async -> Void ) { self.lanes = lanes @@ -522,6 +524,7 @@ struct WorkNewChatScreen: View { self.activeProjectId = activeProjectId self.onStarted = onStarted self.onCliStarted = onCliStarted + self.onChatImported = onChatImported self.onRefreshLanes = onRefreshLanes // Restore the last-used model + access mode so a fresh New Chat screen opens // on the user's most recent choices. Seeding the @State initial values here @@ -579,6 +582,11 @@ struct WorkNewChatScreen: View { ?? lanes.first } + private var selectedConcreteLane: LaneSummary? { + guard !isAutoCreateLane else { return nil } + return lanes.first(where: { $0.id == selectedLaneId }) + } + /// Fast mode only applies to in-app chat sessions on fast-tier models — the /// CLI launcher has no fast-mode parameter — so the lightning toggle (and the /// value we send) is gated on both. The picker's option can only *add* support @@ -643,6 +651,8 @@ struct WorkNewChatScreen: View { .padding(.bottom, 6) } + importSessionChip + composerBar } .adeScreenBackground() @@ -733,21 +743,13 @@ struct WorkNewChatScreen: View { @ViewBuilder private var brandMark: some View { - ZStack { - Text("ADE") - .font(.system(size: 84, weight: .heavy, design: .default)) - .foregroundStyle(ADEColor.accent.opacity(0.18)) - .offset(x: 4, y: 4) - Text("ADE") - .font(.system(size: 84, weight: .heavy, design: .default)) - .foregroundStyle( - LinearGradient( - colors: [ADEColor.textPrimary, ADEColor.accent.opacity(0.9)], - startPoint: .top, - endPoint: .bottom - ) - ) - } + Image("BrandMark") + .resizable() + .renderingMode(.original) + .interpolation(.high) + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 260) + .shadow(color: ADEColor.purpleAccent.opacity(0.45), radius: 22) .padding(.top, 8) .accessibilityLabel("ADE") } @@ -758,13 +760,52 @@ struct WorkNewChatScreen: View { Spacer(minLength: 0) WorkLanePickerDropdown( lanes: lanes, - selectedLaneId: $selectedLaneId, - onRefresh: onRefreshLanes + selectedLaneId: $selectedLaneId ) Spacer(minLength: 0) } } + // Sits just above the composer, like the context chips in a chat. + @ViewBuilder + private var importSessionChip: some View { + if let lane = selectedConcreteLane { + HStack { + Spacer(minLength: 0) + NavigationLink { + WorkImportSessionScreen( + lane: lane, + onCliImported: onCliStarted, + onChatImported: onChatImported + ) + .environmentObject(syncService) + } label: { + importSessionAffordance(disabled: false) + } + .buttonStyle(.plain) + Spacer(minLength: 0) + } + .padding(.horizontal, 20) + .padding(.bottom, 8) + } + } + + private func importSessionAffordance(disabled: Bool) -> some View { + HStack(spacing: 6) { + Image(systemName: "square.and.arrow.down") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(disabled ? ADEColor.textMuted : ADEColor.accent) + Text("Import session") + .font(.subheadline.weight(.medium)) + .foregroundStyle(disabled ? ADEColor.textMuted : ADEColor.textPrimary) + } + .padding(.horizontal, 14) + .frame(height: 34) + .background(ADEColor.surfaceBackground.opacity(disabled ? 0.36 : 0.7), in: Capsule(style: .continuous)) + .overlay { Capsule(style: .continuous).stroke(ADEColor.glassBorder.opacity(disabled ? 0.5 : 1), lineWidth: 0.6) } + .opacity(disabled ? 0.5 : 1) + } + @ViewBuilder private var composerBar: some View { WorkNewChatComposerBar( diff --git a/apps/ios/ADE/Views/Work/WorkRootScreen.swift b/apps/ios/ADE/Views/Work/WorkRootScreen.swift index 0a45855f4..cab8e260f 100644 --- a/apps/ios/ADE/Views/Work/WorkRootScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkRootScreen.swift @@ -689,6 +689,16 @@ struct WorkRootScreen: View { await reload(refreshRemote: true) } }, + onChatImported: { chatSessionId in + selectedSessionTransitionId = nil + var fresh = NavigationPath() + fresh.append(WorkSessionRoute(sessionId: chatSessionId)) + await Task.yield() + path = fresh + Task { @MainActor in + await reload(refreshRemote: true) + } + }, onRefreshLanes: { await reload(refreshRemote: true) } ) .environmentObject(syncService) diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index 3808de2d5..dfa329ad0 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -11,7 +11,8 @@ machinery layered on top. | Path | Role | |---|---| -| `apps/desktop/src/main/services/chat/agentChatService.ts` | Main service: session lifecycle, turn dispatch, event emission, provider adapters, steer queue, handoff, auto-title, prompt-derived lane-name suggestions for auto-created / parallel lanes, event-history snapshots, durable chat transcript replay/storage compaction, slash-command discovery/merge (delegates to per-provider discovery modules and `slashCommandPromptExpansion` for unified prompt expansion), and active-workload detection used by project/window close guards. Lane naming runs through the session-intelligence prompt path, retries the configured/requested/default title models — the auto-title candidate order prefers the configured `titleModelId` before the session's `requestedModelId` — then falls back to a deterministic prompt slug; branch uniqueness is handled by the lane id suffix added by lane creation. Tracks Fast Mode with the legacy `codexFastMode: boolean` session field for every provider whose descriptor advertises `serviceTiers: ["fast"]`; Codex forwards it as `serviceTier: "fast" \| null` on every `thread/start` and `turn/start` JSON-RPC call, while Cursor SDK sessions resolve it through discovered model parameters (see [Agent Routing](agent-routing.md#provider-service-tiers-fast-mode)). Codex chat goals are managed through the app-server `thread/goal/get` / `set` / `clear` RPCs, persisted in session summaries, validated to the provider's 4,000-character objective limit, and normalized to ADE's unlimited-budget policy by sending `tokenBudget: null` and clearing provider-reported budgets. `applyCodexEffectiveThreadState` accepts a `requestedCodexPolicy` option and uses `shouldPreserveRequestedCodexPolicy` to keep ADE-controlled picker selections authoritative when the lifecycle response echoes an older thread policy (prevents a manual Plan→Edit switch from snapping back); it also syncs the abstract `permissionMode` via `syncLegacyPermissionMode` after every policy application. Whenever an `updateSession` touches any permission/interaction/mode field, the service also emits a transient `session_meta_updated` chat event carrying the recomputed mode fields (`permissionMode`, `interactionMode`, `claudePermissionMode`, `codexApprovalPolicy`/`codexSandbox`/`codexConfigSource`, `opencodePermissionMode`, `droidPermissionMode`, `cursorModeId`, and the `cursorModeSnapshot`) so any other client viewing the same session — a desktop refreshing a session an iOS device just re-moded, or vice versa — updates its composer controls live. It is a direct state patch, emitted after the Cursor policy sync so `cursorModeSnapshot` reflects the recomputed mode, and is kept off the session-list refresh path. Builds ADE guidance from the active lane worktree so Agent Skill roots are lane-scoped in persistent system/developer prompts and provider fallback injection. Spawns Claude/Codex agent runtimes with `buildAgentRuntimeEnv(managed)` so every agent process inherits `ADE_CHAT_SESSION_ID`, `ADE_LANE_ID`, `ADE_PROJECT_ROOT`, and `ADE_WORKSPACE_ROOT` (used by the agent guidance to call `ade --socket app-control logs` / `terminal read --chat-session "$ADE_CHAT_SESSION_ID"` without resolving the chat ID itself). When the session has Linear issues attached (`session_linear_issues`), `buildAgentRuntimeEnv` also materializes them into a per-session context file via `writeSessionLinearIssueContextFile` (`//linear-issues.json`, written atomically; stale files cleared when nothing is attached) and sets `ADE_LINEAR_ISSUE_IDS` (comma-joined identifiers) + `ADE_LINEAR_CONTEXT_FILE` so the agent reads its issue context without Linear credentials. Attaching a `linear_issue` context attachment at run time calls `laneService.attachLinearIssueToSession({ chatSessionId, issues, role: "worked", source: "chat_attach", includeInPr: true })` so the link is persisted even for standalone (laneless) chats; when the session has a lane it additionally runs `laneService.linkLinearIssues` for the lane/PR-card semantics. See [Linear integration](../linear-integration/README.md#session-scoped-issue-attachment-and-cli-context-injection). Claude SDK sessions also resolve the executable through `claudeCodeExecutable.ts` and pass `pathToClaudeCodeExecutable` so packaged builds can prefer the bundled native binary before PATH/auth fallbacks; interrupted Claude turns call `stopTask` for active subagents before emitting stopped subagent results. Claude resume paths run `claudeThinkingTranscriptRepair` before loading a transcript, and the runtime self-heals the same corruption after the Anthropic thinking-block 400 error. Full-auto plan acceptance emits the same plan-mode exit notice as the manual approval path so the renderer composer chip can update even when the session refresh races with compaction. Cursor SDK setup records interrupts that arrive while the worker is still being acquired, releases the acquired generation if setup loses the race, and suppresses false provider-health failures for user-initiated setup interrupts. Cursor provider slash commands use a dedicated discovery path (`cursorSlashCommandDiscovery`) instead of falling through to the generic filesystem-backed list. Large service file. | +| `apps/desktop/src/main/services/chat/agentChatService.ts` | Main service: session lifecycle, external chat import orchestration (`importExternalChatSession` for Claude/Codex sessions discovered by the external-session service), turn dispatch, event emission, provider adapters, steer queue, handoff, auto-title, prompt-derived lane-name suggestions for auto-created / parallel lanes, event-history snapshots, durable chat transcript replay/storage compaction, slash-command discovery/merge (delegates to per-provider discovery modules and `slashCommandPromptExpansion` for unified prompt expansion), and active-workload detection used by project/window close guards. Lane naming runs through the session-intelligence prompt path, retries the configured/requested/default title models — the auto-title candidate order prefers the configured `titleModelId` before the session's `requestedModelId` — then falls back to a deterministic prompt slug; branch uniqueness is handled by the lane id suffix added by lane creation. Tracks Fast Mode with the legacy `codexFastMode: boolean` session field for every provider whose descriptor advertises `serviceTiers: ["fast"]`; Codex forwards it as `serviceTier: "fast" \| null` on every `thread/start` and `turn/start` JSON-RPC call, while Cursor SDK sessions resolve it through discovered model parameters (see [Agent Routing](agent-routing.md#provider-service-tiers-fast-mode)). Codex chat goals are managed through the app-server `thread/goal/get` / `set` / `clear` RPCs, persisted in session summaries, validated to the provider's 4,000-character objective limit, and normalized to ADE's unlimited-budget policy by sending `tokenBudget: null` and clearing provider-reported budgets. `applyCodexEffectiveThreadState` accepts a `requestedCodexPolicy` option and uses `shouldPreserveRequestedCodexPolicy` to keep ADE-controlled picker selections authoritative when the lifecycle response echoes an older thread policy (prevents a manual Plan→Edit switch from snapping back); it also syncs the abstract `permissionMode` via `syncLegacyPermissionMode` after every policy application. Whenever an `updateSession` touches any permission/interaction/mode field, the service also emits a transient `session_meta_updated` chat event carrying the recomputed mode fields (`permissionMode`, `interactionMode`, `claudePermissionMode`, `codexApprovalPolicy`/`codexSandbox`/`codexConfigSource`, `opencodePermissionMode`, `droidPermissionMode`, `cursorModeId`, and the `cursorModeSnapshot`) so any other client viewing the same session — a desktop refreshing a session an iOS device just re-moded, or vice versa — updates its composer controls live. It is a direct state patch, emitted after the Cursor policy sync so `cursorModeSnapshot` reflects the recomputed mode, and is kept off the session-list refresh path. Builds ADE guidance from the active lane worktree so Agent Skill roots are lane-scoped in persistent system/developer prompts and provider fallback injection. Spawns Claude/Codex agent runtimes with `buildAgentRuntimeEnv(managed)` so every agent process inherits `ADE_CHAT_SESSION_ID`, `ADE_LANE_ID`, `ADE_PROJECT_ROOT`, and `ADE_WORKSPACE_ROOT` (used by the agent guidance to call `ade --socket app-control logs` / `terminal read --chat-session "$ADE_CHAT_SESSION_ID"` without resolving the chat ID itself). When the session has Linear issues attached (`session_linear_issues`), `buildAgentRuntimeEnv` also materializes them into a per-session context file via `writeSessionLinearIssueContextFile` (`//linear-issues.json`, written atomically; stale files cleared when nothing is attached) and sets `ADE_LINEAR_ISSUE_IDS` (comma-joined identifiers) + `ADE_LINEAR_CONTEXT_FILE` so the agent reads its issue context without Linear credentials. Attaching a `linear_issue` context attachment at run time calls `laneService.attachLinearIssueToSession({ chatSessionId, issues, role: "worked", source: "chat_attach", includeInPr: true })` so the link is persisted even for standalone (laneless) chats; when the session has a lane it additionally runs `laneService.linkLinearIssues` for the lane/PR-card semantics. See [Linear integration](../linear-integration/README.md#session-scoped-issue-attachment-and-cli-context-injection). Claude SDK sessions also resolve the executable through `claudeCodeExecutable.ts` and pass `pathToClaudeCodeExecutable` so packaged builds can prefer the bundled native binary before PATH/auth fallbacks; interrupted Claude turns call `stopTask` for active subagents before emitting stopped subagent results. Claude resume paths run `claudeThinkingTranscriptRepair` before loading a transcript, and the runtime self-heals the same corruption after the Anthropic thinking-block 400 error. Full-auto plan acceptance emits the same plan-mode exit notice as the manual approval path so the renderer composer chip can update even when the session refresh races with compaction. Cursor SDK setup records interrupts that arrive while the worker is still being acquired, releases the acquired generation if setup loses the race, and suppresses false provider-health failures for user-initiated setup interrupts. Cursor provider slash commands use a dedicated discovery path (`cursorSlashCommandDiscovery`) instead of falling through to the generic filesystem-backed list. Large service file. | +| `apps/desktop/src/main/services/chat/externalChatHistoryImport.ts` | Converts external Claude JSONL and Codex thread-turn history into ADE `AgentChatEventEnvelope` rows. It reads at most the last 32 MB of source transcript bytes, keeps the newest 2,000 imported content events, emits system notices for provenance/truncation, maps user/assistant text plus tool calls/results/file changes/commands/search/image events where available, and derives a fallback imported-chat title from the first user or assistant text. | | `apps/desktop/src/main/services/chat/runtimeEvents.ts` | Canonical cross-runtime event vocabulary (`turn.*`, `content.delta`, `tool.*`, `subagent.*`, teammate/task events, compaction boundaries) plus shims between legacy `AgentChatEvent` rows and the canonical runtime envelope. Claude emits canonical subagent events alongside the legacy rows while the other adapters migrate. | | `apps/ade-cli/src/tuiClient/` | Terminal **Work** chat TUI (Ink + React): same action/RPC contracts as desktop, **attached** (socket) or **embedded** (headless runtime via `ade-cli`). See [ADE Code](../ade-code/README.md). | | `apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.ts` | Main-process broker for the in-app web browser. Owns persistent project-profile partitions derived from the active project root (fallback `persist:ade-browser`) and one window/project browser service per ADE `BrowserWindow`, so each project keeps isolated cookies/storage while its tabs share that project's authenticated browser profile. Each window service manages multiple `WebContentsView` tabs (cap 10), active tab, per-tab lane/chat owner and lease metadata, lightweight browser agent sessions, bounds, visibility, inspect state, targeted status events, screenshot capture, scratch browser-agent observations, diagnostics, per-tab action traces, and emission of `BuiltInBrowserContextItem`s for selected page elements. Observe/click/type/key/scroll/fill/clear/wait/screenshot/select/reload/back/forward/stop can target a hidden or non-active tab by `tabId` or `sessionId`; inspect mode remains a visible-tab interaction. Sessions bind an agent workflow to one tab, remember owner plus last observation/trace ids, and have `ade browser session ` CLI aliases. Scratch observations live under `.ade/cache/browser-observations/`, include a bounded DOM element list plus console/network diagnostics by default, can render a numbered element-map screenshot with `includeElementMap`, and prune to the latest 3 observations per tab by default; click/fill/clear/press/wait can target viewport coordinates or resolve `selector`/`text`/`testId`/`elementIndex`/saved observation `handle` before dispatching CDP input. Waits wake from browser/network/page events with a timeout fallback, and `network-idle` requires complete ready state, no pending browser requests, and a configurable idle window. Handles preserve same-origin iframe/open-shadow-root context when available, and tab traces record action target metadata, duration, before/after URL, session id, observation id, and errors without storing typed fill/type text. `ade browser proof` promotes a fresh scratch observation to durable proof through the proof broker. Window-open requests from a page are handled via `setWindowOpenHandler` returning `action: "allow"` + a `createWindow` factory: a new internal tab is created and its `webContents` is returned to Chromium so the popup keeps its real `window.opener` relationship with the opener tab (important for OAuth flows that postMessage back to the parent). Download requests are saved through the browser session with sanitized, unique filenames in the user's Downloads folder instead of falling through to Chromium defaults. Navigation normalization/protocol policy lives in `builtInBrowserNavigation.ts`; Google sign-in permission policy lives in `builtInBrowserPermissions.ts`. Backs the `ade.builtInBrowser.*` IPC surface and is consumed by both `ChatBuiltInBrowserPanel` (sidebar Browser tab) and `openExternal.ts` (links inside the renderer route through the built-in browser when the protocol is `http`/`https`/`about:blank`). | @@ -371,6 +372,33 @@ See the detail docs for the specifics: - [Composer and UI](composer-and-ui.md) -- composer, tasks, file changes, terminal drawer, message list. +## External chat import + +The external-session import flow can turn provider-native Claude and Codex CLI +sessions into full ADE chats. `externalSessionsService.importExternalSession` +delegates `target: "chat"` imports to +`agentChatService.importExternalChatSession`, which creates a normal +lane-scoped `AgentChatSession`, stamps `importedFrom` provenance, seeds the ADE +transcript from the external history, and then binds future turns to the +imported provider identity. The full feature doc, including CLI imports, +provider storage, mobile routing, and testing constraints, is +[External Session Import](../terminals-and-sessions/external-session-import.md). + +Claude imports read the source JSONL from Claude project storage. When the +source cwd differs from the target lane, ADE makes a non-destructive copied +Claude transcript with a new session id in the target lane's Claude project +folder, then points the Claude SDK resume state at that id. Codex imports read +thread turns from the app-server with `thread/read`; fork imports first ask +Codex for a new thread with `thread/fork` and then seed ADE from that thread. + +Transcript seeding is bounded. `externalChatHistoryImport.ts` reads only the +last `MAX_IMPORT_TRANSCRIPT_BYTES` (`32 MB`) from file-backed transcripts and +keeps the newest 2,000 imported content events. It prepends system notices for +the import provenance and for any byte/event truncation, maps supported user, +assistant, tool, command, file-change, search, and image events into ADE's +`AgentChatEventEnvelope` stream, and derives a fallback title from the first +imported user/assistant text when the caller did not provide one. + ## Session lifecycle 1. `createSession({ laneId, provider, model, modelId?, permissionMode?, diff --git a/docs/features/sync-and-multi-device/README.md b/docs/features/sync-and-multi-device/README.md index 6468afea2..b0248b3c8 100644 --- a/docs/features/sync-and-multi-device/README.md +++ b/docs/features/sync-and-multi-device/README.md @@ -313,14 +313,21 @@ Canonical files (`apps/ade-cli/src/services/sync/`): - `syncRemoteCommandService.ts` (~3,280 lines) — command registry (lanes, chat, git, PR, sessions, conflicts, files, `prs.getMobileSnapshot`, `lanes.presence.*`, `work.runQuickCommand`, - `work.startCliSession`, `modelPicker.*`, …). Each registration carries a - `SyncRemoteCommandDescriptor` with a **scope** label of + `work.startCliSession`, `work.listExternalSessions`, + `work.importExternalSession`, `modelPicker.*`, …). Each registration + carries a `SyncRemoteCommandDescriptor` with a **scope** label of `"runtime"` or `"project"`. The runtime rejects a `project`-scoped command when no project is open or when the caller did not bundle a matching `projectId` (see *Scope enforcement* below). Mobile / controller CLI launches resolve the target lane worktree before building provider argv/env so Agent Skill roots and - `ADE_AGENT_SKILLS_DIRS` stay lane-aware. + `ADE_AGENT_SKILLS_DIRS` stay lane-aware. External-session imports share + the desktop external-session service and DTOs: list returns provider + summaries and import returns either a tracked CLI PTY id or a native ADE + chat id. See + [External Session Import](../terminals-and-sessions/external-session-import.md) + for the provider storage formats, host/runtime requirements, and mobile + testing constraints. Lane snapshot commands accept decoration flags so mobile can refresh runtime/session buckets without recomputing conflict status, rebase suggestions, or auto-rebase status on every light refresh; lane detail @@ -447,7 +454,9 @@ runtime connection or sync authority connection. The shared protocol DTOs (`SyncEnvelope`, controller-originated `terminal_input` / `terminal_resize`, the mobile CLI launcher payload — `SyncCliLaunchProvider`, `SyncStartCliSessionArgs`, -`SyncStartCliSessionResult` — and so on) live in +`SyncStartCliSessionResult` — the external session aliases +`SyncListExternalSessionsArgs` / `SyncImportExternalSessionArgs`, and so on) +live in `apps/desktop/src/shared/types/sync.ts`. The CLI launcher's provider-to-argv translation is shared with the desktop Work tab through `apps/desktop/src/shared/cliLaunch.ts`. @@ -463,6 +472,7 @@ iOS service files (`apps/ios/ADE/Services/`): command routing, keychain integration, PIN-based pairing, lane presence announcements, terminal subscribe/unsubscribe tracking, terminal input/resize senders, mobile CLI launch/continuation, + external-session list/import commands for Work, PR mobile snapshot fetch, live chat-event push listener, lane reparent payload building with the optional stack base-branch override, project home/catalog state, active-project scoping, @@ -779,7 +789,7 @@ payload. | Terminal stream/control | Subscribe to PTY output from the runtime; send input bytes and viewport resize events back to the subscribed PTY | iOS Work tab | | Chat stream | Agent chat transcript events. Each `chat_event` carries a host-assigned per-session monotonic `seq` backed by a capped replay buffer (500 events / 2 MB per session); per-session history is evicted with a 64-session LRU so a phone that has opened many chats cannot pin unbounded host memory. `chat_subscribe` accepts `sinceSeq`: gaps the buffer covers replay as ordinary events; uncoverable gaps fall back to a snapshot, and a non-resumed ack tells the client to drop its stale seq watermark (seq epochs restart at 1 on a new host). The snapshot is a byte-capped tail: `chat_subscribe` also carries the client's `maxBytes`, and the host clamps the snapshot's `getChatEventHistory` budget to `min(host cap, maxBytes)` — for a mobile-sized budget even the newest oversize event is dropped rather than force-included, so a phone never receives a snapshot larger than it asked for. Snapshot events are marked as already-sent to that peer, so the follow-on live pump does not re-deliver the overlap. The ack also carries `turnActive` from the live agent chat service — because the snapshot is a byte-capped tail, a long turn's `status: started` event can fall outside the window and the flag is what lets a mid-turn subscriber render streaming/stop affordances without waiting on the changeset pump (a full ack without the flag tells the client to drop any latched hint). **Cross-project "quick look":** `chat_subscribe` / `chat_unsubscribe` accept an optional `projectId` / `projectRootPath` override. When it names a registered project OTHER than the socket's active one — and the host advertised the `crossProjectChat` feature flag — the host serves that session's transcript **read-only straight off the foreign project's `.ade` transcript JSONL** (byte-capped tail snapshot, then the pump tails the same file for live events), with no project switch and no runtime boot for that project. Such sessions have no live agent chat service here, so `turnActive` is omitted and the client derives turn state from the streamed `status` events. The transcript resolver is the security boundary — it validates the project is registered and confines the path to that project's transcripts dir. A host without a foreign-chat provider never sets `crossProjectChat`, so the phone falls back to a full project activation. A `session_meta_updated` `chat_event` carrying a client's permission/interaction/mode change also rides this stream, so a mode switch made on one client (desktop ↔ iOS) patches every subscribed client's cached summary and composer controls live without a refetch | iOS Work tab, iOS Hub, controller chat | | Chat roster | Machine-wide all-projects projection of every project's lanes + work sessions grouped by lane — agent chats, their attached shell rows, and standalone CLI (tracked terminal) sessions, live **and** ended (`run-shell` infrastructure rows excluded) — so the mobile Hub renders every project's sessions at once **without activating each project**. `roster_subscribe` (handshake mirrors `chat_subscribe`, with an optional `sinceSeq`) → `roster_snapshot` then incremental `roster_delta` (`changed` upserts whole project entries, `removed` lists dropped `projectId`s). Un-booted projects are read cheaply from disk — each project's `/.ade/ade.db` (read-only, no cr-sqlite / no runtime boot) plus `.ade/cache/chat-sessions/*.json` — so their session status is limited to the last-persisted `idle`/`ended`/`awaiting`; live `running`/`awaiting` fidelity is overlaid only for scopes currently booted on the runtime (booted scopes also overlay PTY liveness so a live standalone CLI session reads `running`). `attentionCount` counts awaiting/failed **chat** rows and their attached shells only — standalone CLI failures never count, so a long-dead CLI exit can't pin a project to the top of the hub. Rows carry `toolType` so the phone routes chat rows to the chat surface and CLI rows to the terminal path. Transcripts are excluded from the roster (they load on demand when a chat opens): on a host advertising `crossProjectChat` the phone opens a foreign-project chat as a read-only cross-project quick look (see the Chat stream row) without activating that project; CLI rows never take the quick look (no chat JSONL — they always activate + open the terminal), and only on older hosts lacking the flag does opening a chat fall back to activating the project's full sync. Oversized snapshots ride the generic `envelope_chunk` path. A host without a roster provider (single-project desktop) simply never answers `roster_subscribe`, so the phone falls back to the active project only | iOS Hub | -| Command routing | Send named actions (`chat.send`, `lanes.create`, `git.push`, `prs.getMobileSnapshot`, etc.) | Controller devices | +| Command routing | Send named actions (`chat.send`, `lanes.create`, `git.push`, `prs.getMobileSnapshot`, `work.listExternalSessions`, `work.importExternalSession`, etc.) | Controller devices | | Project switching | `project_catalog` + `project_switch_request/result` for multi-project runtimes | iOS project home | | Project actions | Runtime-scoped project browser plus open/create/clone/list-GitHub-repos/default-parent-dir/forget envelopes. Available from the active project host or the machine-wide fallback handler before a project is selected | iOS project home | | Runtime status | Runtime broadcasts cluster/version status (`brain_status` is the legacy envelope name) | All devices | @@ -820,6 +830,32 @@ the phone trusts the runtime. See `remote-commands.md` for the full action set and the runtime / project scope split. +## External session import commands + +Paired controllers can browse and import provider-native CLI sessions through +the same runtime command registry that starts Work CLI sessions. The full +feature detail lives in +[External Session Import](../terminals-and-sessions/external-session-import.md). + +| Command | Policy | Purpose | +|---|---|---| +| `work.listExternalSessions` | `viewerAllowed: true` | Returns `ExternalSessionSummary[]` from the runtime's external-session service. Payload mirrors `ExternalSessionListArgs` (`providers`, `laneId`, `cwd`, `scope`, `limit`). | +| `work.importExternalSession` | `viewerAllowed: true`, `queueable: true` | Imports one external session into a lane as either `target: "cli"` (`ExternalSessionImportResult.kind = "cli"`, with `sessionId`/`ptyId`) or `target: "chat"` (`kind = "chat"`, with `chatSessionId`). Payload mirrors `ExternalSessionImportArgs` (`provider`, `sessionId`, `laneId`, `target`, `mode`, optional `model`/`permissionMode`). | + +These commands are viewer-allowed for the same reason as +`work.startCliSession`: a paired phone or desktop controller is already a +trusted controller for the runtime machine. The controller never reads provider +session files or launches provider CLIs locally; it sends a command envelope, +and the sync authority runtime does discovery, cwd validation, chat transcript +seeding, PTY creation, and provider resume/fork execution on the host. + +The feature must be present on the host brain the controller is paired to. +Desktop can point at an isolated lane-built brain with an isolated `ADE_HOME`, +but mobile normally cannot because there is one sync host on the shared +port/mDNS/tunnel surface. Real mobile E2E therefore requires the paired host to +contain the external-session service and `work.*` commands, either because the +feature is merged or because a deliberately isolated-port host is running. + ## Security model - **Device-bound pairing (DPoP)**: iOS keeps a P-256 key in the Secure diff --git a/docs/features/terminals-and-sessions/README.md b/docs/features/terminals-and-sessions/README.md index 68fd5ee13..c5ab67061 100644 --- a/docs/features/terminals-and-sessions/README.md +++ b/docs/features/terminals-and-sessions/README.md @@ -32,6 +32,14 @@ or historical `ProcessRuntime` rows in memory, each identified by `runId`. The Run page renders those runs on a single card and the aggregate persisted snapshot (the most recent run) is what lives in the `process_runtime` table. +## External session import + +Users can browse provider-native CLI sessions created outside ADE and continue +or fork them inside Work. See +[External Session Import](external-session-import.md) for the intended use +case, provider storage formats, capability matrix, CLI/chat import paths, +mobile routing, authorization model, and current open items. + ## Source file map Service files. Same sources back the ADE runtime and the limited @@ -103,6 +111,20 @@ and in tests. managed process tests. - `apps/desktop/src/main/services/lanes/laneLaunchContext.ts` — per-lane cwd resolution that gates PTY creation to the lane worktree. +- `apps/desktop/src/main/services/externalSessions/` — + external CLI session discovery and import. `externalSessionsService.ts` + orchestrates provider discovery, capability flags, project/all scoping, + already-imported detection, active-session hints, CLI import into tracked + PTYs, chat import delegation, cwd checks, and provider-specific resume/fork + commands. The per-provider discovery modules scan Claude JSONL transcripts + under `~/.claude/projects`, Codex sessions under `~/.codex/sessions`, + Cursor transcripts under `~/.cursor/projects`, Droid sessions under + `~/.factory/sessions`, and OpenCode through `opencode session list`. + `claudeSessionTransplant.ts` performs the non-destructive Claude JSONL copy + used when forking or importing a Claude session into a different lane cwd; + `discoveryUtils.ts` owns safe filesystem reads, cwd slug resolution, cheap + previews, limits, and sorting. Deep detail: + [external-session-import.md](external-session-import.md). Shared types and IPC: @@ -121,6 +143,12 @@ Shared types and IPC: `TerminalSerializedSnapshot`, `ChatTerminalPreviewArgs`, `ChatTerminalPreviewResult`) used by the `ade.terminal.*` IPC surface and the `terminal` ADE action domain. +- `apps/desktop/src/shared/types/externalSessions.ts` — + `ExternalSessionProvider`, `ExternalSessionCapabilities`, + `ExternalSessionSummary`, `ExternalSessionListArgs`, + `ExternalSessionImportArgs`, and `ExternalSessionImportResult`. This is + the canonical DTO surface shared by desktop IPC, the ADE action domain, + `ade code`, sync remote commands, and iOS. - `apps/desktop/src/main/services/probeLocalhostPort.ts` — tiny shared helper (`probeLocalhostPort(port, timeoutMs)`) that performs a single 127.0.0.1 TCP probe with a default 150 ms timeout. Used by the @@ -137,7 +165,10 @@ Shared types and IPC: the mobile CLI launcher payload (`SyncCliLaunchProvider`, `SyncStartCliSessionArgs`, `SyncStartCliSessionResult`) consumed by the - `work.startCliSession` remote command. + `work.startCliSession` remote command and the external-session remote + command aliases (`SyncListExternalSessionsArgs` / + `SyncImportExternalSessionArgs`) consumed by + `work.listExternalSessions` and `work.importExternalSession`. - `apps/desktop/src/shared/types/config.ts` — `ProcessDefinition` (now carries `groupIds: string[]`), `ProcessGroupDefinition`, `ProcessRuntime` (now carries `runId`), `ProcessRuntimeStatus`, @@ -153,12 +184,18 @@ Shared types and IPC: session-owned `ade.terminal.*` family (`list`, `read`, `preview` — serialized xterm snapshot for the TUI / mobile renderers, `write`, `signal`, `activeForChat`), and the localhost-probe helper - `ade.localhost.probePort`. + `ade.localhost.probePort`, plus `ade.externalSessions.list` and + `ade.externalSessions.import`. Preload bridge: - `apps/desktop/src/preload/preload.ts` — `window.ade.sessions`, - `window.ade.pty`, and `window.ade.processes` APIs. + `window.ade.pty`, `window.ade.processes`, and + `window.ade.externalSessions` APIs. The external-session calls prefer + the runtime `external-sessions` ADE action domain and fall back to the + legacy desktop IPC handlers only when no runtime binding exists. +- `apps/desktop/src/preload/global.d.ts` — renderer-visible typing for + the `window.ade.externalSessions.list/import` bridge. IPC registration: @@ -169,7 +206,8 @@ IPC registration: `ptyDispose`, the `processes.*` handlers, and the session-owned `terminalList` / `terminalRead` / `terminalWrite` / `terminalSignal` / `terminalActiveForChat` - handlers. `terminalRead` delegates transcript-tail reads to + handlers, plus `externalSessionsList` / `externalSessionsImport`. + `terminalRead` delegates transcript-tail reads to `ptyService` so attached terminal panels and `ade code` get the same live-tail merge as the Work tab. @@ -198,6 +236,19 @@ Renderer surfaces: sidebar is open and the view mode is not `grid`, the work view area shares its row with `WorkSidebar` via a flex container with a draggable column separator. +- `apps/desktop/src/renderer/components/terminals/importSessions/` — + desktop import browser, bridge contract, and pure capability-to-action + affordance mapper. It lists external sessions by provider/search, + shows provider/cwd/message-count/imported/possibly-active badges, and + offers only the safe actions for the target lane (`Open as ADE chat`, + `Fork as ADE chat`, `Resume here`, `Fork into this lane`, or + `Resume` in the original folder). +- `apps/desktop/src/renderer/components/chat/AgentChatPane.tsx` — + Work draft/new-chat surface. In draft mode the lane picker stays at + the top, with Shell and Import buttons below; Import opens + `ImportSessionBrowser` when the caller provides `onImportedSession`. + Auto-created lane launches keep import disabled because there is no + existing target lane to import into yet. - `apps/desktop/src/renderer/components/terminals/WorkSidebar.tsx` — right-edge sidebar tied to the active lane (and active Work session when present). Tabbed into `git` (lane git actions + selection-driven @@ -263,7 +314,8 @@ Renderer surfaces: card also reports its multi-select state via `isMultiSelected`. While the card's lane is mid background AI auto-naming it swaps the preview line for an "Auto-naming lane underway…" status and warm-highlights the - title when it changes (subscribes to `laneNamingStore.ts`). + title when it changes (subscribes to `laneNamingStore.ts`). Sessions + carrying `importedFrom` provenance render an `Imported` badge. - `apps/desktop/src/renderer/state/laneNamingStore.ts` — ephemeral, renderer-only zustand store tracking which lanes have an AI auto-naming pass in flight. `setLaneNaming(laneId, on)` is the @@ -488,6 +540,23 @@ Renderer surfaces: to disk via a transient anchor + Object URL. Used by the bulk-export action in the session list. +ADE CLI / TUI runtime surfaces: + +- `apps/ade-cli/src/bootstrap.ts` — constructs the shared + `externalSessionsService` inside the daemon/runtime alongside + `ptyService`, `sessionService`, and `agentChatService`, making the + `external-sessions` ADE action domain available to desktop, `ade code`, + sync remote commands, and headless runtimes. +- `apps/ade-cli/src/tuiClient/externalSessionBrowser.ts` and + `apps/ade-cli/src/tuiClient/app.tsx` — `ade code` import browser. + It reuses the same shared DTOs and affordance mapper as desktop, then + calls `external-sessions.list` / `external-sessions.import` through the + TUI action connection. +- `apps/ade-cli/src/services/sync/syncRemoteCommandService.ts` — + registers `work.listExternalSessions` and + `work.importExternalSession` for trusted paired controllers. See + [Sync and multi-device](../sync-and-multi-device/README.md#external-session-import-commands). + iOS Work surfaces: - `apps/ios/ADE/Views/Work/WorkRootScreen.swift`, @@ -519,7 +588,59 @@ iOS Work surfaces: composer, command/tool/reasoning cards, and new-chat launch surface. `WorkNewChatScreen` segments between **ADE chat** and **CLI session**; the CLI mode submits `work.startCliSession` against the host through - `SyncService.startCliSession`. + `SyncService.startCliSession`, and the Import entry opens the external + session browser. +- `apps/ios/ADE/Views/Work/WorkImportSessionScreen.swift` — iOS import + browser/action sheet. It calls `SyncService.listExternalSessions` and + `SyncService.importExternalSession`, mirrors the desktop capability + affordances, and routes CLI imports to the terminal screen or chat + imports to the chat screen. + +## External CLI session import + +ADE can adopt provider-native CLI sessions that were started outside ADE in a +plain terminal. Discovery is read-only: the runtime scans each provider's +local session storage, returns recent sessions with title/preview/cwd metadata, +and marks rows that already have ADE provenance or whose source file changed in +the last couple of minutes. The default list scope is the current ADE project +(including lane worktrees); callers can request `scope: "all"` to browse the +user's wider provider history. + +Import has two targets: + +| Target | Providers | Result | +|---|---|---| +| CLI | Claude, Codex, Cursor, Droid, OpenCode | Starts a tracked ADE PTY (`terminal_sessions` row) that resumes or forks the provider CLI. The session keeps normal Work behavior: transcript capture, lane association, continuation composer, sync terminal streaming, and the `Imported` badge. | +| ADE chat | Claude, Codex | Creates a native `AgentChatSession`, seeds the ADE transcript from the external provider history, and binds the provider runtime to the imported Claude session id or Codex thread id. See [Chat](../chat/README.md#external-chat-import). | + +Provider capabilities are intentionally explicit because each upstream CLI has +different continuation rules: + +| Provider | Resume in source cwd | Resume in another lane cwd | Fork | Fork into another lane cwd | ADE chat import | +|---|---:|---:|---:|---:|---:| +| Claude | yes | no | yes | yes | yes | +| Codex | yes | yes | yes | yes | yes | +| Cursor | yes | no | no | no | no | +| Droid | yes | no | if installed CLI supports `--fork` | if installed CLI supports `--fork` | no | +| OpenCode | yes | no | yes | no | no | + +The cwd rule is the sharp edge. Claude, Cursor, Droid, and OpenCode sessions +were born in a specific folder and their resume command must run there. +If that folder is not the selected lane, ADE either offers a fork path that +can land in the lane (Claude, Codex, Droid when supported) or a clearly labeled +resume-in-place action that runs in the original folder with +`allowExternalCwd`. Codex threads are cwd-portable, so Codex resume can target +the selected lane directly. + +Resume means "take the baton": ADE starts a tracked continuation of the same +provider session/thread. If the original terminal is still active, both tools +can race on the same provider state, so the UI warns on recently modified +sessions and nudges users to close the other terminal or fork. Fork means "make +a branch": ADE asks the provider for a new continuation target where possible. +Claude cross-lane fork/import copies the source JSONL into the target lane's +Claude project storage with a new session id instead of moving or editing the +original file; Codex uses its native thread fork path; Droid and OpenCode use +their CLI fork flags within the limits above. ## Detail docs @@ -797,6 +918,10 @@ Processes (managed): ## Cross-links +- External session import: + [external-session-import.md](external-session-import.md) — provider-native + session discovery/import, the Open/Fork x ADE-chat/CLI-session model, and + mobile/host constraints. - Lanes feature: [lanes/](../lanes/) - Files surface used by terminals for the transcript: see [../files-and-editor/](../files-and-editor/) (the file watcher is diff --git a/docs/features/terminals-and-sessions/external-session-import.md b/docs/features/terminals-and-sessions/external-session-import.md new file mode 100644 index 000000000..9eb4d7723 --- /dev/null +++ b/docs/features/terminals-and-sessions/external-session-import.md @@ -0,0 +1,348 @@ +# External Session Import + +Users often run AI coding CLIs outside ADE in ordinary terminal windows: +Claude Code, Codex, Cursor `cursor-agent`, Factory Droid, and OpenCode. Those +tools persist their own sessions on disk, or expose a provider-native session +list. ADE's external-session import feature lets the user browse those sessions +from the Work surface and continue one inside ADE. + +The user-facing mental model is a 2x2: + +| Target | Open | Fork | +|---|---|---| +| ADE chat | Continue the provider session as a native ADE chat with imported history. Claude and Codex only. | Create a provider copy and open that copy as a native ADE chat. Claude and Codex only. | +| CLI session | Continue the provider session in a tracked ADE terminal. | Start a copied provider session in a tracked ADE terminal when the provider supports it. | + +"Open" means ADE continues the original provider-native session. The user should +not also keep that same session active in another terminal. "Fork" means ADE +starts from a copy; the original provider session remains untouched. + +The reverse direction also matters: sessions created or imported in ADE remain +resumable from the provider CLI because ADE records and, for fresh launches, +seeds the provider-native identifiers. Tracked CLI sessions persist +`TerminalResumeMetadata`; Claude chat imports seed `sdkSessionId` and mirror a +Claude session pointer; Codex chat imports bind the ADE session to the provider +thread id. Fresh ADE launches follow the same rule: for example Claude CLI can +receive a preassigned `--session-id`, while Codex/Cursor/Droid/OpenCode +continuation metadata is recorded as soon as ADE knows the provider target. + +## Source file map + +| Path | Role | +|---|---| +| `apps/desktop/src/main/services/externalSessions/externalSessionsService.ts` | Service entry point. Runs provider discovery, applies the capabilities matrix, filters project/all scope, detects already-imported sessions, validates import ids, enforces optional lane cwd scope, builds CLI resume/fork commands, delegates chat import, and creates tracked PTYs. | +| `apps/desktop/src/main/services/externalSessions/discoveryUtils.ts` | Shared discovery helpers: safe stat/read, top-N mtime sorting, JSONL prefix/suffix scans, preview clipping, placeholder-title cleanup, Claude/Cursor cwd slug helpers, shell quoting, and path-inside checks. | +| `apps/desktop/src/main/services/externalSessions/discoverClaude.ts` | Discovers Claude JSONL transcripts under `CLAUDE_CONFIG_DIR` or `~/.claude/projects//.jsonl`; reads cwd/timestamps/title/preview from the newest candidates. | +| `apps/desktop/src/main/services/externalSessions/discoverCodex.ts` | Discovers Codex rollout JSONL files under `~/.codex/sessions/YYYY/MM/DD/` and enriches them from `~/.codex/session_index.jsonl`. | +| `apps/desktop/src/main/services/externalSessions/discoverCursor.ts` | Discovers Cursor `cursor-agent` transcripts under `~/.cursor/projects//agent-transcripts//`, excludes SDK-origin `agent-` transcripts, and best-effort resolves cwd from transcript rows or project slugs. | +| `apps/desktop/src/main/services/externalSessions/discoverDroid.ts` | Discovers Factory Droid JSONL sessions under `~/.factory/sessions//`, using the `session_start` row for id/cwd/title. | +| `apps/desktop/src/main/services/externalSessions/discoverOpenCode.ts` | Discovers OpenCode sessions by running `opencode session list --format json --max-count ` in the requested/project cwd. | +| `apps/desktop/src/main/services/externalSessions/claudeSessionTransplant.ts` | Non-destructive Claude transcript transplant. For forks it copies JSONL rows, rekeys `sessionId`, hard-links without clobbering, and leaves the source untouched; for moves it can link/unlink when requested by other callers. | +| `apps/desktop/src/shared/cliLaunch.ts` | Canonical CLI launch/resume/fork command builders. External import uses `buildTrackedCliResumeCommand`, `withCodexNoAltScreen`, provider permission/model mappings, and shell quoting from here. | +| `apps/desktop/src/shared/types/externalSessions.ts` | Canonical DTOs shared by desktop IPC, ADE actions, sync remote commands, `ade code`, and iOS. | +| `apps/desktop/src/main/services/chat/externalChatHistoryImport.ts` | Converts external Claude JSONL and Codex app-server thread history into ADE chat event envelopes with byte/event caps and provenance/truncation notices. | +| `apps/desktop/src/main/services/chat/agentChatService.ts` | Owns `importExternalChatSession`. Creates the ADE chat session, imports Claude/Codex history, seeds Claude `sdkSessionId` or Codex `threadId`, persists provenance, and cleans up failed Codex forks. Statically imports the Claude transplant module for the packaged brain bundle. | +| `apps/desktop/src/shared/ipc.ts`, `apps/desktop/src/main/services/ipc/registerIpc.ts` | Defines and registers `ade.externalSessions.list` and `ade.externalSessions.import` for the legacy desktop in-process fallback. | +| `apps/desktop/src/preload/preload.ts`, `apps/desktop/src/preload/global.d.ts` | Exposes `window.ade.externalSessions.list/import`. The bridge first calls the bound project runtime's `external-sessions` ADE action domain and falls back to desktop IPC only when no runtime is bound. | +| `apps/desktop/src/main/services/adeActions/registry.ts` | Registers the `external-sessions` ADE action domain (`list`, `import`) against the runtime service. | +| `apps/desktop/src/main/main.ts` | Constructs `externalSessionsService` for desktop-owned project runtimes and injects it into IPC, ADE actions, sync, and runtime context. | +| `apps/ade-cli/src/bootstrap.ts` | Constructs the same service for the headless ADE brain/runtime so remote-bound desktop windows and the mobile sync host expose the feature. | +| `apps/ade-cli/src/adeRpcServer.ts` | Authorizes `run_ade_action` calls. Non-CTO callers are lane-scoped for `external-sessions`; CTO callers can use the domain unscoped. | +| `apps/ade-cli/src/services/sync/syncRemoteCommandService.ts`, `apps/desktop/src/main/services/sync/syncRemoteCommandService.ts` | Registers `work.listExternalSessions` and `work.importExternalSession` for paired controllers. The desktop file is a re-export of the ade-cli implementation. | +| `apps/desktop/src/shared/types/sync.ts` | Sync command DTO aliases for external-session list/import payloads and results. | +| `apps/desktop/src/renderer/components/terminals/importSessions/ImportSessionBrowser.tsx` | Desktop import browser: provider filters, project/all scope toggle, progressive per-provider scans, imported/active badges, preview disclosure, Open-in-ADE, and action dispatch. | +| `apps/desktop/src/renderer/components/terminals/importSessions/affordances.ts` | Pure capability-to-action mapper for the 2x2 Open/Fork x ADE-chat/CLI-session UI. Shared with the TUI. | +| `apps/desktop/src/renderer/components/terminals/importSessions/contract.ts` | Renderer bridge/types/display helpers for external sessions. | +| `apps/desktop/src/renderer/components/terminals/useWorkSessions.ts` | Adopts import results into the Work surface and focuses existing imported sessions without re-importing. | +| `apps/desktop/src/renderer/components/chat/AgentChatPane.tsx`, `apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx`, `apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx` | Wires the import browser into the Work draft/new-session surface and routes imported or already-imported sessions to the selected Work tab. | +| `apps/ade-cli/src/tuiClient/externalSessionBrowser.ts`, `apps/ade-cli/src/tuiClient/components/RightPane.tsx` | ADE Code TUI helpers and right-pane rendering for the same external-session DTOs and affordance mapper. | +| `apps/ios/ADE/Models/RemoteModels.swift` | iOS Codable mirrors for `ExternalSessionSummary`, capabilities, imported refs, and import results. | +| `apps/ios/ADE/Services/SyncService.swift` | iOS client methods for `work.listExternalSessions` and `work.importExternalSession`. | +| `apps/ios/ADE/Views/Work/WorkNewChatScreen.swift` | Adds the Import session affordance when a concrete lane is selected. | +| `apps/ios/ADE/Views/Work/WorkImportSessionScreen.swift` | iOS import browser. Mirrors the 2x2 action model, project/all scope, provider chips, real provider logos, Open-in-ADE, imported/active badges, and optimistic navigation after import. | +| `apps/ios/ADE/Views/Work/WorkRootComponents.swift`, `apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift`, `apps/ios/ADE/Views/Components/ADEDesignSystem.swift` | Shared iOS provider logos, fallback symbols, and provider accent colors consumed by the import screen. | + +## Architecture + +### Discovery + +`externalSessionsService.list` fans out to one provider module per requested +provider, catches provider-specific failures, merges the rows, filters to the +current project scope unless `scope: "all"` is requested, and returns +`ExternalSessionSummary[]` sorted by `updatedAt` descending. + +File-backed providers are stat-first. Discovery gathers candidate files, sorts +by mtime, and only reads the newest top-N candidates. The cheap JSONL read is +bounded by `JSONL_SCAN_BYTE_LIMIT` and `JSONL_SCAN_LINE_LIMIT`; line counts are +only computed for files under `MESSAGE_COUNT_MAX_BYTES`. OpenCode is the +exception because its supported interface is the CLI list command, so discovery +runs `opencode session list --format json --max-count ` in the requested +cwd, project root, or home directory. + +Titles and previews are deliberately separate: + +- `title` is a real provider-persisted title, or `null`. Discovery must not use + the first user message as a title. +- `preview` is a distinct snippet, usually the first user message after ADE + guidance stripping, or another safe message snippet when needed. + +The desktop and iOS rows use the real title when present. When title is null, +they fall back to a path/time heading so the heading never duplicates the +preview. Placeholder titles such as "New Session" are normalized to null. + +The service also stamps: + +- `alreadyImported` and `importedSessionRef`, by scanning ADE + `terminal_sessions` resume metadata and Claude session pointers. Chat refs + outrank CLI refs so the UI can offer one "Open in ADE" action. +- `possiblyActive`, when the backing file mtime is within the recent active + window. +- `cwdMatchesRequestedLane`, comparing the provider cwd to the requested lane + cwd when known. + +### Capabilities matrix + +The backend is the source of truth for action availability. The renderer and +iOS app only map capability flags to buttons. + +| Capability | Meaning | +|---|---| +| `resumeInPlace` | The provider can continue the original session in the cwd where it was created. | +| `resumeInDifferentCwd` | The provider can continue the original session while running in the target ADE lane cwd. | +| `fork` | The provider can start a copied/branched session without mutating the original. | +| `forkIntoDifferentCwd` | The provider can start that copy in the target ADE lane cwd even when the source cwd differs. | +| `importToChat` | ADE can convert the provider history into a native ADE chat and continue from there. | + +Current provider flags: + +| Provider | Flags | Why | +|---|---|---| +| Claude | `resumeInPlace`, `fork`, `forkIntoDifferentCwd`, `importToChat` | Claude CLI resume is cwd-scoped. Same-cwd CLI fork uses `--fork-session`; cross-cwd fork copies/rekeys the JSONL into the target lane's Claude project folder. Claude chat import can seed SDK resume state from JSONL. | +| Codex | all five | Codex app-server threads are portable across cwd for ADE's purposes. CLI resume/fork runs in the lane cwd, and chat import uses app-server `thread/read` plus `thread/fork` for forks. | +| Cursor | `resumeInPlace` only | `cursor-agent` resumes are cwd-scoped and there is no supported fork path. SDK-origin `agent-` transcripts are excluded because `cursor-agent --resume` would start empty. | +| Droid | `resumeInPlace`, plus `fork` and `forkIntoDifferentCwd` only when the installed CLI exposes `--fork` | Droid resume is cwd-locked. Factory documents `droid --fork`, but older installed CLIs do not support it, so ADE probes `droid --help` and disables fork until support is confirmed. | +| OpenCode | `resumeInPlace`, `fork` | OpenCode sessions are project/cwd-scoped. ADE can use `--session`, `--continue`, and `--fork`, but fork runs in the session's own project cwd rather than transplanting into another lane cwd. | + +The first Droid list call can be conservative. Service construction starts the +`droid --help` probe immediately, but `list()` reports fork as unavailable until +the async probe resolves. Import does not have that race: Droid fork import +awaits the probe before launching or refusing. + +### CLI target + +`externalSessionsService.importExternalSession` validates the provider and +session id, resolves the target lane cwd, optionally enforces caller lane scope, +finds the external summary, chooses the run cwd, builds +`TerminalResumeMetadata`, then builds a provider command. + +Resume commands come from `buildTrackedCliResumeCommand` in +`apps/desktop/src/shared/cliLaunch.ts`. Fork commands mostly reuse the same +builder and provider-specific flags: + +- Claude same-cwd fork appends `--fork-session` to the Claude resume command. +- Claude cross-cwd fork calls `transplantClaudeSession` first, then resumes the + copied/rekeyed session id from the lane cwd. +- Codex fork rewrites the tracked resume command from `resume` to `fork` and + preserves `--no-alt-screen`. +- Droid fork launches `droid --fork ` after the installed-CLI probe says + `--fork` exists. +- OpenCode fork appends `--fork` to the OpenCode tracked resume command and + runs in the source project cwd. + +The final spawn is a tracked `ptyService.create` call with `tracked: true`, +provider `toolType`, `startupCommand`, direct shell launch fields, and +`resumeMetadata`. The import path currently lets `ptyService` allocate a fresh +ADE session id, so it does not pass `allowNewSessionId`; that flag is required +only for create/resume callers that preassign a new `sessionId`. Future changes +that preassign external-import session ids must set `allowNewSessionId: true` +or `ptyService.create` will treat the request as a missing-session resume. + +`resumeMetadata.importedFrom` records the original provider id and whether the +ADE session opened or forked it. That metadata is what lets later Work +continuation, `ade.pty.resumeSession`, and duplicate-import detection find the +provider target again. + +### Chat target + +Only Claude and Codex support `target: "chat"`. +`externalSessionsService.importExternalSession` delegates those imports to +`agentChatService.importExternalChatSession`, which creates a normal +lane-scoped `AgentChatSession`, stamps `importedFrom`, appends imported +history events, and binds future turns to the provider-native id. + +`externalChatHistoryImport.ts` is the history converter. It reads at most the +last 32 MB of file-backed transcript bytes and keeps the newest 2,000 imported +content events. The importer prepends system notices for provenance and +truncation, then maps user/assistant text plus supported tool calls/results, +commands, file changes, web searches, image generation, and image view events. +If the caller did not provide a title, the chat title falls back to the first +imported user or assistant text. + +Claude chat import reads JSONL from `CLAUDE_CONFIG_DIR` or +`~/.claude/projects`. If the user asks to fork, or if the source cwd differs +from the target lane cwd, ADE transplants the JSONL into the target lane's +Claude project folder under a fresh session id. The new ADE chat sets the +Claude runtime `sdkSessionId`, sets `claudeBackgroundResumeSessionId`, mirrors a +Claude pointer through `sessionService`, repairs known thinking-transcript id +collisions when needed, and then appends the imported ADE transcript events. + +Codex chat import creates a Codex ADE session and asks the app-server for +provider state. Open imports call `thread/read` with `includeTurns: true`. +Fork imports first call `thread/fork` with `excludeTurns: true`, then read the +forked thread. If import fails after creating a provider fork, ADE best-effort +archives the forked thread before deleting the ADE session. Successful imports +set `managed.session.threadId` and persist a `chat:codex:` resume +command. + +The Claude transplant dependency in `agentChatService.ts` must remain a static +import. A dynamic `import()` built from a variable path is not bundled into the +`ade-cli` brain `cli.cjs`; packaged/headless runtimes then fail at runtime with +a missing `externalSessions/claudeSessionTransplant` module. + +### Runtime routing + +The desktop renderer calls `window.ade.externalSessions.list/import`. The +preload bridge first calls `callProjectRuntimeActionIfBound` for the +`external-sessions` ADE action domain. This is the normal path for local-bound +and remote-bound windows. The legacy `ade.externalSessions.*` IPC handlers are +only fallback handlers when no project runtime is bound. + +The service is constructed in both runtime hosts: + +- `apps/desktop/src/main/main.ts` constructs it for desktop-owned project + runtimes. +- `apps/ade-cli/src/bootstrap.ts` constructs it for the headless ADE + brain/runtime. + +That dual construction is required. A remote-bound desktop window and the +mobile sync host talk to the brain/runtime, not to the renderer bundle. + +The ADE action domain is `external-sessions` with actions `list` and `import`. +Non-CTO agents calling it through `run_ade_action` are lane-scoped: the caller +must be authorized for the requested lane, list args are forced to the lane cwd +and project scope, and import args receive `enforceLaneScopeCwd`. CTO callers +are unscoped. + +### Mobile + +Mobile uses sync remote commands: + +- `work.listExternalSessions` +- `work.importExternalSession` + +Both are `viewerAllowed`; import is also `queueable`. This is intentional. A +paired phone is a trusted controller for the runtime machine, so it can ask the +host to list or import sessions even though it never reads provider session +files or launches provider CLIs locally. + +iOS mirrors the desktop model in `WorkImportSessionScreen`: provider chips, +project/all scope, provider logos, imported/possibly-active badges, the 2x2 +Open/Fork x ADE-chat/CLI-session actions, and "Open in ADE" for +`importedSessionRef`. `SyncService` sends the command envelopes and +`RemoteModels.swift` decodes the shared DTOs. + +## Provider gotchas + +### Claude + +Claude stores CLI sessions under +`~/.claude/projects//.jsonl`, or under +`CLAUDE_CONFIG_DIR/projects/...` when `CLAUDE_CONFIG_DIR` is set. Every Claude +path in discovery, CLI fork, and chat transplant must respect +`CLAUDE_CONFIG_DIR`. + +Claude resume is strictly cwd-scoped. Same-cwd CLI fork can use +`--fork-session`; cross-cwd fork has to copy/rekey the JSONL into the target +cwd's Claude project folder. Claude does not persist a reliable title in these +JSONLs, so ADE leaves `title` null and lets UI headings fall back to path/time. + +### Codex + +Codex stores rollout JSONL under `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` +and title/update metadata in `~/.codex/session_index.jsonl`. ADE treats the +thread id as the rollout UUID. Chat import and continuation use the Codex +app-server `thread/read`, `thread/fork`, `thread/archive`, resume, and fork +surfaces. + +Codex file-change history items use tagged enum shapes such as +`{ type: "add" }`, `{ type: "delete" }`, and `{ type: "update" }`; they are +not always bare strings. Keep `externalChatHistoryImport.ts` mapping aligned +with that shape. + +Codex resume/fork flags must match the current provider schema and CLI and +app-server contracts. Do not revive stale `ThreadResumeParams` assumptions when +editing `buildTrackedCliResumeCommand` or Codex chat import; update the tests +that assert the actual request payloads and command argv. + +### Cursor + +Cursor stores `cursor-agent` transcripts under +`~/.cursor/projects//agent-transcripts//`. De-slugging a project +folder back to a cwd is lossy. ADE can reliably recover `/Users/...` paths and +some existing local paths, but the transcript row's own cwd is preferred when +present. + +SDK-origin `agent-` transcripts are excluded because `cursor-agent` cannot +resume them as meaningful CLI sessions; it would start empty. Cursor has no fork +support and its resume is cwd-scoped. Cursor edit mode must not map to +`--mode ask`; `ask` is read-only. In `cliLaunch.ts`, only plan mode maps to +`--mode plan`, full-auto maps to `--force`, and edit/default omit a mode flag. + +### Droid + +Droid stores sessions under `~/.factory/sessions//*.jsonl`. The +first row must be `session_start`; ADE reads id, cwd, title, and timestamp from +that row. `"New Session"` is a placeholder title and becomes null. + +`droid --fork` is gated on an installed-CLI probe. Discovery can temporarily +show fork disabled while the probe is pending; import awaits the probe and +fails with a clear message if the installed binary lacks `--fork`. Resume is +cwd-locked. + +### OpenCode + +OpenCode discovery uses `opencode session list --format json`; ADE does not +walk OpenCode's private storage. Resume and fork commands use OpenCode's +`--session`, `--continue`, and `--fork` flags. Sessions are per-project by cwd, +so ADE resumes or forks in the source project cwd rather than transplanting +them into another lane cwd. + +## Testing constraints + +This feature must exist on both sides of the connection: the client surface and +the host brain it talks to. + +Desktop pre-merge testing can point the renderer at an isolated lane-built +brain by using an isolated `ADE_HOME` and runtime. That lets the desktop client +exercise the lane's `externalSessionsService`, ADE action domain, and PTY/chat +import paths before merge. + +Mobile is harder. The iOS app pairs to a single sync host. Two sync-enabled +brains conflict on the shared port, mDNS publication, and tunnel routing, so +the phone cannot casually point at an isolated lane brain while the normal host +is still serving. Real mobile E2E requires the feature to be present on the +host the phone actually pairs to, usually because the branch is merged, or +because a deliberately isolated-port sync host is running and the phone is +paired to that host. + +When mobile appears to "not have" the feature, check the host first. An updated +iOS client cannot import sessions if the paired brain does not expose +`work.listExternalSessions` and `work.importExternalSession`. + +## Known follow-ups / open items + +- No feature flag gates external-session import. If the service is constructed + and the UI is present, the feature is live. +- There are no TODO/FIXME markers in the current external-session service, + chat importer, desktop import UI, iOS import screen, sync command path, or + lane-scoped ADE action path. +- The Droid fork probe has a first-list conservative state: until `droid + --help` resolves, Droid summaries report `fork: false` and + `forkIntoDifferentCwd: false`. Refreshing after the probe resolves shows the + actual capability. Fork import itself awaits the probe. +- Keep the shared DTOs, desktop affordance mapper, iOS models, and sync command + payloads in lockstep. A server-only change will break mobile decoding or + hide buttons; a UI-only change will show actions the runtime rejects. +- Keep `agentChatService.ts`'s Claude transplant dependency static. Dynamic + imports can pass desktop dev runs and still fail in the packaged/headless + brain bundle.