From 7a9d4e941ca6d0bce9ff54b10f5b93f07964af8a Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 3 Aug 2026 04:13:17 -0400 Subject: [PATCH 1/2] ship: prepare lane for review External-session import overhaul (Codex state-DB authority, Claude chain dedupe, Cursor artifact merge, durable imported-refs, lineage marking) + auto-lanes (every git worktree is a lane; real delete; reap; attach UI gutted across desktop/web/CLI/iOS). Co-Authored-By: Claude Fable 5 --- apps/ade-cli/README.md | 5 +- apps/ade-cli/src/adeRpcServer.test.ts | 34 +- apps/ade-cli/src/adeRpcServer.ts | 19 +- apps/ade-cli/src/bootstrap.ts | 2 - apps/ade-cli/src/cli.ts | 62 +- apps/ade-cli/src/multiProjectRpcServer.ts | 8 +- .../src/services/sync/syncHostService.ts | 2 - .../services/sync/syncRemoteCommandService.ts | 33 +- apps/desktop/src/main/main.ts | 2 - .../main/services/adeActions/registry.test.ts | 4 +- .../src/main/services/adeActions/registry.ts | 4 - .../main/services/chat/agentChatService.ts | 6 +- .../externalSessions/claudeLiveSessions.ts | 73 + .../externalSessions/discoverClaude.ts | 260 +++- .../externalSessions/discoverCodex.ts | 772 ++++++----- .../externalSessions/discoverCursor.ts | 420 ++++-- .../externalSessions/discoverDroid.ts | 82 +- .../externalSessions/discoverOpenCode.ts | 8 +- .../discoverProviders.test.ts | 973 ++++++++++++-- .../externalSessions/discoveryUtils.ts | 182 ++- .../externalSessionsService.test.ts | 481 ++++++- .../externalSessionsService.ts | 367 ++--- .../externalSessions/importedSessionStore.ts | Bin 0 -> 8849 bytes .../src/main/services/ipc/registerIpc.ts | 37 +- .../src/main/services/ipc/runtimeBridge.ts | 13 - .../lanes/laneListSnapshotService.test.ts | 31 + .../services/lanes/laneListSnapshotService.ts | 5 +- .../main/services/lanes/laneService.test.ts | 1192 ++++++++++++++--- .../src/main/services/lanes/laneService.ts | 712 ++++++---- .../services/lanes/worktreeResidualCleanup.ts | 58 +- .../onboarding/onboardingService.test.ts | 6 - .../services/onboarding/onboardingService.ts | 64 +- ...moteRuntime.offlineRpc.integration.test.ts | 1 - .../services/sessions/sessionService.test.ts | 25 + .../main/services/sessions/sessionService.ts | 7 +- .../sync/syncRemoteCommandService.test.ts | 44 +- apps/desktop/src/preload/global.d.ts | 11 - apps/desktop/src/preload/preload.ts | 38 - apps/desktop/src/renderer/browserMock.ts | 32 +- .../automations/adeActionSchemas.ts | 25 - .../lanes/AdoptAttachedLaneConfirmDialog.tsx | 71 - .../components/lanes/AttachLaneDialog.tsx | 126 -- .../components/lanes/LaneContextMenu.test.tsx | 1 - .../components/lanes/LaneContextMenu.tsx | 3 - .../renderer/components/lanes/LanesPage.tsx | 301 +---- .../lanes/ManageLaneDialog.test.tsx | 21 +- .../components/lanes/ManageLaneDialog.tsx | 87 +- .../lanes/MultiAttachWorktreeDialog.tsx | 350 ----- .../components/lanes/laneContextMenuItems.tsx | 10 - .../onboarding/ProjectSetupPage.tsx | 2 - .../onboarding/WorktreesCard.test.tsx | 51 - .../components/onboarding/WorktreesCard.tsx | 322 ----- .../projects/ProjectWelcomePage.test.tsx | 49 +- .../projects/WorktreeOpenDialog.test.tsx | 74 +- .../projects/WorktreeOpenDialog.tsx | 4 +- .../projects/worktreeLaneFlow.test.ts | 45 +- .../components/projects/worktreeLaneFlow.ts | 51 +- .../prs/shared/PrLaneCleanupBanner.tsx | 3 +- .../prs/shared/PrManageLaneDialogHost.tsx | 5 +- .../terminals/WorkManageLaneDialogHost.tsx | 81 +- .../importSessions/ImportSessionBrowser.tsx | 34 +- .../terminals/useWorkLaneContextMenu.tsx | 2 - .../src/renderer/state/appStore.test.ts | 5 - .../src/renderer/webclient/adapter/lanes.ts | 3 - .../src/renderer/webclient/adapter/misc.ts | 1 - apps/desktop/src/shared/ipc.ts | 4 - apps/desktop/src/shared/types/chat.ts | 8 + apps/desktop/src/shared/types/core.ts | 8 - apps/desktop/src/shared/types/lanes.ts | 29 +- apps/ios/ADE.xcodeproj/project.pbxproj | 12 - apps/ios/ADE/Models/RemoteModels.swift | 7 - apps/ios/ADE/Services/Database.swift | 3 +- apps/ios/ADE/Services/SyncService.swift | 16 - apps/ios/ADE/Views/Lanes/AddLaneSheet.swift | 34 - .../ios/ADE/Views/Lanes/LaneActionsCard.swift | 229 ---- .../ios/ADE/Views/Lanes/LaneAttachSheet.swift | 91 -- apps/ios/ADE/Views/Lanes/LaneComponents.swift | 6 +- .../ADE/Views/Lanes/LaneDetailScreen.swift | 3 +- apps/ios/ADE/Views/Lanes/LaneHelpers.swift | 5 + .../ADE/Views/Lanes/LaneListViewParts.swift | 16 - .../ios/ADE/Views/Lanes/LaneManageSheet.swift | 44 +- .../Views/Lanes/LaneMultiAttachSheet.swift | 313 ----- apps/ios/ADETests/ADETests.swift | 24 +- docs/ARCHITECTURE.md | 6 +- docs/features/lanes/README.md | 252 +++- docs/features/lanes/worktree-isolation.md | 99 +- .../onboarding-and-settings/README.md | 12 +- .../onboarding-and-settings/first-run.md | 16 +- docs/features/sync-and-multi-device/README.md | 2 +- .../sync-and-multi-device/ios-companion.md | 25 +- .../sync-and-multi-device/remote-commands.md | 18 +- .../features/terminals-and-sessions/README.md | 13 +- .../external-session-import.md | 280 +++- 93 files changed, 5266 insertions(+), 4111 deletions(-) create mode 100644 apps/desktop/src/main/services/externalSessions/claudeLiveSessions.ts create mode 100644 apps/desktop/src/main/services/externalSessions/importedSessionStore.ts delete mode 100644 apps/desktop/src/renderer/components/lanes/AdoptAttachedLaneConfirmDialog.tsx delete mode 100644 apps/desktop/src/renderer/components/lanes/AttachLaneDialog.tsx delete mode 100644 apps/desktop/src/renderer/components/lanes/MultiAttachWorktreeDialog.tsx delete mode 100644 apps/desktop/src/renderer/components/onboarding/WorktreesCard.test.tsx delete mode 100644 apps/desktop/src/renderer/components/onboarding/WorktreesCard.tsx delete mode 100644 apps/ios/ADE/Views/Lanes/LaneActionsCard.swift delete mode 100644 apps/ios/ADE/Views/Lanes/LaneAttachSheet.swift delete mode 100644 apps/ios/ADE/Views/Lanes/LaneMultiAttachSheet.swift diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index fabcde19d..f803b5209 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -355,7 +355,8 @@ ade doctor --online --text # also check the latest deskto ade projects list --text ade projects inspect /path/to/checkout --json # classify a path (repo root vs linked/ADE-managed worktree) and find its owning project + existing lane ade init -ade lanes list --text +ade lanes list --text # every git worktree of the project is a lane: this reconciles against git on each call, adopting worktrees made outside ADE and dropping lanes whose worktree is gone from git and disk. There is no attach/adopt step +ade lanes import --branch feature/login --text # lane for an existing branch, checked out in a new managed worktree; refuses when that branch is already checked out (that worktree is already a lane) ade lanes create "fix-checkout-flow" --parent main ade lanes create "fix-login" --base origin/main # omit --base to branch from the configured new-lane base (remote-first by default) ade lanes child --lane lane-parent --name fix-followup # child lane carries the parent's unmerged work; a base-less `ade lanes create`/`--auto-create-lane` from a lane with commits not yet on main prints a non-blocking stderr nudge to use this instead @@ -501,6 +502,8 @@ ade --role cto actions list --domain attention --text # discover account-wide Ac ade --role cto actions run attention.getSnapshot --input-json '{"since":0}' --json ade actions run git.stageFile --arg laneId=lane-id --arg path=src/index.ts ade actions run pty.resumeSession --arg sessionId=session-id +ade actions run external-sessions.list --input-json '{"scope":"project","limit":20}' --text # claude/codex/cursor/droid/opencode sessions on this machine; discovery that cannot run — `opencode` is not installed, say — fails the call when that provider is the only one asked for, rather than reporting an empty list; in a multi-provider scan it is skipped and logged +ade actions run external-sessions.import --input-json '{"provider":"codex","sessionId":"thread-id","laneId":"lane-1","target":"cli","mode":"resume"}' --text ade cursor cloud agents list --text ade cursor cloud agents create --repo https://github.com/owner/repo --prompt "fix flaky test" --auto-pr ade --role cto github app-auth login # device-flow authorize the machine ADE GitHub App (headless/brain) diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index e721e6643..9289f6e7d 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -169,7 +169,6 @@ function createRuntime() { laneService: { list: vi.fn(async () => laneRows), ensurePrimaryLane: vi.fn(async () => laneRows[0]), - listUnregisteredWorktrees: vi.fn(async () => [{ path: "/tmp/untracked-worktree", branch: "feature/untracked" }]), getLaneWorktreePath: vi.fn((laneId: string) => { const lane = laneRows.find((row) => row.id === laneId) ?? laneRows[0]!; return lane.worktreePath; @@ -3165,16 +3164,11 @@ describe("adeRpcServer", () => { expect(response.structuredContent.message).toBe("generated commit message"); }); - it("lists and imports unregistered lane worktrees", async () => { + it("imports an existing branch as a lane", async () => { const fixture = createRuntime(); const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { callerId: "agent-1", role: "agent" }); - const listResponse = await callTool(handler, "list_unregistered_lanes", {}); - expect(listResponse?.isError).toBeUndefined(); - expect(fixture.runtime.laneService.listUnregisteredWorktrees).toHaveBeenCalledTimes(1); - expect(listResponse.structuredContent.worktrees[0].branch).toBe("feature/untracked"); - const importResponse = await callTool(handler, "import_lane", { branchRef: "feature/untracked", name: "Imported lane", @@ -4851,6 +4845,32 @@ describe("adeRpcServer", () => { }); }); + // Discovery for a provider whose CLI is missing now throws instead of + // returning [], and `ade actions run external-sessions.list` is the only way + // an agent reaches it. The message has to survive to the CLI's stderr as a + // normal action failure, because the alternative an agent sees is a stack + // trace it cannot act on. + it("reports a failed external-session discovery as a readable action error", async () => { + const fixture = createRuntime(); + const list = vi.fn(async () => { + throw new Error("OpenCode CLI not found: install `opencode` to import its sessions."); + }); + (fixture.runtime as any).externalSessionsService = { list, importExternalSession: vi.fn() }; + 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: { providers: ["opencode"], scope: "all" }, + }); + + expect(listed.isError).toBe(true); + expect(listed.error.message).toBe( + "OpenCode CLI not found: install `opencode` to import its sessions.", + ); + }); + it("allows the unbound ade CLI to use explicit-lane external-session actions", async () => { const fixture = createRuntime(); const persistedSession = { diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index 8a8b4788d..d8f022ccc 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -697,7 +697,7 @@ const TOOL_SPECS: ToolSpec[] = [ }, { name: "list_lanes", - description: "List active lanes with metadata and branch status.", + description: "List active lanes with metadata and branch status. Every git worktree of the project is a lane, so this also picks up worktrees created outside ADE and drops lanes whose worktree no longer exists.", inputSchema: { type: "object", additionalProperties: false, @@ -706,18 +706,9 @@ const TOOL_SPECS: ToolSpec[] = [ } } }, - { - name: "list_unregistered_lanes", - description: "List git worktrees that are not yet registered as ADE lanes.", - inputSchema: { - type: "object", - additionalProperties: false, - properties: {} - } - }, { name: "import_lane", - description: "Import an existing git branch/worktree into ADE lane tracking.", + description: "Create a lane for an existing git branch by checking it out in a new ADE-managed worktree. Fails when the branch is already checked out in another worktree, because that worktree is already a lane.", inputSchema: { type: "object", required: ["branchRef"], @@ -1378,7 +1369,6 @@ const READ_ONLY_TOOLS = new Set([ "get_lane_status", "get_lane_conflict_state", "list_lanes", - "list_unregistered_lanes", "git_get_sync_status", "git_list_branches", "git_get_user_identity", @@ -4189,11 +4179,6 @@ async function runTool(args: { }; } - if (name === "list_unregistered_lanes") { - const worktrees = await runtime.laneService.listUnregisteredWorktrees(); - return { worktrees }; - } - if (name === "get_lane_status") { const laneId = requireLaneIdForTool(runtime, session, toolArgs, "get_lane_status"); return await buildLaneStatus(runtime, laneId); diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index e014b319d..675273e1d 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -835,9 +835,7 @@ export async function createAdeRuntime(args: { logger, projectRoot, projectId, - baseRef, freshProject: !hadAdeDb, - laneService, projectConfigService, }); diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 7ae172881..ceb7199b5 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -1476,10 +1476,13 @@ const HELP_BY_COMMAND: Record = { lanes: `${ADE_BANNER} Lanes - Lanes are ADE-managed worktrees and branches. Most commands accept either - --lane or a positional lane id. + Every git worktree of the project is a lane. "ade lanes list" reconciles lane + rows against git on each call, so a worktree you created by hand shows up as a + lane with no attach step, and a lane whose worktree is gone from git and disk + stops being listed. Most commands accept either --lane or a + positional lane id. - $ ade lanes list --text Show lane stack graph and branch names + $ ade lanes list --text Show lane stack graph and branch names (adopts new worktrees) $ ade lanes show --text Inspect one lane status $ ade lane drift --lane --text Check whether the worktree HEAD drifted off the lane's branch $ ade lane drift resolve --lane --switch-back @@ -1503,14 +1506,15 @@ const HELP_BY_COMMAND: Record = { $ ade lanes create --branch-name Override the auto-generated branch name $ ade lanes child --lane --name Create a child lane under a parent Child lanes carry the parent's unmerged work - $ ade lanes import --branch Register an existing branch/worktree + $ ade lanes import --branch Create a lane for an existing branch, checked out in a new managed + worktree (fails if the branch is already checked out somewhere — + that worktree is already a lane) $ ade lanes archive Archive a lane in ADE $ ade lanes reclaim-preview Show reclaimable space and safety warnings $ ade lanes archive-and-reclaim --confirm RECLAIM Archive the lane, then remove its ADE-managed local files $ ade lanes unarchive Restore an archived lane and recreate its worktree if needed $ ade lanes delete --force Delete a lane and clean up its worktree - $ ade lanes attach --path --name Attach an external worktree $ ade lanes reparent --parent Move lane onto a new parent (runs git rebase) $ ade lanes reparent --parent --stack-base-branch Reparent and stack onto a specific branch (e.g. origin/main) @@ -4355,41 +4359,6 @@ function buildLanePlan(args: string[]): CliPlan { ], }; } - if (sub === "attach") { - return { - kind: "execute", - label: "lane attach", - steps: [ - actionStep( - "result", - "lane", - "attach", - collectGenericObjectArgs(args, { - worktreePath: readValue(args, ["--path"]) ?? firstPositional(args), - name: readValue(args, ["--name"]), - }), - ), - ], - }; - } - if (sub === "adopt-attached") { - const laneId = requireValue( - readLaneId(args) ?? firstPositional(args), - "laneId", - ); - return { - kind: "execute", - label: "lane adopt attached", - steps: [ - actionStep( - "result", - "lane", - "adoptAttached", - collectGenericObjectArgs(args, { laneId }), - ), - ], - }; - } if (sub === "split-unstaged") { return { kind: "execute", @@ -4434,19 +4403,6 @@ function buildLanePlan(args: string[]): CliPlan { ], }; } - if (sub === "unregistered" || sub === "list-unregistered") { - return { - kind: "execute", - label: "unregistered lanes", - steps: [ - actionCallStep( - "result", - "list_unregistered_lanes", - collectGenericObjectArgs(args), - ), - ], - }; - } return { kind: "execute", label: `lane ${sub}`, diff --git a/apps/ade-cli/src/multiProjectRpcServer.ts b/apps/ade-cli/src/multiProjectRpcServer.ts index 51c79b082..5a1a5e675 100644 --- a/apps/ade-cli/src/multiProjectRpcServer.ts +++ b/apps/ade-cli/src/multiProjectRpcServer.ts @@ -1677,9 +1677,11 @@ export function createMultiProjectRpcRequestHandler( "projects.inspectPath requires path.", ); } - // Deliberately uncached: desktop main's inspectProjectPathCached relies - // on lane attach/adopt IPC hooks for invalidation, which never fire in - // this long-lived daemon — a cache here could serve pre-attach results. + // Deliberately uncached: desktop main's inspectProjectPathCached is + // invalidated by lane lifecycle IPC, which never fires in this + // long-lived daemon. A worktree becomes a lane the moment `lanes.list` + // next reconciles, so a cache here would keep reporting a path as + // laneless well after it stopped being so. return await inspectProjectPath(targetPath); } diff --git a/apps/ade-cli/src/services/sync/syncHostService.ts b/apps/ade-cli/src/services/sync/syncHostService.ts index 16a777515..868ab92e6 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.ts @@ -2763,8 +2763,6 @@ export function createSyncHostService(args: SyncHostServiceArgs) { case "lanes.createChild": case "lanes.createFromUnstaged": case "lanes.importBranch": - case "lanes.attach": - case "lanes.adoptAttached": return result && typeof result === "object" ? decorateLaneSummary(result as LaneSummary) : result; diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index 37a3653cf..0ad6ede2e 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -67,7 +67,6 @@ import type { AiReviewSummaryArgs, ApplyLaneTemplateArgs, ArchiveLaneArgs, - AttachLaneArgs, ChatTerminalActiveForChatArgs, ChatTerminalListArgs, ClosePrArgs, @@ -1349,14 +1348,6 @@ function parseImportBranchArgs(value: Record): ImportBranchLane }; } -function parseAttachLaneArgs(value: Record): AttachLaneArgs { - return { - name: requireString(value.name, "lanes.attach requires name."), - attachedPath: requireString(value.attachedPath, "lanes.attach requires attachedPath."), - ...(asTrimmedString(value.description) ? { description: asTrimmedString(value.description)! } : {}), - }; -} - function parseArchiveLaneArgs(value: Record, action: string): ArchiveLaneArgs { return { laneId: requireString(value.laneId, `${action} requires laneId.`), @@ -3585,7 +3576,10 @@ async function buildLaneListSnapshots( autoRebaseStatus: autoRebaseByLaneId.get(lane.id) ?? null, conflictStatus: conflictByLaneId.get(lane.id) ?? null, stateSnapshot: stateByLaneId.get(lane.id) ?? null, - adoptableAttached: lane.laneType === "attached" && lane.archivedAt == null, + // Deprecated, always false. Shipped iOS builds decode this as a + // non-optional Bool; dropping the key blanks their lane list. See the + // field's doc comment in shared/types/lanes.ts. + adoptableAttached: false, })); } @@ -3806,10 +3800,21 @@ function registerLaneRemoteCommands({ args, register }: RemoteCommandRegistratio args.laneService.importBranch(parseImportBranchArgs(payload))); register("lanes.previewBranchSwitch", { viewerAllowed: true }, async (payload) => args.laneService.previewBranchSwitch(parseGitCheckoutBranchArgs(payload))); - register("lanes.attach", { viewerAllowed: true, queueable: true }, async (payload) => args.laneService.attach(parseAttachLaneArgs(payload))); - register("lanes.listUnregisteredWorktrees", { viewerAllowed: true }, async () => args.laneService.listUnregisteredWorktrees()); - register("lanes.adoptAttached", { viewerAllowed: true, queueable: true }, async (payload) => - args.laneService.adoptAttached({ laneId: requireString(payload.laneId, "lanes.adoptAttached requires laneId.") })); + // Every git worktree is now a lane the moment it exists, so there is nothing + // left to select, attach, or adopt. These three stay REGISTERED (and so keep + // appearing in hello_ok.features.commandRouting.actions) because they are in + // MOBILE_SYNC_REQUIRED_REMOTE_COMMAND_ACTIONS: dropping them would flip an + // otherwise healthy host into "limited" mode for every phone, new or old. + // Instead the list returns empty — an older phone's attach sheet renders its + // own "nothing to add" state — and the two mutations fail with a message the + // phone surfaces verbatim. + register("lanes.listUnregisteredWorktrees", { viewerAllowed: true }, async () => []); + register("lanes.attach", { viewerAllowed: true, queueable: true }, async () => { + throw new Error("Attaching worktrees is no longer supported — every git worktree in the project already appears as a lane."); + }); + register("lanes.adoptAttached", { viewerAllowed: true, queueable: true }, async () => { + throw new Error("Adopting attached lanes is no longer supported — every git worktree in the project is managed as a lane."); + }); register("lanes.rename", { viewerAllowed: true, queueable: true }, async (payload) => { args.laneService.rename(parseRenameLaneArgs(payload)); return { ok: true }; diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index d9eefadd1..64129bbea 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -2717,9 +2717,7 @@ app.whenReady().then(async () => { logger, projectRoot, projectId, - baseRef, freshProject: !hadAdeDir, - laneService, projectConfigService, }); diff --git a/apps/desktop/src/main/services/adeActions/registry.test.ts b/apps/desktop/src/main/services/adeActions/registry.test.ts index 788292902..332f36c20 100644 --- a/apps/desktop/src/main/services/adeActions/registry.test.ts +++ b/apps/desktop/src/main/services/adeActions/registry.test.ts @@ -2283,6 +2283,8 @@ describe("runtime lane snapshot actions", () => { autoRebaseStatus, conflictStatus, stateSnapshot, + // Deprecated wire-compat field: always false, but still emitted so + // shipped iOS builds can decode the snapshot. adoptableAttached: false, }, { @@ -2302,7 +2304,7 @@ describe("runtime lane snapshot actions", () => { autoRebaseStatus: null, conflictStatus: null, stateSnapshot: null, - adoptableAttached: true, + adoptableAttached: false, }, ]); }); diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index 2d79814e8..6a9120b0b 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -326,10 +326,8 @@ export const ADE_ACTION_ALLOWLIST: Partial => { const summaryRow = sessionService.get(managed.session.id); if (!summaryRow) { @@ -31371,6 +31372,7 @@ export function createAgentChatService(args: { return { chatSessionId: managed.session.id, chatSummary: await summarizeSessionRow(summaryRow), + providerTargetId, }; }; @@ -31471,7 +31473,7 @@ export function createAgentChatService(args: { persistChatState(managed); await appendImportedChatEvents(managed, events); persistChatState(managed); - return await persistedImportedChatResult(managed, "claude"); + return await persistedImportedChatResult(managed, "claude", targetClaudeSessionId); } catch (error) { if (createdSessionId) { await deleteSession({ sessionId: createdSessionId }).catch((cleanupError) => { @@ -31620,7 +31622,7 @@ export function createAgentChatService(args: { persistChatState(managed); await appendImportedChatEvents(managed, events); persistChatState(managed); - return await persistedImportedChatResult(managed, "codex"); + return await persistedImportedChatResult(managed, "codex", targetThreadId); } catch (error) { if (createdSessionId && forkedProviderThreadId) { const managed = managedSessions.get(createdSessionId); diff --git a/apps/desktop/src/main/services/externalSessions/claudeLiveSessions.ts b/apps/desktop/src/main/services/externalSessions/claudeLiveSessions.ts new file mode 100644 index 000000000..6cb1aa88e --- /dev/null +++ b/apps/desktop/src/main/services/externalSessions/claudeLiveSessions.ts @@ -0,0 +1,73 @@ +import fs from "node:fs"; +import path from "node:path"; +import { claudeConfigDir } from "./discoverClaude"; +import { safeParseJson, safeStat, type ExternalSessionDiscoveryArgs } from "./discoveryUtils"; + +/** + * Claude CLI writes `/sessions/.json` for each running + * session and leaves the file behind when the process dies. A live pid is a far + * better activity signal than the transcript's mtime, which says "was written + * recently", not "is open right now" — a session idling for ten minutes is + * still the one the user must not resume from a second terminal. + */ +/** + * Cap on registry files opened per call. Claude never deletes these, so the + * directory is mostly dead sessions and the cap has to be applied newest-first: + * in raw `readdir` order a live session can fall outside it, and reporting it as + * not running is exactly the mistake this registry exists to prevent. + */ +const MAX_REGISTRY_FILES = 256; + +function processAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + // EPERM means the pid exists but belongs to another user. + return (error as NodeJS.ErrnoException | null)?.code === "EPERM"; + } +} + +/** + * Session ids Claude currently has open, from its own live-session registry. + * Returns null when the registry does not exist, so callers keep their mtime + * heuristic instead of silently reporting nothing as active. + */ +export function liveClaudeSessionIds( + args: Pick = {}, +): Set | null { + const dir = path.join(claudeConfigDir(args), "sessions"); + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + // Missing or unreadable is "no answer", not "nothing is running". + return null; + } + const newestFirst = entries + .filter((entry) => entry.isFile() && entry.name.endsWith(".json")) + .map((entry) => { + const filePath = path.join(dir, entry.name); + return { filePath, mtimeMs: safeStat(filePath)?.mtimeMs ?? 0 }; + }) + .sort((left, right) => right.mtimeMs - left.mtimeMs || left.filePath.localeCompare(right.filePath)) + .slice(0, MAX_REGISTRY_FILES); + + const live = new Set(); + for (const { filePath } of newestFirst) { + let raw: string; + try { + raw = fs.readFileSync(filePath, "utf8"); + } catch { + continue; + } + const record = safeParseJson(raw); + if (!record || typeof record !== "object" || Array.isArray(record)) continue; + const { pid, sessionId } = record as { pid?: unknown; sessionId?: unknown }; + if (typeof sessionId !== "string" || !sessionId.trim()) continue; + // A stale entry keeps its pid, so liveness has to be checked, not assumed. + if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0 || !processAlive(pid)) continue; + live.add(sessionId.trim()); + } + return live; +} diff --git a/apps/desktop/src/main/services/externalSessions/discoverClaude.ts b/apps/desktop/src/main/services/externalSessions/discoverClaude.ts index 6df6b1ae0..fda7a71d7 100644 --- a/apps/desktop/src/main/services/externalSessions/discoverClaude.ts +++ b/apps/desktop/src/main/services/externalSessions/discoverClaude.ts @@ -1,3 +1,4 @@ +import type fs from "node:fs"; import path from "node:path"; import { claudeProjectSlugForCwd, @@ -7,13 +8,13 @@ import { cwdIsInScope, isUuidLike, normalizeExternalSessionLimit, - readFileSuffix, + readFileWindow, readJsonlRecords, + readJsonlRecordsFromSuffix, recentExternalSessionMessagesFromRecords, recordWithFile, resolveHomeDir, safeReadDir, - safeParseJson, sessionFileCandidate, slugMatchesScopeRoots, sortFileCandidatesByMtime, @@ -21,11 +22,48 @@ import { asEpochMs, asRecord, asString, + JSONL_SCAN_BYTE_LIMIT, type ExternalSessionDiscoveryArgs, type ExternalSessionFileCandidate, type ExternalSessionDiscoveryRecord, } from "./discoveryUtils"; +/** Window read when looking for the first or last record uuid, and when probing. */ +const CLAUDE_CHAIN_WINDOW_BYTES = JSONL_SCAN_BYTE_LIMIT; +/** + * How far into a transcript the walk for the first record uuid may go. One + * pasted opening prompt can be hundreds of KB on its own, and roughly one + * transcript in seven pushes its first uuid past a 64 KiB window. + */ +const CLAUDE_CHAIN_HEAD_MAX_BYTES = 4 * JSONL_SCAN_BYTE_LIMIT; +/** Overlap between successive windows so a uuid split across the seam is still seen. */ +const CLAUDE_CHAIN_WINDOW_OVERLAP_BYTES = 256; +/** Window read from each side when sampling shared record uuids mid-file. */ +const CLAUDE_CHAIN_SAMPLE_BYTES = 64 * 1024; +const CLAUDE_CHAIN_SAMPLE_FRACTIONS = [0.25, 0.5, 0.75] as const; +/** Entrypoint rows inspected at the head of a transcript to classify its origin. */ +const CLAUDE_ENTRYPOINT_SAMPLE_SIZE = 20; + +type ClaudeSessionCandidate = ExternalSessionFileCandidate<{ id: string }>; + +type ClaudeChainSignature = { + /** uuid of the first record that carries one; identifies the continuation chain. */ + chainKey: string | null; + /** Snake-case `session_id` pointers to the session this transcript continues. */ + ancestorIds: string[]; + /** uuid of the last record that carries one. */ + lastUuid: string | null; +}; + +type ClaudeChainMember = { + candidate: ClaudeSessionCandidate; + signature: ClaudeChainSignature; + /** Index of this member's row in the result array, so a leaf can replace it. */ + recordIndex: number; +}; + +type ClaudeChain = { members: ClaudeChainMember[] }; + export function claudeConfigDir(args: ExternalSessionDiscoveryArgs = {}): string { const env = args.env ?? (args.homeDir ? undefined : process.env); const configured = typeof env?.CLAUDE_CONFIG_DIR === "string" @@ -66,13 +104,7 @@ function claudeScanRecords(filePath: string): { combined: unknown[]; } { const prefix = readJsonlRecords(filePath); - const suffixText = readFileSuffix(filePath); - if (!suffixText) return { prefix, suffix: [], combined: prefix }; - const suffixLines = suffixText.split(/\r?\n/u); - if (suffixText.length > 0 && !suffixText.startsWith("{")) suffixLines.shift(); - const suffix = suffixLines - .map((line) => safeParseJson(line)) - .filter((record): record is Record => record != null); + const suffix = readJsonlRecordsFromSuffix(filePath); return { prefix, suffix, combined: [...prefix, ...suffix] }; } @@ -84,11 +116,154 @@ function latestClaudeCwd(records: unknown[]): string | null { return null; } -function isClaudeCliTranscript(records: unknown[]): boolean { - const entrypoints = records - .map((item) => asString(asRecord(item)?.entrypoint)?.toLowerCase()) - .filter((entrypoint): entrypoint is string => Boolean(entrypoint)); - return !entrypoints.some((entrypoint) => entrypoint.startsWith("sdk")); +/** + * A transcript is SDK-origin when the entrypoints it *starts* with are SDK ones, + * which is how ADE's own Claude chats look. Classifying on any single `sdk` row + * instead would hide a real CLI session the moment one SDK-driven turn landed in + * it; those sessions resume fine and must stay listed. + */ +function isClaudeSdkOriginTranscript(records: unknown[]): boolean { + const entrypoints: string[] = []; + for (const item of records) { + const entrypoint = asString(asRecord(item)?.entrypoint)?.toLowerCase(); + if (entrypoint) entrypoints.push(entrypoint); + if (entrypoints.length >= CLAUDE_ENTRYPOINT_SAMPLE_SIZE) break; + } + if (!entrypoints.length) return false; + const sdkCount = entrypoints.filter((entrypoint) => entrypoint.startsWith("sdk")).length; + return sdkCount * 2 > entrypoints.length; +} + +/** + * Only `/.jsonl` is a resumable CLI session. Sidecar + * transcripts live under `/subagents/**` (workflow runs included) and + * outnumber real sessions roughly nine to one, so discovery never descends into + * a project subdirectory, and non-uuid names such as `agent-*.jsonl` are ignored. + */ +function claudeTopLevelSessionId(entry: fs.Dirent): string | null { + if (!entry.isFile() || !entry.name.endsWith(".jsonl")) return null; + const id = entry.name.slice(0, -".jsonl".length); + return isUuidLike(id) ? id : null; +} + +/** + * Field-level scan rather than a per-line `JSON.parse`. A single tool result can + * be larger than these windows, which leaves a window with no complete line and + * would otherwise report a transcript as having no records at all. + * `"parentUuid"` cannot match: the pattern anchors on the opening quote. + */ +function scanUuidField(text: string | null, field: "uuid" | "session_id"): string[] { + if (!text) return []; + const pattern = new RegExp(`"${field}"\\s*:\\s*"([0-9a-fA-F-]{36})"`, "gu"); + return [...text.matchAll(pattern)].map((match) => match[1]!); +} + +/** + * Chain identity for one transcript. The per-record camelCase `sessionId` is + * always rewritten to match the filename, so it says nothing about ancestry; the + * uuid of the first record survives every copy and separates chains exactly, + * including chats whose opening text is identical because ADE injected the same + * preamble. First-message text must never be used as the key. + */ +function claudeChainSignature(candidate: ClaudeSessionCandidate): ClaudeChainSignature { + const ancestorIds = new Set(); + let chainKey: string | null = null; + const headCeiling = Math.min(candidate.size, CLAUDE_CHAIN_HEAD_MAX_BYTES); + for (let offset = 0; offset < headCeiling; offset += CLAUDE_CHAIN_WINDOW_BYTES - CLAUDE_CHAIN_WINDOW_OVERLAP_BYTES) { + const window = readFileWindow(candidate.filePath, offset, CLAUDE_CHAIN_WINDOW_BYTES); + if (!window) break; + for (const ancestorId of scanUuidField(window, "session_id")) { + if (ancestorId !== candidate.id && isUuidLike(ancestorId)) ancestorIds.add(ancestorId); + } + chainKey = scanUuidField(window, "uuid")[0] ?? null; + if (chainKey) break; + } + const tailOffset = Math.max(0, candidate.size - CLAUDE_CHAIN_WINDOW_BYTES); + const tailUuids = scanUuidField(readFileWindow(candidate.filePath, tailOffset, CLAUDE_CHAIN_WINDOW_BYTES), "uuid"); + return { chainKey, ancestorIds: [...ancestorIds], lastUuid: tailUuids.at(-1) ?? null }; +} + +/** + * True when `shorter`'s records are a prefix of (or identical to) `longer`'s. + * + * Both checks are needed. A resumed session appends in place, so a continuation + * copy holds the shorter file's final record; a real `--fork-session` or rewind + * branch does not. But a longer file can still contain the shorter file's last + * record while having dropped records in between, so uuids sampled across the + * shorter file must also appear in the longer one at the same rough offsets. + * Copies rewrite `sessionId` in place with a same-length uuid, which keeps byte + * offsets aligned to within a few KB of the sampling windows. + */ +function isClaudeContinuationPrefix( + shorter: ClaudeSessionCandidate, + shorterSignature: ClaudeChainSignature, + longer: ClaudeSessionCandidate, +): boolean { + if (!shorterSignature.lastUuid || shorter.size > longer.size) return false; + const probeStart = Math.max(0, shorter.size - Math.floor(CLAUDE_CHAIN_WINDOW_BYTES / 2)); + const probe = readFileWindow(longer.filePath, probeStart, CLAUDE_CHAIN_WINDOW_BYTES); + if (!probe?.includes(shorterSignature.lastUuid)) return false; + for (const fraction of CLAUDE_CHAIN_SAMPLE_FRACTIONS) { + const offset = Math.floor(shorter.size * fraction); + const sampled = scanUuidField(readFileWindow(shorter.filePath, offset, CLAUDE_CHAIN_SAMPLE_BYTES), "uuid"); + const against = scanUuidField(readFileWindow( + longer.filePath, + Math.max(0, offset - CLAUDE_CHAIN_SAMPLE_BYTES), + CLAUDE_CHAIN_SAMPLE_BYTES * 3, + ), "uuid"); + // One record can span the whole window; a sample with no uuid on either side says nothing. + if (!sampled.length || !against.length) continue; + if (!sampled.some((uuid) => against.includes(uuid))) return false; + } + return true; +} + +function claudeChainKeys(candidate: ClaudeSessionCandidate, signature: ClaudeChainSignature): string[] { + const keys = [`id:${candidate.id}`]; + if (signature.chainKey) keys.push(`chain:${signature.chainKey}`); + for (const ancestorId of signature.ancestorIds) keys.push(`id:${ancestorId}`); + return keys; +} + +/** + * Returns the chain these keys belong to, merging any chains they join. A + * transcript can arrive keyed by its first record while an already-listed one + * pointed at it by `session_id`, so the two groups have to become one. + */ +function claimClaudeChain(index: Map, keys: string[]): ClaudeChain { + const joined = new Set(); + for (const key of keys) { + const existing = index.get(key); + if (existing) joined.add(existing); + } + if (joined.size === 1) { + const [only] = joined; + for (const key of keys) index.set(key, only); + return only; + } + const merged: ClaudeChain = { members: [] }; + for (const chain of joined) merged.members.push(...chain.members); + if (joined.size) { + for (const [key, chain] of index) { + if (joined.has(chain)) index.set(key, merged); + } + } + for (const key of keys) index.set(key, merged); + return merged; +} + +/** + * Ids a listed row now stands for. Only the leaf of a continuation chain is + * listed, so an import recorded against an earlier id in the same chain has to + * be recognizable from the leaf. + */ +function addClaudeLineage(record: ExternalSessionDiscoveryRecord, ids: Array): void { + const lineage = new Set(record.lineageIds ?? []); + for (const id of ids) { + const clean = id?.trim(); + if (clean && clean !== record.id) lineage.add(clean); + } + if (lineage.size) record.lineageIds = [...lineage]; } export async function discoverClaudeSessions( @@ -98,7 +273,7 @@ export async function discoverClaudeSessions( const projectsDir = path.join(claudeConfigDir(args), "projects"); const lookupId = args.sessionId?.trim() || null; if (lookupId && !isUuidLike(lookupId)) return []; - const candidates: Array> = []; + const candidates: ClaudeSessionCandidate[] = []; for (const projectEntry of safeReadDir(projectsDir)) { if (!projectEntry.isDirectory()) continue; @@ -110,25 +285,26 @@ export async function discoverClaudeSessions( continue; } 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 }); + const id = claudeTopLevelSessionId(entry); + if (!id) continue; + const candidate = sessionFileCandidate(path.join(projectDir, entry.name), { id }); if (candidate) candidates.push(candidate); } } - const newestById = new Map>(); + const newestById = new Map(); for (const candidate of sortFileCandidatesByMtime(candidates, candidates.length)) { if (!newestById.has(candidate.id)) newestById.set(candidate.id, candidate); } const records: ExternalSessionDiscoveryRecord[] = []; + // Continuation chains cross project directories — a session moved with `/cd` + // lands in another slug — so chains are tracked globally, not per project dir. + const chainIndex = new Map(); for (const candidate of newestById.values()) { const scan = claudeScanRecords(candidate.filePath); const jsonl = scan.combined; - if (!isClaudeCliTranscript(jsonl)) continue; + if (isClaudeSdkOriginTranscript(scan.prefix)) continue; const cwd = latestClaudeCwd(jsonl); let createdAt: number | null = null; for (const item of jsonl) { @@ -139,7 +315,7 @@ export async function discoverClaudeSessions( } if (!cwdIsInScope(cwd, args.scopeRoots)) continue; const firstUserText = firstUserTextFromRecords(scan.prefix); - records.push(recordWithFile({ + const record = recordWithFile({ provider: "claude", id: candidate.id, cwd, @@ -150,7 +326,43 @@ export async function discoverClaudeSessions( messageCount: countJsonlUserMessagesCheap(candidate.filePath, "claude"), filePath: candidate.filePath, sourceMtimeMs: candidate.mtimeMs, - })); + }); + + // An exact lookup already names the session the caller wants; it must resolve + // even when that session is an ancestor of a newer continuation. + const signature = lookupId ? null : claudeChainSignature(candidate); + const chain = signature + ? claimClaudeChain(chainIndex, claudeChainKeys(candidate, signature)) + : null; + if (chain && signature) { + const listedLeaf = chain.members.find( + (member) => isClaudeContinuationPrefix(candidate, signature, member.candidate), + ); + if (listedLeaf) { + addClaudeLineage(records[listedLeaf.recordIndex]!, [candidate.id, ...signature.ancestorIds]); + continue; + } + const listedAncestor = chain.members.find( + (member) => isClaudeContinuationPrefix(member.candidate, member.signature, candidate), + ); + if (listedAncestor) { + const superseded = records[listedAncestor.recordIndex]!; + addClaudeLineage(record, [ + ...(superseded.lineageIds ?? []), + superseded.id, + ...signature.ancestorIds, + ]); + records[listedAncestor.recordIndex] = record; + listedAncestor.candidate = candidate; + listedAncestor.signature = signature; + continue; + } + // Diverging members are real forks. Both stay listed. + } + + if (signature) addClaudeLineage(record, signature.ancestorIds); + records.push(record); + if (chain && signature) chain.members.push({ candidate, signature, recordIndex: records.length - 1 }); if (records.length >= limit) break; } diff --git a/apps/desktop/src/main/services/externalSessions/discoverCodex.ts b/apps/desktop/src/main/services/externalSessions/discoverCodex.ts index 776eb24da..a6102b82e 100644 --- a/apps/desktop/src/main/services/externalSessions/discoverCodex.ts +++ b/apps/desktop/src/main/services/externalSessions/discoverCodex.ts @@ -1,23 +1,29 @@ -import { createHash, randomUUID } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; +import type { DatabaseSync as DatabaseSyncType } from "node:sqlite"; import { asEpochMs, asRecord, asString, cleanSessionTitle, + clipExternalSessionText, + closeExternalSessionDb, countJsonlUserMessagesCheap, cwdIsInScope, firstUserTextFromRecords, + isCanonicalCodexUserRecord, normalizeExternalSessionLimit, + openExternalSessionDb, readFileSuffix, readJsonlRecords, + readJsonlRecordsFromSuffix, canonicalCodexRecords, recentExternalSessionMessagesFromRecords, recordWithFile, resolveHomeDir, safeParseJson, safeReadDir, + safeStat, sessionFileCandidate, sortFileCandidatesByMtime, sortDiscoveryRecords, @@ -37,6 +43,15 @@ const CODEX_LAUNCH_BACKWARD_SCAN_MAX_BYTES = 64 * 1024 * 1024; const CODEX_LAUNCH_BACKWARD_SCAN_MAX_LINE_BYTES = 1024 * 1024; const CODEX_RECENT_MESSAGES_BROWSE_BYTE_LIMIT = 64 * 1024; const CODEX_RECENT_MESSAGES_EXACT_BYTE_LIMIT = 128 * 1024; +const CODEX_SESSION_META_MAX_BYTES = 64 * 1024; +const CODEX_RECENT_SCAN_FLOOR = 1000; + +/** Codex's own thread store. Present since the multi-agent rewrite; absent on older installs. */ +const CODEX_STATE_DB_BASENAME = "state_5.sqlite"; +/** Upper bound on rows pulled from `threads` in one discovery pass. */ +const CODEX_STATE_DB_ROW_CEILING = 20_000; +/** DB text columns only ever feed a clipped preview, so read a bounded prefix of each. */ +const CODEX_STATE_DB_TEXT_MAX_CHARS = 1024; type CodexIndexEntry = { id: string; @@ -49,31 +64,44 @@ type CodexSessionMeta = { cwd: string | null; createdAt: number | null; title: string | null; - importable: boolean; + /** Raw: a plain string for interactive rollouts, an object for spawned subagents. */ + source: unknown; + originator: string | null; + agentRole: string | null; + /** Set when this rollout replays another thread's transcript before diverging. */ + forkedFromId: string | null; }; -type CodexSessionCandidate = ExternalSessionFileCandidate<{ meta?: CodexSessionMeta | null }>; +type CodexSessionCandidate = ExternalSessionFileCandidate; -type CodexScopeIndexEntry = { - mtimeMs: number; - size: number; - meta: CodexSessionMeta | null; +/** + * Everything needed to build one discovery record, from whichever authority + * produced it. The state DB fills these directly; the file scan reconstructs + * them from `session_meta` prefixes. + */ +type CodexRecordSeed = { + id: string; + rolloutPath: string; + cwd: string | null; + title: string | null; + createdAt: number | null; + updatedAt: number | null; + mtimeMs: number | null; + /** Used only when the rollout file cannot be read (compressed or dangling). */ + fallbackPreview: string | null; + /** Parent threads collapsed into this fork; see `isCodexForkContinuation`. */ + lineageIds?: string[]; }; -type CodexScopeIndexState = { - codexDir: string; - entries: Map; - persisted: boolean; - dirty: boolean; - pendingWrite: ReturnType | null; +/** + * Per-discovery-run classification state. The warned set keeps one telemetry + * line per unrecognized `source` per run instead of one per rollout. + */ +type CodexClassifyContext = { + logger: ExternalSessionDiscoveryArgs["logger"]; + warnedSources: Set; }; -const CODEX_SESSION_META_MAX_BYTES = 64 * 1024; -const CODEX_SCOPE_INDEX_VERSION = 1; -const CODEX_SCOPE_INDEX_WRITE_DELAY_MS = 30_000; -const CODEX_RECENT_SCAN_FLOOR = 1000; -const codexScopeIndexStates = new Map(); - function readCodexIndex(indexPath: string): Map { const map = new Map(); const text = readFileSuffix(indexPath, 2 * 1024 * 1024); @@ -131,6 +159,8 @@ function readCodexSessionMeta( if (lineStart >= bytesRead) return null; const newline = scratch.subarray(lineStart, bytesRead).indexOf(0x0a); const lineEnd = newline >= 0 ? lineStart + newline : bytesRead; + // Only line 1 is trusted: a fork replays the parent transcript verbatim, so a + // forked rollout carries the parent's `session_meta` again further down. const first = asRecord(safeParseJson(scratch.subarray(lineStart, lineEnd).toString("utf8"))); const payload = asRecord(first?.payload); const type = asString(first?.type); @@ -142,7 +172,10 @@ function readCodexSessionMeta( cwd: asString(payload.cwd), createdAt: asEpochMs(payload.timestamp) ?? asEpochMs(first.timestamp), title: titleFromCodexPayload(payload, undefined), - importable: isImportableCodexPayload(payload), + source: payload.source, + originator: asString(payload.originator), + agentRole: asString(payload.agent_role) ?? asString(payload.agentRole), + forkedFromId: asString(payload.forked_from_id) ?? asString(payload.forkedFromId), }; } @@ -153,7 +186,7 @@ function sortedChildDirs(dir: string, pattern: RegExp): string[] { .sort((left, right) => right.localeCompare(left)); } -export function matchesCodexLookup(entryName: string, sessionId: string | null): boolean { +function matchesCodexLookup(entryName: string, sessionId: string | null): boolean { if (!sessionId) return true; return entryName.endsWith(`-${sessionId}.jsonl`) || entryName.endsWith(`-${sessionId}.jsonl.zst`); } @@ -231,219 +264,69 @@ function codexHomeDir(args: ExternalSessionDiscoveryArgs): string { return configured ? path.resolve(configured) : path.join(resolveHomeDir(args), ".codex"); } -function isImportableCodexPayload(payload: Record): boolean { - const rawSource = payload.source; - const source = asString(rawSource)?.toLowerCase() ?? null; - const originator = asString(payload.originator)?.toLowerCase() ?? null; - const agentRole = asString(payload.agent_role) ?? asString(payload.agentRole); - if (agentRole) return false; - // Current Codex subagents use an object-shaped `source` payload. External - // import is deliberately limited to interactive CLI/user-shell rollouts, so - // fail closed for any structured or otherwise unknown source representation. - if (rawSource != null && !source) return false; - if (source === "exec" || source === "vscode") return false; - if (source && source !== "cli" && source !== "user_shell") return false; - if (!originator) return true; - return !( - originator === "ade" - || originator === "ade_desktop" - || originator === "codex desktop" - || originator === "codex_exec" - || originator.startsWith("codex_sdk") - || originator.startsWith("ade-title") - ); -} - -function isImportableCodexSession(meta: CodexSessionMeta): boolean { - return meta.importable; -} - -function codexScopeIndexPath( - args: ExternalSessionDiscoveryArgs, - codexDir: string, -): string { - const env = args.env ?? (args.homeDir ? undefined : process.env); - const configuredAdeHome = typeof env?.ADE_HOME === "string" ? env.ADE_HOME.trim() : ""; - const adeHome = configuredAdeHome - ? path.resolve(configuredAdeHome) - : path.join(resolveHomeDir(args), ".ade"); - const codexHomeKey = createHash("sha256") - .update(path.resolve(codexDir)) - .digest("hex") - .slice(0, 16); - return path.join( - adeHome, - "cache", - "external-sessions", - `codex-cwd-index-${codexHomeKey}.json`, - ); -} - -function scopeIndexMetaFromValue(value: unknown): CodexSessionMeta | null { - const record = asRecord(value); - if (!record) return null; - const id = asString(record.id); - const cwd = record.cwd === null ? null : asString(record.cwd); - let createdAt: number | null; - if (record.createdAt === null) { - createdAt = null; - } else if (typeof record.createdAt === "number" && Number.isFinite(record.createdAt)) { - createdAt = record.createdAt; - } else { - return null; - } - const title = record.title === null ? null : asString(record.title); - if ( - !id - || (record.cwd !== null && cwd === null) - || (record.title !== null && title === null) - || typeof record.importable !== "boolean" - ) return null; - return { - id, - cwd, - createdAt, - title, - importable: record.importable, - }; +/** + * Codex spawns subagents with an object-shaped `source`. The state DB stores + * that column as its JSON serialization, so both representations must classify + * the same way. + */ +function structuredCodexSource(rawSource: unknown): boolean { + if (asRecord(rawSource)) return true; + const text = asString(rawSource); + return text != null && (text.startsWith("{") || text.startsWith("[")); } -function loadCodexScopeIndex( - indexPath: string, - codexDir: string, -): CodexScopeIndexState { - const cached = codexScopeIndexStates.get(indexPath); - if (cached?.codexDir === codexDir) return cached; - const state: CodexScopeIndexState = { - codexDir, - entries: new Map(), - persisted: false, - dirty: false, - pendingWrite: null, - }; - try { - const parsed = asRecord(safeParseJson(fs.readFileSync(indexPath, "utf8"))); - const rawEntries = asRecord(parsed?.entries); - if ( - parsed?.version !== CODEX_SCOPE_INDEX_VERSION - || asString(parsed.codexDir) !== codexDir - || !rawEntries - ) { - codexScopeIndexStates.set(indexPath, state); - return state; - } - for (const [relativePath, rawEntry] of Object.entries(rawEntries)) { - if (!/^\d{4}\/\d{2}\/\d{2}\/[^/]+\.jsonl$/u.test(relativePath)) continue; - const entry = asRecord(rawEntry); - const mtimeMs = entry?.mtimeMs; - const size = entry?.size; - const meta = entry?.meta === null ? null : scopeIndexMetaFromValue(entry?.meta); - if ( - typeof mtimeMs !== "number" - || !Number.isFinite(mtimeMs) - || typeof size !== "number" - || !Number.isFinite(size) - || size < 0 - || (entry?.meta !== null && meta === null) - ) continue; - state.entries.set(relativePath, { mtimeMs, size, meta }); - } - state.persisted = true; - } catch { - // Missing or corrupt indexes are rebuildable from Codex rollout metadata. +/** + * External import is for interactive CLI/user-shell rollouts. Known + * non-interactive entrypoints are dropped; an unrecognized *string* source is + * listed anyway, because a new Codex entrypoint silently disappearing from the + * import list is worse than one extra row the user can ignore. + */ +function isImportableCodexSource(rawSource: unknown, ctx: CodexClassifyContext): boolean { + if (structuredCodexSource(rawSource)) return false; + const source = asString(rawSource)?.toLowerCase(); + if (!source) return true; + if (source === "exec" || source === "vscode") return false; + if (source === "cli" || source === "user_shell") return true; + if (!ctx.warnedSources.has(source)) { + ctx.warnedSources.add(source); + ctx.logger?.warn?.("external_sessions.codex_unknown_source", { source }); } - codexScopeIndexStates.set(indexPath, state); - return state; + return true; } -function writeCodexScopeIndex( - indexPath: string, - state: CodexScopeIndexState, - logger: ExternalSessionDiscoveryArgs["logger"], -): boolean { - const serializedEntries = Object.fromEntries( - Array.from(state.entries.entries()).sort(([left], [right]) => left.localeCompare(right)), +function isImportableCodexOriginator(originator: string | null): boolean { + if (!originator) return true; + const value = originator.toLowerCase(); + return !( + value === "ade" + || value === "ade_desktop" + || value === "codex desktop" + || value === "codex_exec" + || value.startsWith("codex_sdk") + || value.startsWith("ade-title") ); - const serialized = `${JSON.stringify({ - version: CODEX_SCOPE_INDEX_VERSION, - codexDir: state.codexDir, - entries: serializedEntries, - })}\n`; - const tempPath = `${indexPath}.tmp-${process.pid}-${randomUUID()}`; - try { - fs.mkdirSync(path.dirname(indexPath), { recursive: true }); - fs.writeFileSync(tempPath, serialized, { encoding: "utf8", mode: 0o600 }); - try { - fs.renameSync(tempPath, indexPath); - } catch { - fs.copyFileSync(tempPath, indexPath); - fs.unlinkSync(tempPath); - } - } catch (error) { - try { - fs.unlinkSync(tempPath); - } catch { - // best effort cleanup - } - logger?.warn?.("external_sessions.codex_cwd_index_write_failed", { - indexPath, - error: error instanceof Error ? error.message : String(error), - }); - return false; - } - return true; } -function scheduleCodexScopeIndexWrite( - indexPath: string, - state: CodexScopeIndexState, - logger: ExternalSessionDiscoveryArgs["logger"], -): void { - if (!state.dirty) return; - if (!state.persisted) { - if (writeCodexScopeIndex(indexPath, state, logger)) { - state.persisted = true; - state.dirty = false; - } - return; - } - if (state.pendingWrite) return; - state.pendingWrite = setTimeout(() => { - state.pendingWrite = null; - if (writeCodexScopeIndex(indexPath, state, logger)) { - state.dirty = false; - } - }, CODEX_SCOPE_INDEX_WRITE_DELAY_MS); - state.pendingWrite.unref(); +function isImportableCodexSession(meta: CodexSessionMeta, ctx: CodexClassifyContext): boolean { + if (meta.agentRole) return false; + if (!isImportableCodexSource(meta.source, ctx)) return false; + return isImportableCodexOriginator(meta.originator); } function firstCodexUserText(records: unknown[]): string | null { - const canonical = records.filter((item) => { - const record = asRecord(item); - const payload = asRecord(record?.payload); - if (asString(record?.type)?.toLowerCase() !== "event_msg") return false; - const type = asString(payload?.type)?.toLowerCase(); - const role = asString(payload?.role)?.toLowerCase(); - return type === "user_message" || (type === "message" && role === "user"); - }); + const canonical = records.filter(isCanonicalCodexUserRecord); return canonical.length ? firstUserTextFromRecords(canonical) : firstUserTextFromRecords(records); } function recentCodexRecords(filePath: string, exactLookup: boolean): unknown[] { - const text = readFileSuffix( + return readJsonlRecordsFromSuffix( filePath, exactLookup ? CODEX_RECENT_MESSAGES_EXACT_BYTE_LIMIT : CODEX_RECENT_MESSAGES_BROWSE_BYTE_LIMIT, ); - if (!text) return []; - const lines = text.split(/\r?\n/u); - if (!text.startsWith("{")) lines.shift(); - return lines - .map((line) => safeParseJson(line)) - .filter((record): record is Record => record != null); } function codexApprovalPolicy(payload: Record): AgentChatCodexApprovalPolicy | null { @@ -614,101 +497,6 @@ async function codexLaunchForFile( return Object.keys(fallback).length ? fallback : null; } -function collectIndexedProjectScopedCodexSessionCandidates( - args: ExternalSessionDiscoveryArgs, - root: string, - codexDir: string, - limit: number, - scopeRoots: string[], -): CodexSessionCandidate[] { - const scratch = Buffer.allocUnsafe(CODEX_SESSION_META_MAX_BYTES); - const indexPath = codexScopeIndexPath(args, codexDir); - const indexState = loadCodexScopeIndex(indexPath, codexDir); - const nextEntries = new Map(); - const candidatesById = new Map(); - let indexChanged = false; - - for (const candidate of collectCodexSessionCandidates(root)) { - if (candidate.filePath.endsWith(".jsonl.zst")) continue; - const relativePath = path.relative(root, candidate.filePath).split(path.sep).join("/"); - const cached = indexState.entries.get(relativePath); - let entry = cached; - if ( - !entry - || entry.mtimeMs !== candidate.mtimeMs - || entry.size !== candidate.size - ) { - const meta = readCodexSessionMeta(candidate.filePath, scratch); - entry = { - mtimeMs: candidate.mtimeMs, - size: candidate.size, - meta, - }; - indexChanged = true; - } - nextEntries.set(relativePath, entry); - if (!entry.meta?.importable || !cwdIsInScope(entry.meta.cwd, scopeRoots)) continue; - const meta = entry.meta; - const existing = candidatesById.get(meta.id); - if (!existing || candidate.mtimeMs > existing.mtimeMs) { - candidatesById.set(meta.id, { ...candidate, meta }); - } - } - - if (nextEntries.size !== indexState.entries.size) indexChanged = true; - indexState.entries = nextEntries; - if (indexChanged) indexState.dirty = true; - scheduleCodexScopeIndexWrite(indexPath, indexState, args.logger); - return sortFileCandidatesByMtime(Array.from(candidatesById.values()), limit); -} - -function collectExactProjectScopedCodexSessionCandidates( - root: string, - limit: number, - scopeRoots: string[], - sessionId: string, -): CodexSessionCandidate[] { - const scratch = Buffer.allocUnsafe(CODEX_SESSION_META_MAX_BYTES); - const candidates: CodexSessionCandidate[] = []; - for (const candidate of collectCodexSessionCandidates(root, sessionId)) { - if (candidate.filePath.endsWith(".jsonl.zst")) continue; - const meta = readCodexSessionMeta(candidate.filePath, scratch); - if ( - meta - && isImportableCodexSession(meta) - && cwdIsInScope(meta.cwd, scopeRoots) - ) { - candidates.push({ ...candidate, meta }); - } - } - return sortFileCandidatesByMtime(candidates, limit); -} - -function projectScopedCodexSessionCandidates( - args: ExternalSessionDiscoveryArgs, - root: string, - codexDir: string, - limit: number, - scopeRoots: string[], - sessionId: string | null, -): CodexSessionCandidate[] { - if (sessionId) { - return collectExactProjectScopedCodexSessionCandidates( - root, - limit, - scopeRoots, - sessionId, - ); - } - return collectIndexedProjectScopedCodexSessionCandidates( - args, - root, - codexDir, - limit, - scopeRoots, - ); -} - 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); @@ -718,10 +506,10 @@ function idFromCodexFilename(filePath: string): string | null { /** * Resolve a Codex rollout file path by session/thread id with NO originator * filtering. `discoverCodexSessions` is the external-import surface and - * deliberately excludes ADE-originated rollouts (`isImportableCodexSession`), - * which makes it wrong for flows that need ADE's own sessions — e.g. packaging - * a cross-machine fork of an ADE-created Codex chat. Mirrors codex's own - * filename-suffix lookup; returns the newest match or null. + * deliberately excludes ADE-originated rollouts, which makes it wrong for flows + * that need ADE's own sessions — e.g. packaging a cross-machine fork of an + * ADE-created Codex chat. Mirrors codex's own filename-suffix lookup; returns + * the newest match or null. */ export function findCodexRolloutPathBySessionId( sessionId: string, @@ -735,76 +523,320 @@ export function findCodexRolloutPathBySessionId( return candidates[0]?.filePath ?? null; } +function asDbEpochMs(value: unknown): number | null { + if (typeof value === "bigint") return Number(value); + return asEpochMs(value); +} + +type CodexThreadRow = { + id: string; + rolloutPath: string; + cwd: string | null; + source: unknown; + agentRole: string | null; + name: string | null; + createdAt: number | null; + updatedAt: number | null; +}; + +function codexThreadRow(value: unknown): CodexThreadRow | null { + const record = asRecord(value); + const id = asString(record?.id); + const rolloutPath = asString(record?.rolloutPath); + if (!record || !id || !rolloutPath) return null; + return { + id, + rolloutPath, + cwd: asString(record.cwd), + source: record.source, + agentRole: asString(record.agentRole), + name: asString(record.name), + createdAt: asDbEpochMs(record.createdAtMs), + updatedAt: asDbEpochMs(record.updatedAtMs), + }; +} + +/** + * Top-level threads only: every row in `thread_spawn_edges` is a spawned + * subagent, and the DB knows about children whose own `session_meta` carries no + * parent id at all. `archived` rollouts move out of `sessions/`, so excluding + * them here matches what the file scan can see. + * + * `*_ms` columns arrived in a later migration and backfill to 0 for rows the + * triggers never touched, hence the COALESCE onto the second-resolution ones. + */ +function codexThreadRowsSql(exactLookup: boolean): string { + return ` + SELECT t.id AS id, + t.rollout_path AS rolloutPath, + t.cwd AS cwd, + t.source AS source, + t.agent_role AS agentRole, + t.name AS name, + COALESCE(NULLIF(t.created_at_ms, 0), t.created_at * 1000) AS createdAtMs, + COALESCE(NULLIF(t.updated_at_ms, 0), t.updated_at * 1000) AS updatedAtMs + FROM threads t + WHERE t.archived = 0 + AND NOT EXISTS ( + SELECT 1 FROM thread_spawn_edges e WHERE e.child_thread_id = t.id + ) + ${exactLookup ? "AND t.id = ?" : ""} + ORDER BY updatedAtMs DESC, t.id DESC + LIMIT ? + `; +} + +const CODEX_THREAD_TEXT_SQL = ` + SELECT substr(t.preview, 1, ?) AS preview, + substr(t.first_user_message, 1, ?) AS firstUserMessage, + substr(t.title, 1, ?) AS title + FROM threads t + WHERE t.id = ? +`; + +/** + * Whether the store holds any threads at all, asked only when a query came back + * empty. A freshly-migrated or truncated `state_5.sqlite` is not an authority on + * what exists, and must not shadow the rollout tree that still is. + */ +function codexStateDbHasThreads(db: DatabaseSyncType): boolean { + try { + return db.prepare("SELECT 1 AS present FROM threads LIMIT 1").get() != null; + } catch { + return false; + } +} + +function codexThreadTextPreview(db: DatabaseSyncType, id: string): string | null { + try { + const row = asRecord(db.prepare(CODEX_THREAD_TEXT_SQL).get( + CODEX_STATE_DB_TEXT_MAX_CHARS, + CODEX_STATE_DB_TEXT_MAX_CHARS, + CODEX_STATE_DB_TEXT_MAX_CHARS, + id, + )); + return clipExternalSessionText( + asString(row?.preview) ?? asString(row?.firstUserMessage) ?? asString(row?.title), + ); + } catch { + return null; + } +} + +/** + * Hide a parent thread when a fork of it exists and the parent recorded no + * activity after the fork point: that pair is one conversation the user + * continued elsewhere. A parent that kept going after being forked is a + * genuinely separate conversation and stays in the list alongside its fork. + * + * Only forks that are themselves listable count. ADE's own chats fork Codex + * threads too, and collapsing a real terminal session into a fork this surface + * refuses to show would drop the conversation entirely. + */ +function isCodexForkContinuation(row: CodexThreadRow, forkedAtById: Map): boolean { + const forkedAt = forkedAtById.get(row.id); + return forkedAt != null && (row.updatedAt ?? 0) <= forkedAt; +} + +function codexSeedsFromStateDb( + args: ExternalSessionDiscoveryArgs, + codexDir: string, + limit: number, + lookupId: string | null, + ctx: CodexClassifyContext, +): CodexRecordSeed[] | null { + const db = openExternalSessionDb(path.join(codexDir, CODEX_STATE_DB_BASENAME), args.logger); + if (!db) return null; + try { + let rows: CodexThreadRow[]; + try { + const statement = db.prepare(codexThreadRowsSql(lookupId != null)); + const raw = lookupId != null + ? statement.all(lookupId, CODEX_STATE_DB_ROW_CEILING) + : statement.all(CODEX_STATE_DB_ROW_CEILING); + rows = raw.map(codexThreadRow).filter((row): row is CodexThreadRow => row != null); + } catch (error) { + args.logger?.warn?.("external_sessions.codex_state_db_query_failed", { + error: error instanceof Error ? error.message : String(error), + }); + return null; + } + // An empty *result* with threads present is a real answer — everything was + // archived, spawned, or out of scope — and must not fall back to the file + // scan, which cannot tell a subagent rollout from a session. + if (!rows.length && !codexStateDbHasThreads(db)) return null; + + const scratch = Buffer.allocUnsafe(CODEX_SESSION_META_MAX_BYTES); + const forkedAtById = new Map(); + const forkSeedsByParent = new Map(); + const seeds: CodexRecordSeed[] = []; + for (const row of rows) { + if (seeds.length >= limit) break; + if (row.agentRole) continue; + if (!isImportableCodexSource(row.source, ctx)) continue; + if (!cwdIsInScope(row.cwd, args.scopeRoots)) continue; + + const compressed = row.rolloutPath.endsWith(".jsonl.zst"); + const meta = compressed ? null : readCodexSessionMeta(row.rolloutPath, scratch); + // A dangling `rollout_path` (upstream openai/codex#21196) leaves only the + // DB's own classification, which already passed above. + if (meta && !isImportableCodexSession(meta, ctx)) continue; + + // Registered only once this row is known to be listable. A fork this + // surface refuses to show — an ADE chat's own fork — contributes no seed, + // so letting it hide its parent would drop the conversation entirely. + const forkedFromId = meta?.forkedFromId ?? null; + if (forkedFromId) { + // Rows arrive newest-first, and a parent worth collapsing stopped at or + // before the fork point, so such a parent is always seen after its fork. + const forkedAt = row.createdAt ?? meta?.createdAt; + if (forkedAt != null) { + forkedAtById.set(forkedFromId, Math.max(forkedAtById.get(forkedFromId) ?? 0, forkedAt)); + } + } + + if (isCodexForkContinuation(row, forkedAtById)) { + // The fork that hid this parent now speaks for it, including for an + // import the user made against the parent id before forking. + const absorbing = forkSeedsByParent.get(row.id) ?? []; + for (const forkSeed of absorbing) { + forkSeed.lineageIds = [...new Set([...(forkSeed.lineageIds ?? []), row.id])]; + } + // This row is itself a fork of something older, and is no longer here to + // speak for it. Hand its parent claim to whatever absorbed it, so a + // P←F1←F2 chain leaves F2 holding both P and F1. + if (forkedFromId && absorbing.length) { + forkSeedsByParent.set(forkedFromId, [ + ...(forkSeedsByParent.get(forkedFromId) ?? []), + ...absorbing, + ]); + } + continue; + } + + const stat = safeStat(row.rolloutPath); + const seed: CodexRecordSeed = { + id: row.id, + rolloutPath: row.rolloutPath, + cwd: row.cwd ?? meta?.cwd ?? null, + title: row.name ?? meta?.title ?? null, + createdAt: row.createdAt ?? meta?.createdAt ?? null, + updatedAt: row.updatedAt, + mtimeMs: stat?.isFile() ? Math.floor(stat.mtimeMs) : null, + fallbackPreview: stat?.isFile() && !compressed && meta + ? null + : codexThreadTextPreview(db, row.id), + }; + seeds.push(seed); + if (forkedFromId) { + const siblings = forkSeedsByParent.get(forkedFromId) ?? []; + siblings.push(seed); + forkSeedsByParent.set(forkedFromId, siblings); + } + } + return seeds; + } finally { + closeExternalSessionDb(db); + } +} + +/** + * Pre-state-DB discovery: walk `sessions/YYYY/MM/DD` and classify each rollout + * from its `session_meta` prefix. Only reachable when the state DB is missing, + * unreadable, or shaped differently than this build expects. + */ +function codexSeedsFromFiles( + sessionsDir: string, + limit: number, + lookupId: string | null, + scopeRoots: string[] | null | undefined, + ctx: CodexClassifyContext, +): CodexRecordSeed[] { + if (!fs.existsSync(sessionsDir)) return []; + const scoped = Boolean(scopeRoots?.length); + const scanLimit = Math.max(CODEX_RECENT_SCAN_FLOOR, limit * 20); + const candidates = scoped + ? collectCodexSessionCandidates(sessionsDir, lookupId) + : collectRecentCodexSessionCandidates(sessionsDir, scanLimit, lookupId); + const scratch = Buffer.allocUnsafe(CODEX_SESSION_META_MAX_BYTES); + const seedsById = new Map(); + + for (const candidate of candidates) { + const compressed = candidate.filePath.endsWith(".jsonl.zst"); + const meta = compressed ? null : readCodexSessionMeta(candidate.filePath, scratch); + if (!compressed && !meta) continue; + if (meta && !isImportableCodexSession(meta, ctx)) continue; + const id = meta?.id ?? idFromCodexFilename(candidate.filePath); + if (!id || (lookupId && id !== lookupId)) continue; + if (!cwdIsInScope(meta?.cwd ?? null, scopeRoots)) continue; + const existing = seedsById.get(id); + if (existing && existing.mtimeMs >= candidate.mtimeMs) continue; + seedsById.set(id, { + id, + rolloutPath: candidate.filePath, + cwd: meta?.cwd ?? null, + title: meta?.title ?? null, + createdAt: meta?.createdAt ?? null, + updatedAt: null, + mtimeMs: candidate.mtimeMs, + fallbackPreview: null, + }); + } + + return Array.from(seedsById.values()) + .sort((left, right) => right.mtimeMs - left.mtimeMs || left.id.localeCompare(right.id)) + .slice(0, scoped ? limit : scanLimit); +} + +async function codexRecordFromSeed( + seed: CodexRecordSeed, + indexed: CodexIndexEntry | undefined, + exactLookup: boolean, + logger: ExternalSessionDiscoveryArgs["logger"], +): Promise { + const exists = safeStat(seed.rolloutPath)?.isFile() === true; + const readable = exists && !seed.rolloutPath.endsWith(".jsonl.zst"); + const jsonl = readable ? readJsonlRecords(seed.rolloutPath) : []; + const first = asRecord(jsonl[0]); + const payload = asRecord(first?.payload); + const updatedAt = Math.max(seed.updatedAt ?? 0, indexed?.updatedAt ?? 0, seed.mtimeMs ?? 0); + const record = recordWithFile({ + provider: "codex", + id: seed.id, + cwd: seed.cwd, + title: cleanSessionTitle(seed.title) ?? titleFromCodexPayload(payload ?? {}, indexed), + // A readable rollout is the only source that survives ADE-guidance and + // transport stripping intact; DB text only stands in when there is no file. + preview: readable ? firstCodexUserText(jsonl) : seed.fallbackPreview, + createdAt: seed.createdAt ?? asEpochMs(payload?.timestamp) ?? asEpochMs(first?.timestamp), + updatedAt: updatedAt || null, + messageCount: readable ? countJsonlUserMessagesCheap(seed.rolloutPath, "codex") : null, + launch: readable ? await codexLaunchForFile(seed.rolloutPath, jsonl, exactLookup, logger) : null, + filePath: exists ? seed.rolloutPath : null, + sourceMtimeMs: seed.mtimeMs, + }); + if (seed.lineageIds?.length) record.lineageIds = seed.lineageIds; + return record; +} + export async function discoverCodexSessions( args: ExternalSessionDiscoveryArgs = {}, ): Promise { const limit = normalizeExternalSessionLimit(args.limit); const lookupId = args.sessionId?.trim() || null; const codexDir = codexHomeDir(args); - 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 ctx: CodexClassifyContext = { logger: args.logger, warnedSources: new Set() }; - const scanLimit = Math.max(CODEX_RECENT_SCAN_FLOOR, limit * 20); - const candidates = args.scopeRoots?.length - ? projectScopedCodexSessionCandidates( - args, - sessionsDir, - codexDir, - limit, - args.scopeRoots, - lookupId, - ) - : collectRecentCodexSessionCandidates(sessionsDir, scanLimit, lookupId); + const seeds = codexSeedsFromStateDb(args, codexDir, limit, lookupId, ctx) + ?? codexSeedsFromFiles(path.join(codexDir, "sessions"), limit, lookupId, args.scopeRoots, ctx); - for (const candidate of candidates) { - const filePath = candidate.filePath; - const compressed = filePath.endsWith(".jsonl.zst"); - if (compressed) { - const id = idFromCodexFilename(filePath); - if (!id || (lookupId && id !== lookupId) || 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 meta = candidate.meta ?? readCodexSessionMeta(filePath); - if (!meta || !isImportableCodexSession(meta)) continue; - const first = asRecord(jsonl[0]); - const payload = asRecord(first?.payload); - const id = meta.id; - if (!id || (lookupId && id !== lookupId) || recordsById.has(id)) continue; - const indexed = index.get(id); - const firstUserText = firstCodexUserText(jsonl); - const title = meta.title ?? titleFromCodexPayload(payload ?? {}, indexed); - const launch = await codexLaunchForFile(filePath, jsonl, lookupId != null, args.logger); - recordsById.set(id, recordWithFile({ - provider: "codex", - id, - cwd: meta.cwd ?? asString(payload?.cwd), - title, - preview: firstUserText, - createdAt: meta.createdAt ?? asEpochMs(payload?.timestamp) ?? asEpochMs(first?.timestamp), - updatedAt: Math.max(indexed?.updatedAt ?? 0, candidate.mtimeMs), - messageCount: countJsonlUserMessagesCheap(filePath, "codex"), - launch, - filePath, - sourceMtimeMs: candidate.mtimeMs, - })); + const records: ExternalSessionDiscoveryRecord[] = []; + for (const seed of seeds) { + records.push(await codexRecordFromSeed(seed, index.get(seed.id), lookupId != null, args.logger)); } - return sortDiscoveryRecords(Array.from(recordsById.values()), limit) + return sortDiscoveryRecords(records, limit) .map((record) => { if (!record.sourcePath || record.sourcePath.endsWith(".jsonl.zst")) return record; return { diff --git a/apps/desktop/src/main/services/externalSessions/discoverCursor.ts b/apps/desktop/src/main/services/externalSessions/discoverCursor.ts index 7d6e85678..0650349c0 100644 --- a/apps/desktop/src/main/services/externalSessions/discoverCursor.ts +++ b/apps/desktop/src/main/services/externalSessions/discoverCursor.ts @@ -1,17 +1,18 @@ import { createHash } from "node:crypto"; -import { createRequire } from "node:module"; import path from "node:path"; -import type { DatabaseSync as DatabaseSyncType } from "node:sqlite"; import { asEpochMs, asRecord, asString, cleanSessionTitle, + closeExternalSessionDb, countJsonlUserMessagesCheap, cwdCandidatesIncludeScope, cwdIsInScope, firstUserTextFromRecords, + moreCompleteFileCandidate, normalizeExternalSessionLimit, + openExternalSessionDb, readFilePrefix, readJsonlRecords, recordWithFile, @@ -21,32 +22,31 @@ import { safeReadDir, safeParseJson, safeStat, - sessionFileCandidate, slugMatchesScopeRoots, - sortFileCandidatesByMtime, sortDiscoveryRecords, + EXTERNAL_SESSION_READ_BUDGET_MULTIPLIER, type ExternalSessionDiscoveryArgs, - type ExternalSessionFileCandidate, type ExternalSessionDiscoveryRecord, } from "./discoveryUtils"; -type DatabaseSyncConstructor = new ( - dbPath: string, - options?: { allowExtension?: boolean }, -) => DatabaseSyncType; +/** + * Cursor hands this id to unrelated runs, so its artifacts belong to no single + * conversation. Grouping them would fuse strangers into one session. + */ +const CURSOR_BLOCKED_SESSION_IDS = new Set(["00000000-0000-4000-8000-000000000000"]); -const require = createRequire(path.join(process.cwd(), "ade-runtime.cjs")); -const { DatabaseSync } = require("node:sqlite") as { DatabaseSync: DatabaseSyncConstructor }; +/** + * SDK-origin `agent-` runs are excluded everywhere: `cursor-agent --resume` + * cannot reopen them, so surfacing them offers an import that starts empty. + */ +function isCursorConversationId(id: string): boolean { + return id.length > 0 && !id.startsWith("agent-") && !CURSOR_BLOCKED_SESSION_IDS.has(id); +} function cursorProjectSlugForCwd(cwd: string): string { return cwd.replace(/^[/\\]+/u, "").replace(/[\\/]/gu, "-"); } -type CursorStoreCandidate = ExternalSessionFileCandidate<{ - agentId: string; - cwd: string; -}>; - function cursorWorkspaceHash(cwd: string): string { return createHash("md5").update(cwd).digest("hex"); } @@ -57,40 +57,72 @@ function trustedCursorWorkspacePath(projectDir: string): string | null { return asString(record?.workspacePath); } -function cursorWorkspaceByHash( - projectsDir: string, +/** + * Whether a scope decision could be made at all. A slug or hash that reverse-maps + * to nothing is not evidence of being out of project — `empty-window` and buckets + * for vanished directories land here — so those stay in the pool until an artifact + * body can answer. + */ +type CursorScope = "in" | "unknown" | "out"; + +function widerCursorScope(left: CursorScope, right: CursorScope): CursorScope { + if (left === "in" || right === "in") return "in"; + if (left === "unknown" || right === "unknown") return "unknown"; + return "out"; +} + +function cursorScopeForCwd( + cwd: string | null, scopeRoots: readonly string[] | null | undefined, -): Map { - const result = new Map(); - for (const projectEntry of safeReadDir(projectsDir)) { - if (!projectEntry.isDirectory()) continue; - const projectDir = path.join(projectsDir, projectEntry.name); - const cwd = trustedCursorWorkspacePath(projectDir) ?? resolveCursorCwdFromSlug(projectEntry.name); - if (!cwd || !cwdIsInScope(cwd, scopeRoots)) continue; - result.set(cursorWorkspaceHash(cwd), cwd); - } - for (const scopeRoot of scopeRoots ?? []) { - const cwd = path.resolve(scopeRoot); - result.set(cursorWorkspaceHash(cwd), cwd); - } - return result; +): CursorScope { + if (cwdIsInScope(cwd, scopeRoots)) return "in"; + return cwd ? "out" : "unknown"; } -function cursorStoreMtime(storePath: string): number { +type CursorChatMeta = { + cwd: string | null; + title: string | null; + createdAt: number | null; +}; + +/** + * Newer Cursor sessions record their own cwd next to the store, which beats + * reverse-mapping the bucket's md5 through the set of known project directories: + * the hash is only recoverable for a cwd ADE can already name. + */ +function readCursorChatMeta(sessionDir: string): CursorChatMeta | null { + const text = readFilePrefix(path.join(sessionDir, "meta.json"), 64 * 1024); + const record = text ? asRecord(safeParseJson(text)) : null; + if (!record) return null; + return { + cwd: asString(record.cwd), + title: cleanSessionTitle(asString(record.title)), + createdAt: asEpochMs(record.createdAtMs), + }; +} + +function cursorStoreMtime(sessionDir: string): number { + const storePath = path.join(sessionDir, "store.db"); return Math.max( safeStat(storePath)?.mtimeMs ?? 0, safeStat(`${storePath}-wal`)?.mtimeMs ?? 0, + safeStat(path.join(sessionDir, "meta.json"))?.mtimeMs ?? 0, ); } -function readCursorStoreMeta(storePath: string): { - agentId: string; +function readCursorStoreMeta( + storePath: string, + logger: ExternalSessionDiscoveryArgs["logger"], +): { title: string | null; createdAt: number | null; } | null { - let db: DatabaseSyncType | null = null; + // A store Cursor is mid-write on can refuse a read-only open (its WAL needs + // recovery, which only a writer may perform). The session is still resumable, + // so discovery falls back to what the directory says about it. + const db = openExternalSessionDb(storePath, logger); + if (!db) return null; try { - db = new DatabaseSync(storePath); const row = db.prepare("SELECT value FROM meta WHERE key = '0'").get() as { value?: unknown } | undefined; const encoded = typeof row?.value === "string" ? row.value.trim() : ""; if (!encoded) return null; @@ -99,147 +131,253 @@ function readCursorStoreMeta(storePath: string): { : encoded; const record = asRecord(safeParseJson(json)); const agentId = asString(record?.agentId); - if (!agentId || agentId.startsWith("agent-")) return null; + if (!agentId || !isCursorConversationId(agentId)) return null; return { - agentId, title: cleanSessionTitle(asString(record?.name)), createdAt: asEpochMs(record?.createdAt), }; } catch { + // Newer session headers can carry a `blobEncryptionKey`; an unreadable body + // still leaves a resumable session, so fall back to the directory facts. return null; } finally { - db?.close(); + closeExternalSessionDb(db); } } -export async function discoverCursorSessions( - args: ExternalSessionDiscoveryArgs = {}, -): Promise { - const limit = normalizeExternalSessionLimit(args.limit); - const projectsDir = path.join(resolveHomeDir(args), ".cursor", "projects"); - const chatsDir = path.join(resolveHomeDir(args), ".cursor", "chats"); - const lookupId = args.sessionId?.trim() || null; - const candidates: Array> = []; - const workspaceByHash = cursorWorkspaceByHash(projectsDir, args.scopeRoots); - const storeCandidates: CursorStoreCandidate[] = []; +type CursorArtifact = { + filePath: string; + mtimeMs: number; + size: number; + cwd: string | null; + meta: CursorChatMeta | null; +}; + +/** + * One conversation, however many places Cursor wrote it. The bare uuid is the + * only identity shared across md5 buckets, project slugs, and `empty-window`. + */ +type CursorSessionGroup = { + id: string; + store: CursorArtifact | null; + transcript: CursorArtifact | null; + scope: CursorScope; + mtimeMs: number; +}; +function moreCompleteCursorArtifact( + current: CursorArtifact | null, + next: CursorArtifact, +): CursorArtifact { + return current ? moreCompleteFileCandidate(current, next) : next; +} + +function upsertCursorGroup( + groups: Map, + id: string, + kind: "store" | "transcript", + artifact: CursorArtifact, + scope: CursorScope, +): void { + const existing = groups.get(id); + const group: CursorSessionGroup = existing ?? { + id, + store: null, + transcript: null, + scope: "out", + mtimeMs: 0, + }; + if (kind === "store") { + group.store = moreCompleteCursorArtifact(group.store, artifact); + } else { + group.transcript = moreCompleteCursorArtifact(group.transcript, artifact); + } + group.scope = widerCursorScope(group.scope, scope); + group.mtimeMs = Math.max(group.mtimeMs, artifact.mtimeMs); + groups.set(id, group); +} + +/** + * Maps a chat bucket back to its cwd. The bucket name is md5 of the raw process + * cwd, so it can only be recovered by hashing paths ADE already knows about. + */ +function cursorWorkspaceByHash( + projectsDir: string, + scopeRoots: readonly string[] | null | undefined, + resolveSlugCwd: (slug: string) => string | null, +): Map { + const result = new Map(); + for (const projectEntry of safeReadDir(projectsDir)) { + if (!projectEntry.isDirectory()) continue; + const projectDir = path.join(projectsDir, projectEntry.name); + const cwd = trustedCursorWorkspacePath(projectDir) ?? resolveSlugCwd(projectEntry.name); + if (cwd) result.set(cursorWorkspaceHash(cwd), cwd); + } + for (const scopeRoot of scopeRoots ?? []) { + const cwd = path.resolve(scopeRoot); + result.set(cursorWorkspaceHash(cwd), cwd); + } + return result; +} + +function collectCursorStores( + chatsDir: string, + workspaceByHash: Map, + lookupId: string | null, + args: ExternalSessionDiscoveryArgs, + groups: Map, +): void { for (const workspaceEntry of safeReadDir(chatsDir)) { if (!workspaceEntry.isDirectory()) continue; - const cwd = workspaceByHash.get(workspaceEntry.name); - if (!cwd) continue; + const hashedCwd = workspaceByHash.get(workspaceEntry.name) ?? null; + // The bucket name is md5 of exactly one cwd, so a recognised out-of-project + // bucket can be dropped whole — before any file in it is opened. + if (hashedCwd && !cwdIsInScope(hashedCwd, args.scopeRoots)) continue; const workspaceDir = path.join(chatsDir, workspaceEntry.name); - if (lookupId) { - if (lookupId.startsWith("agent-")) continue; - const storePath = path.join(workspaceDir, lookupId, "store.db"); - const stat = safeStat(storePath); - if (stat?.isFile()) { - storeCandidates.push({ - filePath: storePath, - mtimeMs: Math.floor(cursorStoreMtime(storePath)), - size: stat.size, - agentId: lookupId, - cwd, - }); - } - continue; - } - for (const chatEntry of safeReadDir(workspaceDir)) { - if (!chatEntry.isDirectory() || chatEntry.name.startsWith("agent-")) continue; - const storePath = path.join(workspaceDir, chatEntry.name, "store.db"); + const ids = lookupId + ? [lookupId] + : safeReadDir(workspaceDir).filter((entry) => entry.isDirectory()).map((entry) => entry.name); + for (const id of ids) { + if (!isCursorConversationId(id)) continue; + const sessionDir = path.join(workspaceDir, id); + const storePath = path.join(sessionDir, "store.db"); const stat = safeStat(storePath); if (!stat?.isFile()) continue; - storeCandidates.push({ + const meta = readCursorChatMeta(sessionDir); + const cwd = meta?.cwd ?? hashedCwd; + upsertCursorGroup(groups, id, "store", { filePath: storePath, - mtimeMs: Math.floor(cursorStoreMtime(storePath)), + mtimeMs: Math.floor(cursorStoreMtime(sessionDir)), size: stat.size, - agentId: chatEntry.name, cwd, - }); + meta, + }, cursorScopeForCwd(cwd, args.scopeRoots)); } } +} +function cursorTranscriptScope( + projectSlug: string, + trustedCwd: string | null, + args: ExternalSessionDiscoveryArgs, + resolveSlugCwd: (slug: string) => string | null, +): CursorScope { + if ( + cwdIsInScope(trustedCwd, args.scopeRoots) + || slugMatchesScopeRoots(projectSlug, args.scopeRoots, cursorProjectSlugForCwd) + || cwdCandidatesIncludeScope(cursorSlugCwdCandidates(projectSlug), args.scopeRoots) + ) { + return "in"; + } + // A slug that names a real directory has answered the question; one that names + // nothing (`empty-window`, numeric window ids) has not. + return resolveSlugCwd(projectSlug) ? "out" : "unknown"; +} + +function collectCursorTranscripts( + projectsDir: string, + lookupId: string | null, + args: ExternalSessionDiscoveryArgs, + resolveSlugCwd: (slug: string) => string | null, + groups: Map, +): void { for (const projectEntry of safeReadDir(projectsDir)) { if (!projectEntry.isDirectory()) continue; const projectDir = path.join(projectsDir, projectEntry.name); const trustedCwd = trustedCursorWorkspacePath(projectDir); - if ( - !cwdIsInScope(trustedCwd, args.scopeRoots) - && !slugMatchesScopeRoots(projectEntry.name, args.scopeRoots, cursorProjectSlugForCwd) - && !cwdCandidatesIncludeScope(cursorSlugCwdCandidates(projectEntry.name), args.scopeRoots) - ) { - continue; - } + const scope = cursorTranscriptScope(projectEntry.name, trustedCwd, args, resolveSlugCwd); + if (scope === "out") continue; const transcriptRoot = path.join(projectDir, "agent-transcripts"); - if (lookupId) { - if (lookupId.startsWith("agent-")) continue; - const filePath = path.join(transcriptRoot, lookupId, `${lookupId}.jsonl`); - const candidate = sessionFileCandidate(filePath, { - agentId: lookupId, - projectSlug: projectEntry.name, - trustedCwd, - }); - if (candidate) candidates.push(candidate); - continue; - } - 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, - trustedCwd, - }); - if (candidate) candidates.push(candidate); + const ids = lookupId + ? [lookupId] + : safeReadDir(transcriptRoot).filter((entry) => entry.isDirectory()).map((entry) => entry.name); + for (const id of ids) { + if (!isCursorConversationId(id)) continue; + // Only `/.jsonl` is the conversation; the sibling `subagents/` tree + // holds nested runs that are not resumable sessions of their own. + const filePath = path.join(transcriptRoot, id, `${id}.jsonl`); + const stat = safeStat(filePath); + if (!stat?.isFile()) continue; + upsertCursorGroup(groups, id, "transcript", { + filePath, + mtimeMs: Math.floor(stat.mtimeMs), + size: stat.size, + cwd: trustedCwd ?? resolveSlugCwd(projectEntry.name), + meta: null, + }, scope); } } +} - const recordsById = new Map(); - for (const candidate of sortFileCandidatesByMtime(storeCandidates, Math.max(limit * 4, limit))) { - const meta = readCursorStoreMeta(candidate.filePath); - const agentId = meta?.agentId ?? candidate.agentId; - if (!agentId || agentId.startsWith("agent-")) continue; - recordsById.set(agentId, recordWithFile({ - provider: "cursor", - id: agentId, - cwd: candidate.cwd, - title: meta?.title ?? null, - preview: null, - createdAt: meta?.createdAt ?? null, - filePath: candidate.filePath, - sourceMtimeMs: candidate.mtimeMs, - })); - } +export async function discoverCursorSessions( + args: ExternalSessionDiscoveryArgs = {}, +): Promise { + const limit = normalizeExternalSessionLimit(args.limit); + const homeDir = resolveHomeDir(args); + const projectsDir = path.join(homeDir, ".cursor", "projects"); + const chatsDir = path.join(homeDir, ".cursor", "chats"); + const lookupId = args.sessionId?.trim() || null; + if (lookupId && !isCursorConversationId(lookupId)) return []; + + // De-slugging walks the filesystem, and the same slug is consulted once per + // bucket lookup and again per transcript directory. + const slugCwdCache = new Map(); + const resolveSlugCwd = (slug: string): string | null => { + const cached = slugCwdCache.get(slug); + if (cached !== undefined) return cached; + const resolved = resolveCursorCwdFromSlug(slug); + slugCwdCache.set(slug, resolved); + return resolved; + }; + + const groups = new Map(); + const workspaceByHash = cursorWorkspaceByHash(projectsDir, args.scopeRoots, resolveSlugCwd); + collectCursorStores(chatsDir, workspaceByHash, lookupId, args, groups); + collectCursorTranscripts(projectsDir, lookupId, args, resolveSlugCwd, groups); + + // Conversations already proven in project are read first, so a machine full of + // out-of-project Cursor usage cannot crowd them out of the read budget. + const ordered = Array.from(groups.values()) + .filter((group) => group.scope !== "out") + .sort((left, right) => { + if (left.scope !== right.scope) return left.scope === "in" ? -1 : 1; + if (right.mtimeMs !== left.mtimeMs) return right.mtimeMs - left.mtimeMs; + return left.id.localeCompare(right.id); + }) + .slice(0, Math.max(limit * EXTERNAL_SESSION_READ_BUDGET_MULTIPLIER, limit)); - for (const candidate of sortFileCandidatesByMtime(candidates, limit)) { - const jsonl = readJsonlRecords(candidate.filePath); - if (!jsonl.length) continue; + const records: ExternalSessionDiscoveryRecord[] = []; + for (const group of ordered) { + const storeMeta = group.store ? readCursorStoreMeta(group.store.filePath, args.logger) : null; + const jsonl = group.transcript ? readJsonlRecords(group.transcript.filePath) : []; const first = asRecord(jsonl[0]); - const transcriptCwd = cursorCwdFromRecords(jsonl); - const cwd = transcriptCwd ?? candidate.trustedCwd ?? resolveCursorCwdFromSlug(candidate.projectSlug); + const cwd = group.store?.cwd + ?? cursorCwdFromRecords(jsonl) + ?? group.transcript?.cwd + ?? null; + // A conversation whose cwd nothing recorded still belongs in an unscoped + // listing; `cwdIsInScope` is what keeps it out of a project-scoped one. if (!cwdIsInScope(cwd, args.scopeRoots)) continue; - const existing = recordsById.get(candidate.agentId); - const firstPrompt = firstUserTextFromRecords(jsonl); - recordsById.set(candidate.agentId, recordWithFile({ + records.push(recordWithFile({ provider: "cursor", - id: candidate.agentId, - cwd: existing?.cwd ?? cwd, - title: existing?.title ?? null, - preview: firstPrompt, - createdAt: existing?.createdAt ?? asEpochMs(first?.timestamp) ?? asEpochMs(asRecord(first?.message)?.timestamp), - updatedAt: Math.max(existing?.updatedAt ?? 0, candidate.mtimeMs), - messageCount: countJsonlUserMessagesCheap(candidate.filePath, "cursor"), - filePath: candidate.filePath, - sourceMtimeMs: Math.max(existing?.sourceMtimeMs ?? 0, candidate.mtimeMs), + id: group.id, + cwd, + title: storeMeta?.title ?? group.store?.meta?.title ?? null, + preview: jsonl.length ? firstUserTextFromRecords(jsonl) : null, + createdAt: storeMeta?.createdAt + ?? group.store?.meta?.createdAt + ?? asEpochMs(first?.timestamp) + ?? asEpochMs(asRecord(first?.message)?.timestamp), + updatedAt: group.mtimeMs, + messageCount: group.transcript + ? countJsonlUserMessagesCheap(group.transcript.filePath, "cursor") + : null, + filePath: group.transcript?.filePath ?? group.store?.filePath ?? null, + sourceMtimeMs: group.mtimeMs, })); } - return sortDiscoveryRecords(Array.from(recordsById.values()), limit); + return sortDiscoveryRecords(records, limit); } function cursorCwdFromRecords(records: unknown[]): string | null { diff --git a/apps/desktop/src/main/services/externalSessions/discoverDroid.ts b/apps/desktop/src/main/services/externalSessions/discoverDroid.ts index b54ca577a..eec93d121 100644 --- a/apps/desktop/src/main/services/externalSessions/discoverDroid.ts +++ b/apps/desktop/src/main/services/externalSessions/discoverDroid.ts @@ -7,6 +7,7 @@ import { countJsonlUserMessagesCheap, cwdIsInScope, firstUserTextFromRecords, + moreCompleteFileCandidate, normalizeExternalSessionLimit, readJsonlRecords, recordWithFile, @@ -15,50 +16,87 @@ import { sessionFileCandidate, slashEscapedCwd, slugMatchesScopeRoots, - sortFileCandidatesByMtime, sortDiscoveryRecords, + EXTERNAL_SESSION_READ_BUDGET_MULTIPLIER, type ExternalSessionDiscoveryArgs, type ExternalSessionFileCandidate, type ExternalSessionDiscoveryRecord, } from "./discoveryUtils"; +/** + * A session directory is the slash-escaped cwd, which for an absolute path always + * begins with the escaped separator. Anything else is a shape ADE cannot map back + * to a path, so only the file's own `session_start` row can place it. + */ +type DroidCandidate = ExternalSessionFileCandidate<{ inRequestedScope: boolean }>; + +function droidDirectoryNamesAPath(directoryName: string): boolean { + return directoryName.startsWith("-"); +} + +function moreCompleteDroidCandidate(current: DroidCandidate, next: DroidCandidate): DroidCandidate { + if (next.inRequestedScope !== current.inRequestedScope) { + return next.inRequestedScope ? next : current; + } + return moreCompleteFileCandidate(current, next); +} + export async function discoverDroidSessions( args: ExternalSessionDiscoveryArgs = {}, ): Promise { const limit = normalizeExternalSessionLimit(args.limit); const sessionsDir = path.join(resolveHomeDir(args), ".factory", "sessions"); const lookupId = args.sessionId?.trim() || null; - const candidates: ExternalSessionFileCandidate[] = []; + // A session id can be written under more than one escaped cwd; keeping one + // candidate per id stops duplicates from spending the read budget twice and + // from reaching the caller as two rows for one session. + const candidatesById = new Map(); for (const projectEntry of safeReadDir(sessionsDir)) { if (!projectEntry.isDirectory()) continue; - if ( - projectEntry.name.startsWith("-") - && !slugMatchesScopeRoots(projectEntry.name, args.scopeRoots, slashEscapedCwd) - ) { - continue; - } + const namesAPath = droidDirectoryNamesAPath(projectEntry.name); + const inRequestedScope = slugMatchesScopeRoots(projectEntry.name, args.scopeRoots, slashEscapedCwd); + // Out-of-project directories are ruled out here, before the mtime cut that + // would otherwise let heavy usage elsewhere crowd in-project sessions out. + if (namesAPath && !inRequestedScope) continue; const projectDir = path.join(sessionsDir, projectEntry.name); - if (lookupId) { - const candidate = sessionFileCandidate(path.join(projectDir, `${lookupId}.jsonl`), {}); - if (candidate) candidates.push(candidate); - continue; - } - 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 fileNames = lookupId + ? [`${lookupId}.jsonl`] + : safeReadDir(projectDir) + .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl")) + .map((entry) => entry.name); + for (const fileName of fileNames) { + const id = path.basename(fileName, ".jsonl"); + if (!id) continue; + const candidate = sessionFileCandidate(path.join(projectDir, fileName), { + inRequestedScope: namesAPath && inRequestedScope, + }); + if (!candidate) continue; + const existing = candidatesById.get(id); + candidatesById.set(id, existing ? moreCompleteDroidCandidate(existing, candidate) : candidate); } } - const records: ExternalSessionDiscoveryRecord[] = []; - for (const candidate of sortFileCandidatesByMtime(candidates, limit)) { + // Sessions already placed in the project are read first, so ones that only + // their own contents can place cannot displace a confirmed match. + const ordered = Array.from(candidatesById.values()) + .sort((left, right) => { + if (left.inRequestedScope !== right.inRequestedScope) return left.inRequestedScope ? -1 : 1; + if (right.mtimeMs !== left.mtimeMs) return right.mtimeMs - left.mtimeMs; + return left.filePath.localeCompare(right.filePath); + }) + .slice(0, Math.max(limit * EXTERNAL_SESSION_READ_BUDGET_MULTIPLIER, limit)); + + const recordsById = new Map(); + for (const candidate of ordered) { 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 || (lookupId && id !== lookupId)) continue; + // The recorded id can differ from the file name; candidates arrive best-first, + // so an earlier claim on the same id is the one worth keeping. + if (recordsById.has(id)) continue; const cwd = asString(first.cwd); if (!cwdIsInScope(cwd, args.scopeRoots)) continue; const firstUserText = firstUserTextFromRecords(jsonl); @@ -68,7 +106,7 @@ export async function discoverDroidSessions( asEpochMs(row?.timestamp) != null || asEpochMs(asRecord(row?.message)?.timestamp) != null )); - records.push(recordWithFile({ + recordsById.set(id, recordWithFile({ provider: "droid", id, cwd, @@ -83,5 +121,5 @@ export async function discoverDroidSessions( })); } - return sortDiscoveryRecords(records, limit); + return sortDiscoveryRecords(Array.from(recordsById.values()), limit); } diff --git a/apps/desktop/src/main/services/externalSessions/discoverOpenCode.ts b/apps/desktop/src/main/services/externalSessions/discoverOpenCode.ts index 2ee1180e1..5a7d5beb7 100644 --- a/apps/desktop/src/main/services/externalSessions/discoverOpenCode.ts +++ b/apps/desktop/src/main/services/externalSessions/discoverOpenCode.ts @@ -26,7 +26,13 @@ export async function discoverOpenCodeSessions( const limit = normalizeExternalSessionLimit(args.limit); const lookupId = args.sessionId?.trim() || null; const executable = resolveOpenCodeBinaryPath(); - if (!executable) return []; + // Returning [] here is indistinguishable from "no sessions yet", which is why + // a machine without OpenCode installed used to show an empty list and no + // explanation. The service surfaces this to the caller that asked only for + // OpenCode and logs it for a mixed-provider scan. + if (!executable) { + throw new Error("OpenCode CLI not found: install `opencode` to import its sessions."); + } const requestedCwd = args.cwd?.trim() || args.projectRoot?.trim() || null; const cwd = requestedCwd ?? resolveHomeDir(args); // OpenCode may omit the directory from `session list` rows. When discovery diff --git a/apps/desktop/src/main/services/externalSessions/discoverProviders.test.ts b/apps/desktop/src/main/services/externalSessions/discoverProviders.test.ts index 8a4261977..9e4bbc71e 100644 --- a/apps/desktop/src/main/services/externalSessions/discoverProviders.test.ts +++ b/apps/desktop/src/main/services/externalSessions/discoverProviders.test.ts @@ -29,6 +29,133 @@ function writeJsonl(filePath: string, rows: unknown[]): void { fs.writeFileSync(filePath, rows.map((row) => JSON.stringify(row)).join("\n") + "\n", "utf8"); } +function touch(filePath: string, epochMs: number): void { + const seconds = epochMs / 1000; + fs.utimesSync(filePath, seconds, seconds); +} + +function writeCursorStore(storePath: string, meta: Record, padBytes = 0): void { + fs.mkdirSync(path.dirname(storePath), { recursive: true }); + const db = new DatabaseSync(storePath); + try { + db.exec("CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)"); + db.prepare("INSERT INTO meta (key, value) VALUES ('0', ?)") + .run(Buffer.from(JSON.stringify(meta), "utf8").toString("hex")); + if (padBytes > 0) { + db.exec("CREATE TABLE pad (value TEXT)"); + db.prepare("INSERT INTO pad (value) VALUES (?)").run("x".repeat(padBytes)); + } + } finally { + db.close(); + } +} + +function writeCursorChatMeta(sessionDir: string, meta: Record): void { + fs.mkdirSync(sessionDir, { recursive: true }); + fs.writeFileSync(path.join(sessionDir, "meta.json"), JSON.stringify({ schemaVersion: 1, ...meta }), "utf8"); +} + +function cursorTranscriptPath(homeDir: string, projectSlug: string, id: string): string { + return path.join(homeDir, ".cursor", "projects", projectSlug, "agent-transcripts", id, `${id}.jsonl`); +} + +function cursorStoreDir(homeDir: string, cwd: string, id: string): string { + return path.join(homeDir, ".cursor", "chats", createHash("md5").update(cwd).digest("hex"), id); +} + +function codexRolloutPath(homeDir: string, id: string, day = "08"): string { + return path.join(homeDir, ".codex", "sessions", "2026", "07", day, `rollout-2026-07-${day}T10-00-00-${id}.jsonl`); +} + +function writeCodexRollout(filePath: string, meta: Record, rows: unknown[] = []): void { + writeJsonl(filePath, [{ type: "session_meta", payload: { source: "cli", originator: "codex-tui", ...meta } }, ...rows]); +} + +function codexUserRow(message: string): unknown { + return { type: "event_msg", payload: { type: "user_message", message } }; +} + +type CodexThreadFixture = { + id: string; + rolloutPath: string; + cwd: string; + createdAtMs: number; + updatedAtMs: number; + source?: string; + archived?: number; + agentRole?: string | null; + name?: string | null; + title?: string; + preview?: string; + firstUserMessage?: string; +}; + +/** Mirrors the columns `discoverCodex` reads from a real `~/.codex/state_5.sqlite`. */ +function writeCodexStateDb( + homeDir: string, + threads: CodexThreadFixture[], + spawnEdges: Array<{ parent: string; child: string }> = [], +): void { + const dbPath = path.join(homeDir, ".codex", "state_5.sqlite"); + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + const db = new DatabaseSync(dbPath); + try { + db.exec(` + CREATE TABLE threads ( + id TEXT PRIMARY KEY, + rollout_path TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + source TEXT NOT NULL, + cwd TEXT NOT NULL, + title TEXT NOT NULL DEFAULT '', + archived INTEGER NOT NULL DEFAULT 0, + agent_role TEXT, + name TEXT, + created_at_ms INTEGER, + updated_at_ms INTEGER, + preview TEXT NOT NULL DEFAULT '', + first_user_message TEXT NOT NULL DEFAULT '' + ); + CREATE TABLE thread_spawn_edges ( + parent_thread_id TEXT NOT NULL, + child_thread_id TEXT NOT NULL PRIMARY KEY, + status TEXT NOT NULL + ); + `); + const insertThread = db.prepare(` + INSERT INTO threads ( + id, rollout_path, created_at, updated_at, source, cwd, title, archived, + agent_role, name, created_at_ms, updated_at_ms, preview, first_user_message + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + for (const thread of threads) { + insertThread.run( + thread.id, + thread.rolloutPath, + Math.floor(thread.createdAtMs / 1000), + Math.floor(thread.updatedAtMs / 1000), + thread.source ?? "cli", + thread.cwd, + thread.title ?? "", + thread.archived ?? 0, + thread.agentRole ?? null, + thread.name ?? null, + thread.createdAtMs, + thread.updatedAtMs, + thread.preview ?? "", + thread.firstUserMessage ?? "", + ); + } + const insertEdge = db.prepare( + "INSERT INTO thread_spawn_edges (parent_thread_id, child_thread_id, status) VALUES (?, ?, ?)", + ); + for (const edge of spawnEdges) insertEdge.run(edge.parent, edge.child, "closed"); + } finally { + db.close(); + } +} + function appendLargeCodexAgentMessage(filePath: string, messageBytes: number): void { const fd = fs.openSync(filePath, "a"); const chunk = Buffer.alloc(1024 * 1024, 0x78); @@ -354,148 +481,273 @@ describe("external session provider discovery", () => { }, }); }); + it("treats the Codex state DB as the top-level authority and falls back when it is unusable", async () => { + const homeDir = path.join(root, "home"); + const cwd = path.join(root, "repo"); + fs.mkdirSync(cwd, { recursive: true }); + const parentId = "31000000-0000-4000-8000-000000000000"; + const childId = "31000000-0000-4000-8000-000000000001"; + const parentPath = codexRolloutPath(homeDir, parentId); + const childPath = codexRolloutPath(homeDir, childId); + // The spawned child's own session_meta carries no parent marker at all — + // only `thread_spawn_edges` knows it is a subagent. + writeCodexRollout(parentPath, { id: parentId, cwd }, [codexUserRow("parent request")]); + writeCodexRollout(childPath, { id: childId, cwd }, [codexUserRow("worker request")]); + const base = Date.parse("2026-07-08T10:00:00.000Z"); + writeCodexStateDb(homeDir, [ + { id: parentId, rolloutPath: parentPath, cwd, createdAtMs: base, updatedAtMs: base + 60_000 }, + { id: childId, rolloutPath: childPath, cwd, createdAtMs: base + 10_000, updatedAtMs: base + 20_000 }, + ], [{ parent: parentId, child: childId }]); + + await expect(discoverCodexSessions({ homeDir, limit: 10 })).resolves.toEqual([ + expect.objectContaining({ id: parentId, cwd, preview: "parent request" }), + ]); - it("finds project-scoped Codex sessions beyond 2,000 newer rollouts", async () => { + fs.writeFileSync(path.join(homeDir, ".codex", "state_5.sqlite"), "not a database", "utf8"); + const warn = vi.fn(); + const sessions = await discoverCodexSessions({ homeDir, limit: 10, logger: { warn } }); + + // Without the DB the file scan cannot tell the two apart, which is exactly + // the leak the state DB closes. + expect(sessions.map((session) => session.id).sort()).toEqual([parentId, childId].sort()); + expect(warn).toHaveBeenCalledWith( + expect.stringMatching(/^external_sessions\.(sqlite_db_unavailable|codex_state_db_query_failed)$/u), + expect.anything(), + ); + }); + + it("falls back to the rollout scan when the state DB exists but holds no threads", async () => { const homeDir = path.join(root, "home"); - const projectCwd = path.join(root, "repo"); - const otherCwd = path.join(root, "other-repo"); - const outsideDir = path.join(homeDir, ".codex", "sessions", "2026", "07", "08"); - const insideDir = path.join(homeDir, ".codex", "sessions", "2026", "07", "07"); - const outsideId = "26000000-0000-4000-8000-000000000000"; - const insideId = "26000000-0000-4000-8000-000000000001"; - fs.mkdirSync(projectCwd, { recursive: true }); - fs.mkdirSync(otherCwd, { recursive: true }); + const cwd = path.join(root, "repo"); + fs.mkdirSync(cwd, { recursive: true }); + const sessionId = "38000000-0000-4000-8000-000000000000"; + writeCodexRollout(codexRolloutPath(homeDir, sessionId), { id: sessionId, cwd }, [ + codexUserRow("survived the migration"), + ]); + // A freshly-migrated or truncated state_5.sqlite knows about nothing; the + // rollout tree is still the user's session history. + writeCodexStateDb(homeDir, []); - const outsideTemplate = path.join(outsideDir, "rollout-outside-0000.jsonl"); - writeJsonl(outsideTemplate, [ - { - type: "session_meta", - payload: { id: outsideId, cwd: otherCwd, source: "cli", originator: "codex-tui" }, - }, - { type: "event_msg", payload: { type: "user_message", message: "outside project" } }, + const sessions = await discoverCodexSessions({ homeDir, limit: 10 }); + + expect(sessions.map((session) => session.id)).toEqual([sessionId]); + }); + + it("hides a forked Codex parent that recorded no activity after the fork", async () => { + const homeDir = path.join(root, "home"); + const cwd = path.join(root, "repo"); + fs.mkdirSync(cwd, { recursive: true }); + const parentId = "32000000-0000-4000-8000-000000000000"; + const forkId = "32000000-0000-4000-8000-000000000001"; + const parentPath = codexRolloutPath(homeDir, parentId); + const forkPath = codexRolloutPath(homeDir, forkId); + const base = Date.parse("2026-07-08T10:00:00.000Z"); + writeCodexRollout(parentPath, { id: parentId, cwd }, [codexUserRow("original request")]); + // A fork replays the parent transcript, so the parent's own session_meta + // reappears below line 1 and must not be mistaken for this rollout's. + writeCodexRollout(forkPath, { id: forkId, cwd, forked_from_id: parentId }, [ + { type: "session_meta", payload: { id: parentId, cwd, source: "cli", originator: "codex-tui" } }, + codexUserRow("original request"), + codexUserRow("continue from here"), + ]); + writeCodexStateDb(homeDir, [ + { id: parentId, rolloutPath: parentPath, cwd, createdAtMs: base, updatedAtMs: base + 60_000 }, + { id: forkId, rolloutPath: forkPath, cwd, createdAtMs: base + 60_000, updatedAtMs: base + 120_000 }, ]); - for (let index = 1; index <= 2_000; index += 1) { - fs.linkSync(outsideTemplate, path.join(outsideDir, `rollout-outside-${String(index).padStart(4, "0")}.jsonl`)); - } - fs.utimesSync( - outsideTemplate, - new Date("2026-07-08T12:00:00.000Z"), - new Date("2026-07-08T12:00:00.000Z"), - ); - const insidePath = path.join(insideDir, `rollout-${insideId}.jsonl`); - writeJsonl(insidePath, [ - { - type: "session_meta", - payload: { id: insideId, cwd: projectCwd, source: "cli", originator: "codex-tui" }, - }, - { type: "event_msg", payload: { type: "user_message", message: "inside project" } }, + const sessions = await discoverCodexSessions({ homeDir, limit: 10 }); + + expect(sessions.map((session) => session.id)).toEqual([forkId]); + }); + + it("keeps a forked Codex parent that kept going after the fork", async () => { + const homeDir = path.join(root, "home"); + const cwd = path.join(root, "repo"); + fs.mkdirSync(cwd, { recursive: true }); + const parentId = "33000000-0000-4000-8000-000000000000"; + const forkId = "33000000-0000-4000-8000-000000000001"; + const parentPath = codexRolloutPath(homeDir, parentId); + const forkPath = codexRolloutPath(homeDir, forkId); + const base = Date.parse("2026-07-08T10:00:00.000Z"); + writeCodexRollout(parentPath, { id: parentId, cwd }, [codexUserRow("original request")]); + writeCodexRollout(forkPath, { id: forkId, cwd, forked_from_id: parentId }, [codexUserRow("branch request")]); + writeCodexStateDb(homeDir, [ + { id: parentId, rolloutPath: parentPath, cwd, createdAtMs: base, updatedAtMs: base + 600_000 }, + { id: forkId, rolloutPath: forkPath, cwd, createdAtMs: base + 60_000, updatedAtMs: base + 120_000 }, ]); - fs.utimesSync( - insidePath, - new Date("2026-07-07T12:00:00.000Z"), - new Date("2026-07-07T12:00:00.000Z"), - ); - const sessions = await discoverCodexSessions({ - homeDir, - limit: 1, - scopeRoots: [projectCwd], - }); + const sessions = await discoverCodexSessions({ homeDir, limit: 10 }); - expect(sessions).toEqual([ - expect.objectContaining({ - id: insideId, - cwd: projectCwd, - preview: "inside project", - }), + expect(sessions.map((session) => session.id).sort()).toEqual([parentId, forkId].sort()); + }); + + it("keeps a Codex parent whose only fork is itself filtered out of the list", async () => { + const homeDir = path.join(root, "home"); + const cwd = path.join(root, "repo"); + fs.mkdirSync(cwd, { recursive: true }); + const parentId = "37000000-0000-4000-8000-000000000000"; + const forkId = "37000000-0000-4000-8000-000000000001"; + const parentPath = codexRolloutPath(homeDir, parentId); + const forkPath = codexRolloutPath(homeDir, forkId); + const base = Date.parse("2026-07-08T10:00:00.000Z"); + writeCodexRollout(parentPath, { id: parentId, cwd }, [codexUserRow("original request")]); + // ADE's own chats fork Codex threads; that fork is never listed here, so + // collapsing the parent into it would drop the conversation entirely. + writeCodexRollout(forkPath, { id: forkId, cwd, source: "vscode", originator: "ade_desktop", forked_from_id: parentId }, [ + codexUserRow("continued inside ADE"), + ]); + writeCodexStateDb(homeDir, [ + { id: parentId, rolloutPath: parentPath, cwd, createdAtMs: base, updatedAtMs: base + 60_000 }, + { id: forkId, rolloutPath: forkPath, cwd, source: "vscode", createdAtMs: base + 60_000, updatedAtMs: base + 120_000 }, + ]); + + const sessions = await discoverCodexSessions({ homeDir, limit: 10 }); + + expect(sessions.map((session) => session.id)).toEqual([parentId]); + }); + + it("keeps a Codex parent whose only fork is originator-filtered", async () => { + const homeDir = path.join(root, "home"); + const cwd = path.join(root, "repo"); + fs.mkdirSync(cwd, { recursive: true }); + const parentId = "3a000000-0000-4000-8000-000000000000"; + const forkId = "3a000000-0000-4000-8000-000000000001"; + const parentPath = codexRolloutPath(homeDir, parentId); + const forkPath = codexRolloutPath(homeDir, forkId); + const base = Date.parse("2026-07-08T10:00:00.000Z"); + writeCodexRollout(parentPath, { id: parentId, cwd }, [codexUserRow("original request")]); + // Source `cli` passes every DB-level check, so this fork is only rejected + // once its rollout's originator is read — after the point where a fork + // registers its claim on the parent. + writeCodexRollout(forkPath, { id: forkId, cwd, originator: "ade_desktop", forked_from_id: parentId }, [ + codexUserRow("continued inside ADE"), + ]); + writeCodexStateDb(homeDir, [ + { id: parentId, rolloutPath: parentPath, cwd, createdAtMs: base, updatedAtMs: base + 60_000 }, + { id: forkId, rolloutPath: forkPath, cwd, createdAtMs: base + 60_000, updatedAtMs: base + 120_000 }, + ]); + + const sessions = await discoverCodexSessions({ homeDir, limit: 10 }); + + expect(sessions.map((session) => session.id)).toEqual([parentId]); + }); + + it("carries a whole Codex fork chain onto the surviving thread", async () => { + const homeDir = path.join(root, "home"); + const cwd = path.join(root, "repo"); + fs.mkdirSync(cwd, { recursive: true }); + const rootId = "3b000000-0000-4000-8000-000000000000"; + const middleId = "3b000000-0000-4000-8000-000000000001"; + const leafId = "3b000000-0000-4000-8000-000000000002"; + const rootPath = codexRolloutPath(homeDir, rootId); + const middlePath = codexRolloutPath(homeDir, middleId); + const leafPath = codexRolloutPath(homeDir, leafId); + const base = Date.parse("2026-07-08T10:00:00.000Z"); + writeCodexRollout(rootPath, { id: rootId, cwd }, [codexUserRow("original request")]); + writeCodexRollout(middlePath, { id: middleId, cwd, forked_from_id: rootId }, [codexUserRow("first branch")]); + writeCodexRollout(leafPath, { id: leafId, cwd, forked_from_id: middleId }, [codexUserRow("second branch")]); + writeCodexStateDb(homeDir, [ + { id: rootId, rolloutPath: rootPath, cwd, createdAtMs: base, updatedAtMs: base + 60_000 }, + { id: middleId, rolloutPath: middlePath, cwd, createdAtMs: base + 60_000, updatedAtMs: base + 120_000 }, + { id: leafId, rolloutPath: leafPath, cwd, createdAtMs: base + 120_000, updatedAtMs: base + 180_000 }, ]); - const cacheDir = path.join(homeDir, ".ade", "cache", "external-sessions"); - expect(fs.readdirSync(cacheDir)).toEqual([ - expect.stringMatching(/^codex-cwd-index-[0-9a-f]{16}\.json$/u), + + const sessions = await discoverCodexSessions({ homeDir, limit: 10 }); + + // Each fork stopped its parent, so one row survives — and it has to answer + // for every id in the chain, or an import made against the original loses + // its badge and the original can be hidden as an ADE-created artifact. + expect(sessions.map((session) => session.id)).toEqual([leafId]); + expect([...(sessions[0]?.lineageIds ?? [])].sort()).toEqual([middleId, rootId].sort()); + }); + + it("scopes Codex discovery by state DB cwd without reading out-of-scope rollouts", async () => { + const homeDir = path.join(root, "home"); + const projectCwd = path.join(root, "repo"); + const otherCwd = path.join(root, "other-repo"); + fs.mkdirSync(projectCwd, { recursive: true }); + fs.mkdirSync(otherCwd, { recursive: true }); + const insideId = "34000000-0000-4000-8000-000000000000"; + const outsideId = "34000000-0000-4000-8000-000000000001"; + const insidePath = codexRolloutPath(homeDir, insideId, "07"); + const outsidePath = codexRolloutPath(homeDir, outsideId, "08"); + writeCodexRollout(insidePath, { id: insideId, cwd: projectCwd }, [codexUserRow("inside project")]); + writeCodexRollout(outsidePath, { id: outsideId, cwd: otherCwd }, [codexUserRow("outside project")]); + const base = Date.parse("2026-07-08T10:00:00.000Z"); + writeCodexStateDb(homeDir, [ + { id: insideId, rolloutPath: insidePath, cwd: projectCwd, createdAtMs: base, updatedAtMs: base + 1_000 }, + { id: outsideId, rolloutPath: outsidePath, cwd: otherCwd, createdAtMs: base, updatedAtMs: base + 900_000 }, ]); const openSync = vi.spyOn(fs, "openSync"); let openedPaths: string[] = []; try { - await expect(discoverCodexSessions({ - homeDir, - limit: 1, - scopeRoots: [projectCwd], - })).resolves.toEqual([ - expect.objectContaining({ id: insideId }), + await expect(discoverCodexSessions({ homeDir, limit: 10, scopeRoots: [projectCwd] })).resolves.toEqual([ + expect.objectContaining({ id: insideId, cwd: projectCwd, preview: "inside project" }), ]); openedPaths = openSync.mock.calls.map((call) => String(call[0])); } finally { openSync.mockRestore(); } - expect(openedPaths.some((filePath) => filePath.startsWith(outsideDir))).toBe(false); + expect(openedPaths).not.toContain(outsidePath); }); - it("batches Codex scope index rewrites while an active rollout changes", async () => { - vi.useFakeTimers(); + it("lists Codex rollouts with an unrecognized source instead of dropping them", async () => { const homeDir = path.join(root, "home"); const cwd = path.join(root, "repo"); - const id = "26000000-0000-4000-8000-000000000002"; - const rolloutPath = path.join( - homeDir, - ".codex", - "sessions", - "2026", - "07", - "08", - `rollout-${id}.jsonl`, - ); fs.mkdirSync(cwd, { recursive: true }); - writeJsonl(rolloutPath, [ + const id = "35000000-0000-4000-8000-000000000000"; + const rolloutPath = codexRolloutPath(homeDir, id); + writeCodexRollout(rolloutPath, { id, cwd, source: "wingman" }, [codexUserRow("new entrypoint")]); + const base = Date.parse("2026-07-08T10:00:00.000Z"); + writeCodexStateDb(homeDir, [ + { id, rolloutPath, cwd, source: "wingman", createdAtMs: base, updatedAtMs: base + 1_000 }, + ]); + const warn = vi.fn(); + + await expect(discoverCodexSessions({ homeDir, limit: 10, logger: { warn } })).resolves.toEqual([ + expect.objectContaining({ id, preview: "new entrypoint" }), + ]); + expect(warn).toHaveBeenCalledWith("external_sessions.codex_unknown_source", { source: "wingman" }); + }); + + it("lists Codex threads whose rollout file is compressed or missing", async () => { + const homeDir = path.join(root, "home"); + const cwd = path.join(root, "repo"); + fs.mkdirSync(cwd, { recursive: true }); + const compressedId = "36000000-0000-4000-8000-000000000000"; + const danglingId = "36000000-0000-4000-8000-000000000001"; + const compressedPath = `${codexRolloutPath(homeDir, compressedId)}.zst`; + const danglingPath = codexRolloutPath(homeDir, danglingId); + fs.mkdirSync(path.dirname(compressedPath), { recursive: true }); + fs.writeFileSync(compressedPath, Buffer.from([0x28, 0xb5, 0x2f, 0xfd])); + const base = Date.parse("2026-07-08T10:00:00.000Z"); + fs.utimesSync(compressedPath, new Date(base + 200_000), new Date(base + 200_000)); + writeCodexStateDb(homeDir, [ { - type: "session_meta", - payload: { id, cwd, source: "cli", originator: "codex-tui" }, + id: compressedId, + rolloutPath: compressedPath, + cwd, + createdAtMs: base, + updatedAtMs: base + 200_000, + preview: "archived to zstd", + }, + { + id: danglingId, + rolloutPath: danglingPath, + cwd, + createdAtMs: base, + updatedAtMs: base + 100_000, + firstUserMessage: "rollout file is gone", }, - { type: "event_msg", payload: { type: "user_message", message: "first turn" } }, ]); - const writeFileSync = vi.spyOn(fs, "writeFileSync"); - try { - await discoverCodexSessions({ homeDir, limit: 1, scopeRoots: [cwd] }); - const indexWrites = () => writeFileSync.mock.calls.filter( - ([filePath]) => String(filePath).includes("codex-cwd-index-"), - ); - expect(indexWrites()).toHaveLength(1); - - fs.appendFileSync( - rolloutPath, - `${JSON.stringify({ type: "event_msg", payload: { type: "user_message", message: "second turn" } })}\n`, - "utf8", - ); - await discoverCodexSessions({ homeDir, limit: 1, scopeRoots: [cwd] }); + const sessions = await discoverCodexSessions({ homeDir, limit: 10 }); - expect(indexWrites()).toHaveLength(1); - fs.appendFileSync( - rolloutPath, - `${JSON.stringify({ type: "event_msg", payload: { type: "user_message", message: "third turn" } })}\n`, - "utf8", - ); - await discoverCodexSessions({ homeDir, limit: 1, scopeRoots: [cwd] }); - expect(indexWrites()).toHaveLength(1); - - await vi.advanceTimersByTimeAsync(30_000); - expect(indexWrites()).toHaveLength(2); - - const cacheDir = path.join(homeDir, ".ade", "cache", "external-sessions"); - const indexPath = path.join(cacheDir, fs.readdirSync(cacheDir)[0]!); - const index = JSON.parse(fs.readFileSync(indexPath, "utf8")) as { - entries: Record; - }; - const relativePath = path.relative( - path.join(homeDir, ".codex", "sessions"), - rolloutPath, - ).split(path.sep).join("/"); - expect(index.entries[relativePath]?.size).toBe(fs.statSync(rolloutPath).size); - } finally { - writeFileSync.mockRestore(); - vi.useRealTimers(); - } + expect(sessions).toEqual([ + expect.objectContaining({ id: compressedId, cwd, preview: "archived to zstd", updatedAt: base + 200_000 }), + expect.objectContaining({ id: danglingId, cwd, preview: "rollout file is gone", updatedAt: base + 100_000 }), + ]); }); it("excludes Codex subagent rollouts with object-shaped source metadata", async () => { @@ -807,7 +1059,178 @@ describe("external session provider discovery", () => { }); }); - it("uses the OpenCode CLI list command and skips cleanly when unavailable", async () => { + it("keeps one Droid record when a session id appears under two escaped cwds", async () => { + const homeDir = path.join(root, "home"); + const cwd = path.join(root, "droid-dupe-repo"); + const nestedCwd = path.join(cwd, "apps"); + const id = "77777777-7777-4777-8777-777777777777"; + writeJsonl(path.join(homeDir, ".factory", "sessions", slashEscapedCwd(cwd), `${id}.jsonl`), [ + { type: "session_start", id, title: "Droid duplicate", cwd }, + { type: "message", timestamp: "2026-07-06T10:00:00.000Z", message: { role: "user", content: [{ type: "text", text: "First prompt" }] } }, + { type: "message", timestamp: "2026-07-06T10:01:00.000Z", message: { role: "user", content: [{ type: "text", text: "Second prompt" }] } }, + ]); + writeJsonl(path.join(homeDir, ".factory", "sessions", slashEscapedCwd(nestedCwd), `${id}.jsonl`), [ + { type: "session_start", id, title: "Droid duplicate", cwd: nestedCwd }, + ]); + + const sessions = await discoverDroidSessions({ homeDir, limit: 10 }); + + expect(sessions).toHaveLength(1); + expect(sessions[0]).toMatchObject({ id, cwd, messageCount: 2 }); + }); + + it("scopes Droid session directories before the recent-session cut", async () => { + const homeDir = path.join(root, "home"); + const cwd = path.join(root, "droid-scoped-repo"); + const elsewhere = path.join(root, "droid-elsewhere"); + const inProjectId = "88888888-8888-4888-8888-888888888888"; + const inProjectPath = path.join(homeDir, ".factory", "sessions", slashEscapedCwd(cwd), `${inProjectId}.jsonl`); + writeJsonl(inProjectPath, [ + { type: "session_start", id: inProjectId, title: "In project", cwd }, + { type: "message", timestamp: "2026-07-01T10:00:00.000Z", message: { role: "user", content: [{ type: "text", text: "In project prompt" }] } }, + ]); + touch(inProjectPath, 1_780_000_000_000); + // Newer sessions elsewhere: one batch under an escaped path that scope rules + // out outright, one under a name that only the session's own cwd can place. + for (let index = 0; index < 3; index += 1) { + for (const directory of [slashEscapedCwd(elsewhere), "relative-elsewhere"]) { + const id = `9999999${directory.length % 10}-9999-4999-8999-99999999999${index}`; + const filePath = path.join(homeDir, ".factory", "sessions", directory, `${id}.jsonl`); + writeJsonl(filePath, [ + { type: "session_start", id, title: "Elsewhere", cwd: elsewhere }, + { type: "message", timestamp: "2026-07-20T10:00:00.000Z", message: { role: "user", content: [{ type: "text", text: `Elsewhere prompt ${index}` }] } }, + ]); + touch(filePath, 1_782_000_000_000 + index); + } + } + + const sessions = await discoverDroidSessions({ homeDir, scopeRoots: [cwd], limit: 1 }); + + expect(sessions.map((session) => session.id)).toEqual([inProjectId]); + }); + + it("merges duplicate Cursor artifacts of one conversation instead of spending a session slot on each", async () => { + const homeDir = path.join(root, "home"); + const cwd = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "adecursordupe"))); + 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 olderId = "aaaaaaaa-1111-4111-8111-111111111111"; + const newerId = "bbbbbbbb-2222-4222-8222-222222222222"; + + writeJsonl(cursorTranscriptPath(homeDir, slug, olderId), [ + { role: "user", message: { content: [{ type: "text", text: "Older conversation" }] } }, + ]); + writeJsonl(cursorTranscriptPath(homeDir, slug, newerId), [ + { role: "user", message: { content: [{ type: "text", text: "Complete copy of the newer conversation" }] } }, + { role: "assistant", message: { content: [{ type: "text", text: "ack" }] } }, + { role: "user", message: { content: [{ type: "text", text: "second prompt" }] } }, + ]); + // Cursor mirrors transcripts under `empty-window`, and that copy can be the + // newer file while holding less of the conversation. + writeJsonl(cursorTranscriptPath(homeDir, "empty-window", newerId), [ + { role: "user", message: { content: [{ type: "text", text: "Partial copy" }] } }, + ]); + touch(cursorTranscriptPath(homeDir, slug, olderId), 1_780_000_000_000); + touch(cursorTranscriptPath(homeDir, slug, newerId), 1_781_000_000_000); + touch(cursorTranscriptPath(homeDir, "empty-window", newerId), 1_782_000_000_000); + + const sessions = await discoverCursorSessions({ homeDir, limit: 2 }); + + expect(sessions.map((session) => session.id)).toEqual([newerId, olderId]); + expect(sessions[0]).toMatchObject({ + cwd, + preview: "Complete copy of the newer conversation", + messageCount: 2, + }); + fs.rmSync(cwd, { recursive: true, force: true }); + }); + + it("picks the fullest Cursor store when one conversation spans two workspace buckets", async () => { + const homeDir = path.join(root, "home"); + const primaryCwd = path.join(root, "cursor-primary"); + const resumedCwd = path.join(root, "cursor-resumed"); + const id = "cccccccc-3333-4333-8333-333333333333"; + const primaryDir = cursorStoreDir(homeDir, primaryCwd, id); + const resumedDir = cursorStoreDir(homeDir, resumedCwd, id); + + writeCursorStore(path.join(primaryDir, "store.db"), { agentId: id, name: "Full conversation" }, 200 * 1024); + writeCursorChatMeta(primaryDir, { cwd: primaryCwd }); + writeCursorStore(path.join(resumedDir, "store.db"), { agentId: id, name: "Empty resume stub" }); + writeCursorChatMeta(resumedDir, { cwd: resumedCwd }); + // Resuming elsewhere leaves the stub as the newer copy; only size tells them apart. + for (const name of ["store.db", "meta.json"]) { + touch(path.join(primaryDir, name), 1_781_000_000_000); + touch(path.join(resumedDir, name), 1_782_000_000_000); + } + + const sessions = await discoverCursorSessions({ homeDir, limit: 10 }); + + expect(sessions).toHaveLength(1); + expect(sessions[0]).toMatchObject({ id, cwd: primaryCwd, title: "Full conversation" }); + }); + + it("skips the Cursor session id that is reused across unrelated runs", async () => { + const homeDir = path.join(root, "home"); + const cwd = path.join(root, "cursor-sentinel"); + const sentinelId = "00000000-0000-4000-8000-000000000000"; + const realId = "dddddddd-4444-4444-8444-444444444444"; + for (const id of [sentinelId, realId]) { + const sessionDir = cursorStoreDir(homeDir, cwd, id); + writeCursorStore(path.join(sessionDir, "store.db"), { agentId: id, name: `Session ${id}` }); + writeCursorChatMeta(sessionDir, { cwd }); + } + + const sessions = await discoverCursorSessions({ homeDir, limit: 10 }); + + expect(sessions.map((session) => session.id)).toEqual([realId]); + }); + + it("discovers Cursor buckets whose cwd only meta.json records", async () => { + const homeDir = path.join(root, "home"); + const repoRoot = path.join(root, "cursor-repo"); + const nestedCwd = path.join(repoRoot, "apps", "desktop"); + fs.mkdirSync(nestedCwd, { recursive: true }); + const id = "eeeeeeee-5555-4555-8555-555555555555"; + // Nothing under `.cursor/projects` names this cwd, so its md5 bucket cannot be + // reversed; `meta.json` is the only record of where the session ran. + const sessionDir = cursorStoreDir(homeDir, nestedCwd, id); + writeCursorStore(path.join(sessionDir, "store.db"), { agentId: id, name: "Nested workspace session" }); + writeCursorChatMeta(sessionDir, { cwd: nestedCwd, title: "meta.json title" }); + + const sessions = await discoverCursorSessions({ homeDir, scopeRoots: [repoRoot], limit: 10 }); + + expect(sessions).toHaveLength(1); + expect(sessions[0]).toMatchObject({ id, cwd: nestedCwd, title: "Nested workspace session" }); + }); + + it("reads in-project Cursor conversations before ones whose cwd nothing on disk confirms", async () => { + const homeDir = path.join(root, "home"); + const repoRoot = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "adecursorscope"))); + expect(repoRoot.includes("-"), "temp cwd must be dash-free for the cursor slug round-trip").toBe(false); + const slug = repoRoot.replace(/^\/+/u, "").replace(/\//gu, "-"); + const inProjectId = "ffffffff-6666-4666-8666-666666666666"; + writeJsonl(cursorTranscriptPath(homeDir, slug, inProjectId), [ + { cwd: repoRoot, role: "user", message: { content: [{ type: "text", text: "In project prompt" }] } }, + ]); + touch(cursorTranscriptPath(homeDir, slug, inProjectId), 1_780_000_000_000); + // `empty-window` de-slugs to nothing, so scope can only be settled by opening + // the transcript — which must never come at the cost of a confirmed match. + for (let index = 0; index < 3; index += 1) { + const id = `99999999-7777-4777-8777-77777777777${index}`; + const filePath = cursorTranscriptPath(homeDir, "empty-window", id); + writeJsonl(filePath, [ + { cwd: path.join(root, "cursor-elsewhere"), role: "user", message: { content: [{ type: "text", text: `Unresolvable ${index}` }] } }, + ]); + touch(filePath, 1_782_000_000_000 + index); + } + + const sessions = await discoverCursorSessions({ homeDir, scopeRoots: [repoRoot], limit: 1 }); + + expect(sessions.map((session) => session.id)).toEqual([inProjectId]); + fs.rmSync(repoRoot, { recursive: true, force: true }); + }); + + it("uses the OpenCode CLI list command and reports an uninstalled CLI", async () => { const homeDir = path.join(root, "home"); const cwd = path.join(root, "opencode-repo"); fs.mkdirSync(cwd, { recursive: true }); @@ -815,7 +1238,10 @@ describe("external session provider discovery", () => { process.env.HOME = homeDir; process.env.PATH = path.join(root, "missing-bin"); clearOpenCodeBinaryCache(); - await expect(discoverOpenCodeSessions({ homeDir, cwd, limit: 10 })).resolves.toEqual([]); + // An empty list is what "no sessions yet" looks like, so a missing CLI has + // to be distinguishable from it. + await expect(discoverOpenCodeSessions({ homeDir, cwd, limit: 10 })) + .rejects.toThrow(/OpenCode CLI not found/u); const binDir = path.join(root, "bin"); fs.mkdirSync(binDir, { recursive: true }); @@ -911,3 +1337,320 @@ describe("external session provider discovery", () => { .rejects.toThrow(/no JSON session list/i); }); }); + +// Claude chain-dedupe + classification suite (folded in from discoverClaude.test.ts +// during /test consolidation; fixtures are Claude-specific and scoped here). +describe("Claude session discovery", () => { +let claudeRoot: string; +let claudeHome: string; + +type ClaudeRow = Record; + +function projectDirFor(cwd: string): string { + return path.join(claudeHome, ".claude", "projects", claudeProjectSlugForCwd(cwd)); +} + +function writeSession(args: { + cwd: string; + id: string; + rows: ClaudeRow[]; + mtime?: string; +}): string { + const filePath = path.join(projectDirFor(args.cwd), `${args.id}.jsonl`); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const stamped = args.rows.map((row) => ({ ...row, sessionId: args.id })); + fs.writeFileSync(filePath, stamped.map((row) => JSON.stringify(row)).join("\n") + "\n", "utf8"); + if (args.mtime) fs.utimesSync(filePath, new Date(args.mtime), new Date(args.mtime)); + return filePath; +} + +function userRow(args: { + uuid: string; + cwd: string; + text: string; + entrypoint?: string; + ancestorSessionId?: string; + padBytes?: number; +}): ClaudeRow { + return { + type: "user", + uuid: args.uuid, + cwd: args.cwd, + entrypoint: args.entrypoint ?? "cli", + ...(args.ancestorSessionId ? { session_id: args.ancestorSessionId } : {}), + message: { + role: "user", + content: [{ type: "text", text: args.padBytes ? args.text + " " + "x".repeat(args.padBytes) : args.text }], + }, + }; +} + +/** `-1111-4111-8111-111111111111`-shaped ids keep fixtures readable. */ +function uuidFor(prefix: string, index: number): string { + return `${prefix.padEnd(8, "0")}-1111-4111-8111-${String(index).padStart(12, "0")}`; +} + +function turns(args: { cwd: string; prefix: string; from: number; count: number; padBytes?: number }): ClaudeRow[] { + return Array.from({ length: args.count }, (_, offset) => userRow({ + uuid: uuidFor(args.prefix, args.from + offset), + cwd: args.cwd, + text: `turn ${args.from + offset}`, + padBytes: args.padBytes, + })); +} + +beforeEach(() => { + claudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-discover-claude-")); + claudeHome = path.join(claudeRoot, "home"); +}); + +afterEach(() => { + fs.rmSync(claudeRoot, { recursive: true, force: true }); +}); + +describe("Claude continuation-chain discovery", () => { + it("collapses a continuation that was relocated to another project directory", async () => { + const sourceCwd = path.join(claudeRoot, "repo"); + const movedCwd = path.join(claudeRoot, "moved"); + const ancestorId = uuidFor("aaaa", 1); + const leafId = uuidFor("bbbb", 2); + const shared = turns({ cwd: sourceCwd, prefix: "cccc", from: 0, count: 6 }); + + writeSession({ cwd: sourceCwd, id: ancestorId, rows: shared, mtime: "2026-07-05T10:00:00.000Z" }); + writeSession({ + cwd: movedCwd, + id: leafId, + rows: [ + ...shared.map((row) => ({ ...row, cwd: movedCwd })), + ...turns({ cwd: movedCwd, prefix: "dddd", from: 6, count: 4 }), + ], + mtime: "2026-07-06T10:00:00.000Z", + }); + + const sessions = await discoverClaudeSessions({ homeDir: claudeHome, limit: 10 }); + + expect(sessions.map((session) => session.id)).toEqual([leafId]); + expect(sessions[0]?.cwd).toBe(movedCwd); + }); + + it("reports the collapsed ancestor ids on the surviving leaf", async () => { + const cwd = path.join(claudeRoot, "repo"); + const ancestorId = uuidFor("aaaa", 1); + const leafId = uuidFor("bbbb", 2); + const shared = turns({ cwd, prefix: "cccc", from: 0, count: 6 }); + + writeSession({ cwd, id: ancestorId, rows: shared, mtime: "2026-07-05T10:00:00.000Z" }); + writeSession({ + cwd, + id: leafId, + rows: [...shared, ...turns({ cwd, prefix: "dddd", from: 6, count: 4 })], + mtime: "2026-07-06T10:00:00.000Z", + }); + + const sessions = await discoverClaudeSessions({ homeDir: claudeHome, limit: 10 }); + + // A session imported under the ancestor id is this row; imported-marking + // has no other way to know that once the ancestor stops being listed. + expect(sessions.map((session) => session.id)).toEqual([leafId]); + expect(sessions[0]?.lineageIds).toContain(ancestorId); + }); + + it("lists the leaf even when the ancestor file was touched more recently", async () => { + const cwd = path.join(claudeRoot, "repo"); + const ancestorId = uuidFor("aaaa", 1); + const leafId = uuidFor("bbbb", 2); + const shared = turns({ cwd, prefix: "cccc", from: 0, count: 6 }); + + writeSession({ cwd, id: ancestorId, rows: shared, mtime: "2026-07-06T10:00:00.000Z" }); + writeSession({ + cwd, + id: leafId, + rows: [...shared, ...turns({ cwd, prefix: "dddd", from: 6, count: 4 })], + mtime: "2026-07-05T10:00:00.000Z", + }); + + const sessions = await discoverClaudeSessions({ homeDir: claudeHome, limit: 10 }); + + expect(sessions.map((session) => session.id)).toEqual([leafId]); + }); + + it("keeps both sides of a forked chain", async () => { + const cwd = path.join(claudeRoot, "repo"); + const originalId = uuidFor("aaaa", 1); + const forkId = uuidFor("bbbb", 2); + const shared = turns({ cwd, prefix: "cccc", from: 0, count: 5 }); + + writeSession({ + cwd, + id: originalId, + rows: [...shared, ...turns({ cwd, prefix: "dddd", from: 5, count: 6 })], + mtime: "2026-07-05T10:00:00.000Z", + }); + writeSession({ + cwd, + id: forkId, + rows: [...shared, ...turns({ cwd, prefix: "eeee", from: 5, count: 3 })], + mtime: "2026-07-06T10:00:00.000Z", + }); + + const sessions = await discoverClaudeSessions({ homeDir: claudeHome, limit: 10 }); + + expect(sessions.map((session) => session.id).sort()).toEqual([originalId, forkId].sort()); + }); + + it("keeps chain members that share only the final record", async () => { + const cwd = path.join(claudeRoot, "repo"); + const shorterId = uuidFor("aaaa", 1); + const longerId = uuidFor("bbbb", 2); + // Records are padded past the sampling windows so the middle of each file is + // actually compared instead of both files fitting in one window. + const head = turns({ cwd, prefix: "cccc", from: 0, count: 2, padBytes: 16 * 1024 }); + const sharedTail = userRow({ uuid: uuidFor("ffff", 99), cwd, text: "final turn", padBytes: 16 * 1024 }); + + writeSession({ + cwd, + id: shorterId, + rows: [...head, ...turns({ cwd, prefix: "dddd", from: 2, count: 30, padBytes: 16 * 1024 }), sharedTail], + mtime: "2026-07-05T10:00:00.000Z", + }); + writeSession({ + cwd, + id: longerId, + rows: [...head, ...turns({ cwd, prefix: "eeee", from: 2, count: 40, padBytes: 16 * 1024 }), sharedTail], + mtime: "2026-07-06T10:00:00.000Z", + }); + + const sessions = await discoverClaudeSessions({ homeDir: claudeHome, limit: 10 }); + + expect(sessions.map((session) => session.id).sort()).toEqual([shorterId, longerId].sort()); + }); + + it("does not treat an identical opening prompt as a shared chain", async () => { + const cwd = path.join(claudeRoot, "repo"); + const firstId = uuidFor("aaaa", 1); + const secondId = uuidFor("bbbb", 2); + const preamble = "You are running inside ADE. Follow the lane conventions."; + + writeSession({ + cwd, + id: firstId, + rows: [userRow({ uuid: uuidFor("cccc", 1), cwd, text: preamble })], + mtime: "2026-07-05T10:00:00.000Z", + }); + writeSession({ + cwd, + id: secondId, + rows: [userRow({ uuid: uuidFor("dddd", 1), cwd, text: preamble })], + mtime: "2026-07-06T10:00:00.000Z", + }); + + const sessions = await discoverClaudeSessions({ homeDir: claudeHome, limit: 10 }); + + expect(sessions.map((session) => session.id).sort()).toEqual([firstId, secondId].sort()); + }); + + it("collapses a continuation linked only by a snake_case session_id pointer", async () => { + const cwd = path.join(claudeRoot, "repo"); + const ancestorId = uuidFor("aaaa", 1); + const leafId = uuidFor("bbbb", 2); + const shared = turns({ cwd, prefix: "cccc", from: 0, count: 4 }); + + writeSession({ cwd, id: ancestorId, rows: shared, mtime: "2026-07-05T10:00:00.000Z" }); + writeSession({ + cwd, + id: leafId, + // A rewound continuation opens on a fresh record, so only the pointer links it. + rows: [ + userRow({ uuid: uuidFor("eeee", 1), cwd, text: "resumed", ancestorSessionId: ancestorId }), + ...shared, + ...turns({ cwd, prefix: "dddd", from: 4, count: 3 }), + ], + mtime: "2026-07-06T10:00:00.000Z", + }); + + const sessions = await discoverClaudeSessions({ homeDir: claudeHome, limit: 10 }); + + expect(sessions.map((session) => session.id)).toEqual([leafId]); + }); + + it("still resolves an ancestor by exact session id lookup", async () => { + const cwd = path.join(claudeRoot, "repo"); + const ancestorId = uuidFor("aaaa", 1); + const leafId = uuidFor("bbbb", 2); + const shared = turns({ cwd, prefix: "cccc", from: 0, count: 4 }); + + writeSession({ cwd, id: ancestorId, rows: shared, mtime: "2026-07-05T10:00:00.000Z" }); + writeSession({ + cwd, + id: leafId, + rows: [...shared, ...turns({ cwd, prefix: "dddd", from: 4, count: 3 })], + mtime: "2026-07-06T10:00:00.000Z", + }); + + const sessions = await discoverClaudeSessions({ homeDir: claudeHome, sessionId: ancestorId, limit: 10 }); + + expect(sessions.map((session) => session.id)).toEqual([ancestorId]); + }); +}); + +describe("Claude transcript classification", () => { + it("keeps a CLI session that picked up incidental SDK rows", async () => { + const cwd = path.join(claudeRoot, "repo"); + const id = uuidFor("aaaa", 1); + + writeSession({ + cwd, + id, + rows: [ + userRow({ uuid: uuidFor("cccc", 1), cwd, text: "started in the terminal" }), + ...turns({ cwd, prefix: "dddd", from: 1, count: 4 }), + userRow({ uuid: uuidFor("eeee", 1), cwd, text: "sdk driven turn", entrypoint: "sdk-ts" }), + ], + }); + + const sessions = await discoverClaudeSessions({ homeDir: claudeHome, limit: 10 }); + + expect(sessions.map((session) => session.id)).toEqual([id]); + }); + + it("excludes a wholly SDK-origin transcript", async () => { + const cwd = path.join(claudeRoot, "repo"); + const id = uuidFor("aaaa", 1); + + writeSession({ + cwd, + id, + rows: [ + { type: "queue-operation" }, + userRow({ uuid: uuidFor("cccc", 1), cwd, text: "ade chat turn", entrypoint: "sdk-ts" }), + userRow({ uuid: uuidFor("dddd", 1), cwd, text: "ade chat reply", entrypoint: "sdk-ts" }), + ], + }); + + const sessions = await discoverClaudeSessions({ homeDir: claudeHome, limit: 10 }); + + expect(sessions).toEqual([]); + }); + + it("ignores subagent transcripts nested under a session directory", async () => { + const cwd = path.join(claudeRoot, "repo"); + const id = uuidFor("aaaa", 1); + writeSession({ cwd, id, rows: turns({ cwd, prefix: "cccc", from: 0, count: 3 }) }); + + const subagentDir = path.join(projectDirFor(cwd), id, "subagents"); + fs.mkdirSync(path.join(subagentDir, "workflows", "wf_6ba7d421-a8b"), { recursive: true }); + const subagentRows = [userRow({ uuid: uuidFor("dddd", 1), cwd, text: "subagent turn" })]; + for (const nested of [ + path.join(subagentDir, `agent-${uuidFor("eeee", 1)}.jsonl`), + path.join(subagentDir, `${uuidFor("ffff", 1)}.jsonl`), + path.join(subagentDir, "workflows", "wf_6ba7d421-a8b", `${uuidFor("ffff", 2)}.jsonl`), + ]) { + fs.writeFileSync(nested, subagentRows.map((row) => JSON.stringify(row)).join("\n") + "\n", "utf8"); + } + + const sessions = await discoverClaudeSessions({ homeDir: claudeHome, limit: 10 }); + + expect(sessions.map((session) => session.id)).toEqual([id]); + }); +}); +}); diff --git a/apps/desktop/src/main/services/externalSessions/discoveryUtils.ts b/apps/desktop/src/main/services/externalSessions/discoveryUtils.ts index 9a31d1242..db926c379 100644 --- a/apps/desktop/src/main/services/externalSessions/discoveryUtils.ts +++ b/apps/desktop/src/main/services/externalSessions/discoveryUtils.ts @@ -1,6 +1,8 @@ import fs from "node:fs"; +import { createRequire } from "node:module"; import os from "node:os"; import path from "node:path"; +import type { DatabaseSync as DatabaseSyncType } from "node:sqlite"; import type { ExternalSessionMessage, ExternalSessionProvider, @@ -14,6 +16,13 @@ export type ExternalSessionDiscoveryRecord = Omit< > & { sourcePath?: string | null; sourceMtimeMs?: number | null; + /** + * Provider ids this row supersedes, because discovery collapsed them into it + * (a Claude continuation chain, a Codex parent hidden behind its fork). A + * session imported under one of those ids is this row, so imported-marking + * matches them alongside `id`. Optional: providers without collapse omit it. + */ + lineageIds?: string[]; }; export type ExternalSessionDiscoveryArgs = { @@ -37,18 +46,24 @@ export type ExternalSessionFileCandidate = Rec size: number; }; -export const DEFAULT_EXTERNAL_SESSION_LIMIT = 50; +const DEFAULT_EXTERNAL_SESSION_LIMIT = 50; export const MAX_EXTERNAL_SESSION_LIMIT = 5000; -export const JSONL_SCAN_LINE_LIMIT = 80; +const JSONL_SCAN_LINE_LIMIT = 80; export const JSONL_SCAN_BYTE_LIMIT = 512 * 1024; -export const MESSAGE_COUNT_MAX_BYTES = 768 * 1024; +const MESSAGE_COUNT_MAX_BYTES = 768 * 1024; export const EXTERNAL_SESSION_PREVIEW_MAX_LENGTH = 240; export const EXTERNAL_SESSION_TITLE_MAX_LENGTH = 160; export const EXTERNAL_SESSION_MESSAGES_MAX_COUNT = 8; export const EXTERNAL_SESSION_MESSAGE_MAX_LENGTH = 320; // Markup-dominant transport receipts are not useful session previews, while // ordinary prose containing a short JSX/XML fragment remains recoverable. -export const EXTERNAL_SESSION_MARKUP_TEXT_MIN_RATIO = 0.35; +const EXTERNAL_SESSION_MARKUP_TEXT_MIN_RATIO = 0.35; +/** + * Reading a session means opening its file, and a file that turns out to be + * out of project, or not a session at all, yields nothing — so providers open + * more candidates than the caller asked for before the final sort trims back. + */ +export const EXTERNAL_SESSION_READ_BUDGET_MULTIPLIER = 2; const EXTERNAL_SESSION_CLIP_BOUNDARY_WINDOW_RATIO = 0.25; const PLACEHOLDER_SESSION_TITLES = new Set([ @@ -89,6 +104,45 @@ export function safeReadDir(dirPath: string): fs.Dirent[] { } } +type DatabaseSyncConstructor = new ( + dbPath: string, + options?: { readOnly?: boolean }, +) => DatabaseSyncType; + +const require = createRequire(path.join(process.cwd(), "ade-runtime.cjs")); +const { DatabaseSync } = require("node:sqlite") as { DatabaseSync: DatabaseSyncConstructor }; + +/** + * A handle on another tool's sqlite store — Codex's thread DB, a Cursor chat + * store. Always read-only: the owning process may be writing right now, and + * closing a read-write handle would checkpoint and rewrite its WAL underneath + * it. A missing file, a WAL awaiting recovery, or a schema this build does not + * understand all yield null so the caller can fall back to reading files. + */ +export function openExternalSessionDb( + dbPath: string, + logger?: ExternalSessionDiscoveryArgs["logger"], +): DatabaseSyncType | null { + if (!safeStat(dbPath)?.isFile()) return null; + try { + return new DatabaseSync(dbPath, { readOnly: true }); + } catch (error) { + logger?.warn?.("external_sessions.sqlite_db_unavailable", { + dbPath, + error: error instanceof Error ? error.message : String(error), + }); + return null; + } +} + +export function closeExternalSessionDb(db: DatabaseSyncType | null): void { + try { + db?.close(); + } catch { + // best effort close + } +} + export function sessionFileCandidate>( filePath: string, extra: T, @@ -116,6 +170,19 @@ export function sortFileCandidatesByMtime( + current: T, + next: T, +): T { + if (next.size !== current.size) return next.size > current.size ? next : current; + return next.mtimeMs > current.mtimeMs ? next : current; +} + export function safeParseJson(line: string): unknown | null { try { return JSON.parse(line); @@ -124,12 +191,13 @@ export function safeParseJson(line: string): unknown | null { } } -export function readFilePrefix(filePath: string, maxBytes = JSONL_SCAN_BYTE_LIMIT): string | null { +export function readFileWindow(filePath: string, offset: number, maxBytes: number): string | null { let fd: number | null = null; try { + if (maxBytes <= 0) return null; fd = fs.openSync(filePath, "r"); const buf = Buffer.alloc(maxBytes); - const bytesRead = fs.readSync(fd, buf, 0, maxBytes, 0); + const bytesRead = fs.readSync(fd, buf, 0, maxBytes, Math.max(0, offset)); if (bytesRead <= 0) return null; return buf.subarray(0, bytesRead).toString("utf8"); } catch { @@ -145,28 +213,16 @@ export function readFilePrefix(filePath: string, maxBytes = JSONL_SCAN_BYTE_LIMI } } +export function readFilePrefix(filePath: string, maxBytes = JSONL_SCAN_BYTE_LIMIT): string | null { + return readFileWindow(filePath, 0, maxBytes); +} + 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 - } - } - } + const size = safeStat(filePath)?.size; + if (typeof size !== "number") return null; + const bytesToRead = Math.min(maxBytes, Math.max(0, size)); + if (bytesToRead <= 0) return null; + return readFileWindow(filePath, size - bytesToRead, bytesToRead); } export function readJsonlRecords(filePath: string, maxLines = JSONL_SCAN_LINE_LIMIT): unknown[] { @@ -178,6 +234,24 @@ export function readJsonlRecords(filePath: string, maxLines = JSONL_SCAN_LINE_LI .filter((record): record is Record => record != null); } +/** + * Records from the last `maxBytes` of a JSONL file. A suffix read almost always + * begins part-way through a record, so the leading fragment is dropped unless + * the window happened to land on a record boundary. + */ +export function readJsonlRecordsFromSuffix( + filePath: string, + maxBytes = JSONL_SCAN_BYTE_LIMIT, +): unknown[] { + const text = readFileSuffix(filePath, maxBytes); + if (!text) return []; + const lines = text.split(/\r?\n/u); + if (!text.startsWith("{")) lines.shift(); + return lines + .map((line) => safeParseJson(line)) + .filter((record): record is Record => record != null); +} + function jsonlRecordsForSemanticCount(filePath: string): unknown[] | null { const stat = safeStat(filePath); if (!stat || stat.size > MESSAGE_COUNT_MAX_BYTES) return null; @@ -367,7 +441,7 @@ export function cleanExternalSessionUserText(raw: string): string | null { return !isProviderGeneratedNotice(normalizedForNotice) ? cleaned : null; } -export function stripAdeGuidance(raw: string): string { +function stripAdeGuidance(raw: string): string { return cleanExternalSessionUserText(raw) ?? ""; } @@ -566,7 +640,8 @@ export function canonicalCodexRecords(records: unknown[]): unknown[] { )); } -function isCanonicalCodexUserRecord(record: unknown): boolean { +/** A Codex turn the user typed, in the `event_msg` form Codex writes first. */ +export function isCanonicalCodexUserRecord(record: unknown): boolean { const obj = asRecord(record); const payload = asRecord(obj?.payload); if (!obj || !payload || asString(obj.type)?.toLowerCase() !== "event_msg") return false; @@ -792,7 +867,7 @@ export function realishPath(filePath: string): string { } } -export function normalizeScopeRoots(scopeRoots: readonly string[] | null | undefined): string[] { +function normalizeScopeRoots(scopeRoots: readonly string[] | null | undefined): string[] { const roots = new Set(); for (const root of scopeRoots ?? []) { const clean = root.trim(); @@ -812,22 +887,55 @@ function scopeRootPathVariants(scopeRoots: readonly string[] | null | undefined) return Array.from(roots); } +/** + * `cwdIsInScope` runs once per discovered row — tens of thousands of rows for a + * large Codex history — and each call otherwise realpaths every scope root plus + * the row's own cwd. Both sides are memoised against the caller's `scopeRoots` + * array, which discovery builds once per listing, so the memo (and every + * resolved path in it) becomes collectable the moment that listing is done and + * never outlives the filesystem snapshot it was taken from. + */ +type ScopeIndex = { roots: string[]; resolvedCwds: Map }; + +/** Guards against a pathological listing whose rows are all distinct cwds. */ +const SCOPE_CWD_MEMO_LIMIT = 10_000; + +const scopeIndexByRootsArray = new WeakMap(); + +function scopeIndexFor(scopeRoots: readonly string[] | null | undefined): ScopeIndex { + if (!scopeRoots) return { roots: [], resolvedCwds: new Map() }; + const cached = scopeIndexByRootsArray.get(scopeRoots); + if (cached) return cached; + const index: ScopeIndex = { roots: normalizeScopeRoots(scopeRoots), resolvedCwds: new Map() }; + scopeIndexByRootsArray.set(scopeRoots, index); + return index; +} + export function cwdIsInScope(cwd: string | null | undefined, scopeRoots: readonly string[] | null | undefined): boolean { - const roots = normalizeScopeRoots(scopeRoots); - if (!roots.length) return true; + // An unscoped listing calls this once per discovered row, and there is nothing + // to memoise against without a roots array — answer before building an index. + if (!scopeRoots) return true; + const index = scopeIndexFor(scopeRoots); + if (!index.roots.length) return true; const clean = cwd?.trim(); if (!clean) return false; - const resolved = realishPath(clean); - return roots.some((root) => isPathInside(root, resolved)); + let resolved = index.resolvedCwds.get(clean); + if (resolved === undefined) { + resolved = realishPath(clean); + if (index.resolvedCwds.size >= SCOPE_CWD_MEMO_LIMIT) index.resolvedCwds.clear(); + index.resolvedCwds.set(clean, resolved); + } + return index.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)); + // Passing `scopeRoots` straight through rather than its normalised copy keeps + // every candidate on the same memo entry. + if (!scopeIndexFor(scopeRoots).roots.length) return true; + return candidates.some((candidate) => cwdIsInScope(candidate, scopeRoots)); } export function slugMatchesScopeRoots( diff --git a/apps/desktop/src/main/services/externalSessions/externalSessionsService.test.ts b/apps/desktop/src/main/services/externalSessions/externalSessionsService.test.ts index ed6e16391..bdd794ec1 100644 --- a/apps/desktop/src/main/services/externalSessions/externalSessionsService.test.ts +++ b/apps/desktop/src/main/services/externalSessions/externalSessionsService.test.ts @@ -4,9 +4,15 @@ 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 { AgentChatSessionSummary, PtyCreateArgs, TerminalSessionSummary } from "../../../shared/types"; +import type { + AgentChatSessionSummary, + ExternalSessionListArgs, + PtyCreateArgs, + TerminalSessionSummary, +} from "../../../shared/types"; import { clearOpenCodeBinaryCache } from "../opencode/openCodeBinaryManager"; import { createExternalSessionsService } from "./externalSessionsService"; +import { createImportedSessionStore, importedSessionsPath } from "./importedSessionStore"; import { transplantClaudeSession } from "./claudeSessionTransplant"; import { claudeProjectSlugForCwd } from "./discoveryUtils"; @@ -31,6 +37,11 @@ function writeJsonl(filePath: string, rows: unknown[]): void { fs.writeFileSync(filePath, rows.map((row) => JSON.stringify(row)).join("\n") + "\n", "utf8"); } +/** Droid names a session directory after its slash-escaped cwd. */ +function droidSessionDir(cwd: string): string { + return cwd.replace(/\//gu, "-"); +} + function makeLogger() { return { warn: vi.fn(), info: vi.fn() }; } @@ -51,14 +62,22 @@ function makeImportedChatSummary(sessionId: string): AgentChatSessionSummary { }; } +let previousAdeHome: string | undefined; + beforeEach(() => { execFileMock.mockReset(); computerUseMocks.resolveCodexComputerUseMcpConfig.mockReset(); computerUseMocks.resolveCodexComputerUseMcpConfig.mockResolvedValue(null); root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-external-service-")); + // The durable import log lives under the ADE home; pin it so a developer's + // real one can never be written to by a test that spreads `process.env`. + previousAdeHome = process.env.ADE_HOME; + process.env.ADE_HOME = path.join(root, "ade-home"); }); afterEach(() => { + if (previousAdeHome === undefined) delete process.env.ADE_HOME; + else process.env.ADE_HOME = previousAdeHome; fs.rmSync(root, { recursive: true, force: true }); }); @@ -409,7 +428,7 @@ describe("externalSessionsService", () => { 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`), [ + writeJsonl(path.join(homeDir, ".factory", "sessions", droidSessionDir(laneCwd), `${id}.jsonl`), [ { type: "session_start", id, @@ -508,7 +527,7 @@ describe("externalSessionsService", () => { 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`), [ + writeJsonl(path.join(homeDir, ".factory", "sessions", droidSessionDir(laneCwd), `${id}.jsonl`), [ { type: "session_start", id, @@ -550,7 +569,7 @@ describe("externalSessionsService", () => { 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`), [ + writeJsonl(path.join(homeDir, ".factory", "sessions", droidSessionDir(laneCwd), `${id}.jsonl`), [ { type: "session_start", id, @@ -1170,3 +1189,457 @@ describe("transplantClaudeSession", () => { expect(fs.readFileSync(targetPath, "utf8")).toBe(targetBefore); }); }); + +describe("externalSessionsService imported-session marking", () => { + function writeClaudeSession(args: { homeDir: string; cwd: string; id: string; text: string }): string { + const filePath = path.join( + args.homeDir, + ".claude", + "projects", + claudeProjectSlugForCwd(args.cwd), + `${args.id}.jsonl`, + ); + writeJsonl(filePath, [ + { + type: "message", + sessionId: args.id, + cwd: args.cwd, + timestamp: "2026-07-06T10:00:00.000Z", + message: { role: "user", content: args.text }, + }, + ]); + return filePath; + } + + function laneSetup(): { homeDir: string; projectRoot: string; laneCwd: string } { + 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 }); + return { homeDir, projectRoot, laneCwd }; + } + + it("keeps the imported badge after the ADE session it created is gone", async () => { + const { homeDir, projectRoot, laneCwd } = laneSetup(); + const id = "d1d1d1d1-d1d1-4d1d-8d1d-d1d1d1d1d1d1"; + writeClaudeSession({ homeDir, cwd: laneCwd, id, text: "import then delete" }); + let liveSessions: TerminalSessionSummary[] = []; + const service = createExternalSessionsService({ + droidForkSupported: true, + projectRoot, + homeDir, + laneService: { getLaneWorktreePath: () => laneCwd }, + sessionService: { list: () => liveSessions, listClaudeSessionPointers: () => [] }, + ptyService: { + create: vi.fn(async (_args: PtyCreateArgs) => ({ sessionId: "terminal-durable", ptyId: "pty-durable", pid: 11 })), + }, + logger: makeLogger(), + }); + + await service.importExternalSession({ + provider: "claude", + sessionId: id, + laneId: "lane-1", + target: "cli", + mode: "resume", + }); + + liveSessions = [{ id: "terminal-durable", toolType: "claude" } as TerminalSessionSummary]; + const listArgs: ExternalSessionListArgs = { + providers: ["claude"], + laneId: "lane-1", + scope: "project", + limit: 5, + }; + await expect(service.list({ ...listArgs })).resolves.toMatchObject([ + { alreadyImported: true, importedSessionRef: { kind: "cli", sessionId: "terminal-durable" } }, + ]); + + // The terminal row is what used to carry the badge; deleting it must not + // make an already-imported provider session look importable again. + liveSessions = []; + await expect(service.list({ ...listArgs })).resolves.toMatchObject([ + { alreadyImported: true, importedSessionRef: null }, + ]); + }); + + it("marks both ids of a Codex chat fork and stops listing the fork as a new session", async () => { + const { homeDir, projectRoot, laneCwd } = laneSetup(); + const sourceId = "c0dec0de-0000-4000-8000-000000000001"; + const forkId = "c0dec0de-0000-4000-8000-000000000002"; + for (const threadId of [sourceId, forkId]) { + writeJsonl( + path.join(homeDir, ".codex", "sessions", "2026", "07", "06", `rollout-2026-07-06T10-00-00-${threadId}.jsonl`), + [{ + timestamp: "2026-07-06T10:00:00.000Z", + type: "session_meta", + payload: { id: threadId, cwd: laneCwd, timestamp: "2026-07-06T10:00:00.000Z" }, + }], + ); + } + const service = createExternalSessionsService({ + droidForkSupported: true, + projectRoot, + homeDir, + laneService: { getLaneWorktreePath: () => laneCwd }, + sessionService: { + list: () => [{ id: "chat-codex", toolType: "codex-chat" } as TerminalSessionSummary], + listClaudeSessionPointers: () => [], + }, + ptyService: { create: vi.fn() }, + logger: makeLogger(), + chatImporter: { + importExternalChatSession: async () => ({ + chatSessionId: "chat-codex", + chatSummary: makeImportedChatSummary("chat-codex"), + providerTargetId: forkId, + }), + }, + }); + + await service.importExternalSession({ + provider: "codex", + sessionId: sourceId, + laneId: "lane-1", + target: "chat", + mode: "fork", + }); + + const rows = await service.list({ providers: ["codex"], laneId: "lane-1", scope: "project", limit: 10 }); + + expect(rows.map((row) => row.id)).toEqual([sourceId]); + expect(rows[0]).toMatchObject({ + alreadyImported: true, + importedSessionRef: { kind: "chat", sessionId: "chat-codex" }, + }); + }); + + it("hides the Claude transcript ADE transplanted for a cross-folder fork", async () => { + const homeDir = path.join(root, "home"); + 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 = "d2d2d2d2-d2d2-4d2d-8d2d-d2d2d2d2d2d2"; + writeClaudeSession({ homeDir, cwd: sourceCwd, id, text: "copy me into the lane" }); + const service = createExternalSessionsService({ + droidForkSupported: true, + projectRoot, + homeDir, + laneService: { getLaneWorktreePath: () => laneCwd }, + sessionService: { + list: () => [{ id: "terminal-transplant", toolType: "claude" } as TerminalSessionSummary], + listClaudeSessionPointers: () => [], + }, + ptyService: { + create: vi.fn(async (_args: PtyCreateArgs) => ({ + sessionId: "terminal-transplant", + ptyId: "pty-transplant", + pid: 12, + })), + }, + logger: makeLogger(), + }); + + await service.importExternalSession({ + provider: "claude", + sessionId: id, + laneId: "lane-1", + target: "cli", + mode: "fork", + }); + + const laneProjectDir = path.join( + homeDir, + ".claude", + "projects", + claudeProjectSlugForCwd(fs.realpathSync(laneCwd)), + ); + expect(fs.readdirSync(laneProjectDir).filter((name) => name.endsWith(".jsonl"))).toHaveLength(1); + + // The transplanted copy is a real Claude transcript on disk; it must not + // come back as a session the user can import into ADE a second time. + const rows = await service.list({ providers: ["claude"], scope: "all", limit: 10 }); + + expect(rows.map((row) => row.id)).toEqual([id]); + expect(rows[0]).toMatchObject({ + alreadyImported: true, + importedSessionRef: { kind: "cli", sessionId: "terminal-transplant" }, + }); + }); + + it("marks a continuation-chain leaf that was imported under an ancestor id", async () => { + const { homeDir, projectRoot, laneCwd } = laneSetup(); + const ancestorId = "e1e1e1e1-e1e1-4e1e-8e1e-e1e1e1e1e1e1"; + const leafId = "e2e2e2e2-e2e2-4e2e-8e2e-e2e2e2e2e2e2"; + const turn = (index: number) => ({ + type: "user", + uuid: `f0f0f0f0-f0f0-4f0f-8f0f-${String(index).padStart(12, "0")}`, + cwd: laneCwd, + entrypoint: "cli", + message: { role: "user", content: [{ type: "text", text: `turn ${index}` }] }, + }); + const shared = Array.from({ length: 6 }, (_, index) => turn(index)); + const projectDir = path.join(homeDir, ".claude", "projects", claudeProjectSlugForCwd(laneCwd)); + writeJsonl(path.join(projectDir, `${ancestorId}.jsonl`), shared); + writeJsonl( + path.join(projectDir, `${leafId}.jsonl`), + [...shared, ...Array.from({ length: 4 }, (_, index) => turn(6 + index))], + ); + + const service = createExternalSessionsService({ + droidForkSupported: true, + projectRoot, + homeDir, + laneService: { getLaneWorktreePath: () => laneCwd }, + sessionService: { + list: () => [{ id: "chat-ancestor", toolType: "claude-chat" } as TerminalSessionSummary], + listClaudeSessionPointers: () => [], + }, + ptyService: { create: vi.fn() }, + logger: makeLogger(), + importedSessionStore: { + list: () => [{ + provider: "claude", + externalId: ancestorId, + targetId: null, + kind: "chat" as const, + adeSessionId: "chat-ancestor", + mode: "continue" as const, + importedAt: "2026-07-06T10:00:00.000Z", + }], + record: vi.fn(), + }, + }); + + const rows = await service.list({ providers: ["claude"], laneId: "lane-1", scope: "project", limit: 10 }); + + // Discovery lists the leaf, but the import happened before the chain grew. + expect(rows.map((row) => row.id)).toEqual([leafId]); + expect(rows[0]).toMatchObject({ + alreadyImported: true, + importedSessionRef: { kind: "chat", sessionId: "chat-ancestor" }, + }); + }); + + it("reads Claude activity from the live-session registry instead of file mtime", async () => { + const { homeDir, projectRoot, laneCwd } = laneSetup(); + const openId = "a1a1a1a1-a1a1-4a1a-8a1a-a1a1a1a1a1a1"; + const closedId = "a2a2a2a2-a2a2-4a2a-8a2a-a2a2a2a2a2a2"; + writeClaudeSession({ homeDir, cwd: laneCwd, id: openId, text: "still open" }); + // Written just now, but no live process owns it. + writeClaudeSession({ homeDir, cwd: laneCwd, id: closedId, text: "just closed" }); + const registryPath = path.join(homeDir, ".claude", "sessions", `${process.pid}.json`); + fs.mkdirSync(path.dirname(registryPath), { recursive: true }); + fs.writeFileSync( + registryPath, + JSON.stringify({ pid: process.pid, sessionId: openId, cwd: laneCwd, kind: "interactive" }), + "utf8", + ); + const service = createExternalSessionsService({ + droidForkSupported: true, + projectRoot, + homeDir, + laneService: { getLaneWorktreePath: () => laneCwd }, + sessionService: { list: () => [], listClaudeSessionPointers: () => [] }, + ptyService: { create: vi.fn() }, + logger: makeLogger(), + }); + + const rows = await service.list({ providers: ["claude"], laneId: "lane-1", scope: "project", limit: 10 }); + const active = Object.fromEntries(rows.map((row) => [row.id, row.possiblyActive])); + + expect(active).toEqual({ [openId]: true, [closedId]: false }); + }); + + it("reads the newest registry entries when dead ones outnumber the cap", async () => { + const { homeDir, projectRoot, laneCwd } = laneSetup(); + const openId = "a3a3a3a3-a3a3-4a3a-8a3a-a3a3a3a3a3a3"; + writeClaudeSession({ homeDir, cwd: laneCwd, id: openId, text: "still open" }); + const registryDir = path.join(homeDir, ".claude", "sessions"); + fs.mkdirSync(registryDir, { recursive: true }); + // Claude never prunes this directory, so a long-running install accumulates + // far more dead entries than any one pass will open. + const staleSeconds = (Date.now() - 60 * 60_000) / 1000; + for (let index = 0; index < 2_000; index += 1) { + const deadPath = path.join(registryDir, `${4_000_000 + index}.json`); + fs.writeFileSync( + deadPath, + JSON.stringify({ pid: 4_000_000 + index, sessionId: `dead-${index}`, cwd: laneCwd }), + "utf8", + ); + fs.utimesSync(deadPath, staleSeconds, staleSeconds); + } + fs.writeFileSync( + path.join(registryDir, `${process.pid}.json`), + JSON.stringify({ pid: process.pid, sessionId: openId, cwd: laneCwd, kind: "interactive" }), + "utf8", + ); + const service = createExternalSessionsService({ + droidForkSupported: true, + projectRoot, + homeDir, + laneService: { getLaneWorktreePath: () => laneCwd }, + sessionService: { list: () => [], listClaudeSessionPointers: () => [] }, + ptyService: { create: vi.fn() }, + logger: makeLogger(), + }); + + const rows = await service.list({ providers: ["claude"], laneId: "lane-1", scope: "project", limit: 10 }); + + expect(rows.find((row) => row.id === openId)?.possiblyActive).toBe(true); + }); + + it("returns as many rows as the caller asks for, not the default page", async () => { + const { homeDir, projectRoot, laneCwd } = laneSetup(); + for (let index = 0; index < 60; index += 1) { + writeClaudeSession({ + homeDir, + cwd: laneCwd, + id: `30000000-0000-4000-8000-${String(index).padStart(12, "0")}`, + text: `session ${index}`, + }); + } + const service = createExternalSessionsService({ + droidForkSupported: true, + projectRoot, + homeDir, + laneService: { getLaneWorktreePath: () => laneCwd }, + sessionService: { list: () => [], listClaudeSessionPointers: () => [] }, + ptyService: { create: vi.fn() }, + logger: makeLogger(), + }); + + await expect(service.list({ providers: ["claude"], laneId: "lane-1", scope: "project", limit: 60 })) + .resolves.toHaveLength(60); + await expect(service.list({ providers: ["claude"], laneId: "lane-1", scope: "project" })) + .resolves.toHaveLength(50); + }); + + it("reports an uninstalled OpenCode CLI to a scan that asked only for it", async () => { + const { homeDir, projectRoot, laneCwd } = laneSetup(); + const previousPath = process.env.PATH; + const previousHome = process.env.HOME; + const previousDisableBundled = process.env.ADE_DISABLE_BUNDLED_OPENCODE; + // Binary resolution also searches HOME-derived CLI directories, so PATH + // alone does not describe a machine without OpenCode. + process.env.PATH = path.join(root, "missing-bin"); + process.env.HOME = homeDir; + process.env.ADE_DISABLE_BUNDLED_OPENCODE = "1"; + clearOpenCodeBinaryCache(); + const logger = makeLogger(); + const service = createExternalSessionsService({ + droidForkSupported: true, + projectRoot, + homeDir, + laneService: { getLaneWorktreePath: () => laneCwd }, + sessionService: { list: () => [], listClaudeSessionPointers: () => [] }, + ptyService: { create: vi.fn() }, + logger, + }); + + try { + await expect(service.list({ providers: ["opencode"], scope: "all" })) + .rejects.toThrow(/OpenCode CLI not found/u); + // A mixed scan still returns the providers that did work. + await expect(service.list({ providers: ["claude", "opencode"], scope: "all" })).resolves.toEqual([]); + expect(logger.warn).toHaveBeenCalledWith( + "external_sessions.discovery_failed", + expect.objectContaining({ provider: "opencode" }), + ); + } finally { + if (previousPath === undefined) delete process.env.PATH; + else process.env.PATH = previousPath; + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + if (previousDisableBundled === undefined) delete process.env.ADE_DISABLE_BUNDLED_OPENCODE; + else process.env.ADE_DISABLE_BUNDLED_OPENCODE = previousDisableBundled; + clearOpenCodeBinaryCache(); + } + }); +}); + +// importedSessionStore suite (folded in from importedSessionStore.test.ts during +// /test consolidation; the store's only consumer is this service). +describe("imported session store (module)", () => { +let storeRoot: string; + + +function storeRecord(index: number) { + return { + provider: "codex", + externalId: `external-${index}`, + targetId: null, + kind: "cli" as const, + adeSessionId: `ade-${index}`, + mode: "continue" as const, + }; +} + +beforeEach(() => { + storeRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-imported-store-")); +}); + +afterEach(() => { + fs.rmSync(storeRoot, { recursive: true, force: true }); +}); + +describe("imported session store", () => { + it("keeps a record another host appended since this store last read", () => { + const desktop = createImportedSessionStore({ homeDir: storeRoot }); + const headless = createImportedSessionStore({ homeDir: storeRoot }); + + desktop.record(storeRecord(1)); + // Populates the desktop store's cache, which the second write must not trust. + expect(desktop.list()).toHaveLength(1); + headless.record(storeRecord(2)); + desktop.record(storeRecord(3)); + + const externalIds = createImportedSessionStore({ homeDir: storeRoot }) + .list() + .map((entry) => entry.externalId) + .sort(); + expect(externalIds).toEqual(["external-1", "external-2", "external-3"]); + }); + + it("takes over a lock left behind by a process that died holding it", () => { + const filePath = importedSessionsPath({ homeDir: storeRoot }); + const lockPath = `${filePath}.lock`; + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(lockPath, "", "utf8"); + const longAgo = (Date.now() - 60_000) / 1000; + fs.utimesSync(lockPath, longAgo, longAgo); + const warn = vi.fn(); + const store = createImportedSessionStore({ homeDir: storeRoot, logger: { warn } }); + + const startedAt = Date.now(); + store.record(storeRecord(1)); + + expect(store.list().map((entry) => entry.externalId)).toEqual(["external-1"]); + expect(warn).not.toHaveBeenCalled(); + // Waiting out a lock nobody holds would stall every import behind it. + expect(Date.now() - startedAt).toBeLessThan(500); + expect(fs.existsSync(lockPath)).toBe(false); + }); + + it("writes anyway when a live lock never clears", () => { + const filePath = importedSessionsPath({ homeDir: storeRoot }); + const lockPath = `${filePath}.lock`; + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(lockPath, "", "utf8"); + const warn = vi.fn(); + const store = createImportedSessionStore({ homeDir: storeRoot, logger: { warn } }); + + // An import that already created the ADE session must never fail because + // its receipt could not take the lock. + store.record(storeRecord(1)); + + expect(store.list().map((entry) => entry.externalId)).toEqual(["external-1"]); + expect(warn).toHaveBeenCalledWith( + "external_sessions.imported_store_lock_timeout", + expect.objectContaining({ path: lockPath }), + ); + }); +}); +}); diff --git a/apps/desktop/src/main/services/externalSessions/externalSessionsService.ts b/apps/desktop/src/main/services/externalSessions/externalSessionsService.ts index 2d2ced84b..215fcbdd8 100644 --- a/apps/desktop/src/main/services/externalSessions/externalSessionsService.ts +++ b/apps/desktop/src/main/services/externalSessions/externalSessionsService.ts @@ -20,17 +20,22 @@ import type { TerminalToolType, } from "../../../shared/types"; import { transplantClaudeSession } from "./claudeSessionTransplant"; +import { liveClaudeSessionIds } from "./claudeLiveSessions"; import { claudeConfigDir, discoverClaudeSessions } from "./discoverClaude"; import { discoverCodexSessions } from "./discoverCodex"; import { discoverCursorSessions } from "./discoverCursor"; import { discoverDroidSessions } from "./discoverDroid"; import { discoverOpenCodeSessions } from "./discoverOpenCode"; import { resolveCodexComputerUseMcpConfig } from "../../utils/codexComputerUse"; +import { CLAUDE_SESSION_POINTER_MAX_LIMIT } from "../sessions/sessionService"; +import { createImportedSessionStore, type ImportedSessionStore } from "./importedSessionStore"; import { commandArrayToLine, + cwdIsInScope, directShellLaunchForCommandLine, isPathInside, normalizeExternalSessionLimit, + realishPath, sortDiscoveryRecords, type ExternalSessionDiscoveryArgs, type ExternalSessionDiscoveryRecord, @@ -92,6 +97,8 @@ type ExternalSessionsServiceArgs = { env?: NodeJS.ProcessEnv; /** Overrides the installed-droid `--fork` probe (tests / callers that already know). */ droidForkSupported?: boolean; + /** Overrides the machine-local durable import log (tests). */ + importedSessionStore?: ImportedSessionStore; }; type LaneScopedExternalSessionImportArgs = ExternalSessionImportArgs & { @@ -161,14 +168,6 @@ function providerSet(raw: ExternalSessionProvider[] | undefined): ExternalSessio 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 safeDirectoryExists(filePath: string, cache?: Map): boolean { const cacheKey = path.resolve(filePath); const cached = cache?.get(cacheKey); @@ -186,7 +185,7 @@ function safeDirectoryExists(filePath: string, cache?: Map): bo function deriveProjectScopeRoots(projectRoot: string): string[] { const roots = new Set(); const raw = path.resolve(projectRoot); - const resolved = realish(projectRoot); + const resolved = realishPath(projectRoot); roots.add(raw); roots.add(resolved); const marker = `${path.sep}.ade${path.sep}worktrees${path.sep}`; @@ -205,19 +204,13 @@ function resolveLaneCwd(laneService: LaneServiceLike, laneId: string): string { 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); + return realishPath(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)); + return realishPath(cwd) === realishPath(requestedCwd); } function refRank(ref: ImportedSessionRef | null): number { @@ -238,13 +231,23 @@ function putImportedRef( } } +type ImportedRefIndex = { + refs: Map; + /** `provider:id` keys for provider sessions ADE itself created by copying. */ + adeCreated: Set; +}; + async function importedSessionRefs( sessionService: SessionServiceLike, chatImportedRefsProvider: ChatImportedRefsProvider | null | undefined, + importedStore: ImportedSessionStore, logger: LoggerLike, -): Promise> { +): Promise { const refs = new Map(); + const adeCreated = new Set(); + const liveAdeSessionIds = new Set(); for (const session of sessionService.list({ limit: null })) { + liveAdeSessionIds.add(session.id); const metadata = session.resumeMetadata as (TerminalResumeMetadata & { importedFrom?: { provider?: ExternalSessionProvider; targetId?: string | null }; }) | null | undefined; @@ -259,7 +262,23 @@ async function importedSessionRefs( putImportedRef(refs, `${metadata.importedFrom.provider}:${metadata.importedFrom.targetId}`, ref); } } - for (const pointer of sessionService.listClaudeSessionPointers?.({ limit: 5000 }) ?? []) { + // The durable log is the only source that survives deleting the ADE session, + // and the only one that knows the provider id a fork import created. + for (const record of importedStore.list()) { + const ref: ImportedSessionRef | null = liveAdeSessionIds.has(record.adeSessionId) + ? { kind: record.kind, sessionId: record.adeSessionId } + : null; + putImportedRef(refs, `${record.provider}:${record.externalId}`, ref); + if (record.targetId) { + putImportedRef(refs, `${record.provider}:${record.targetId}`, ref); + if (record.targetId !== record.externalId) { + adeCreated.add(`${record.provider}:${record.targetId}`); + } + } + } + // Claude pointers are one of the imported-marking sources; ask for all of them + // rather than a page, at exactly the ceiling `sessionService` enforces. + for (const pointer of sessionService.listClaudeSessionPointers?.({ limit: CLAUDE_SESSION_POINTER_MAX_LIMIT }) ?? []) { const sessionId = pointer.sessionId?.trim(); if (!sessionId) continue; const chatSessionId = pointer.chatSessionId?.trim(); @@ -284,7 +303,46 @@ async function importedSessionRefs( }); } } - return refs; + return { refs, adeCreated }; +} + +/** + * The strongest ref among the ids this row stands for. Discovery collapses + * continuation chains and fork parents, so the id a session was imported under + * is often no longer the id it is listed under. + */ +function resolveImportedRef( + index: ImportedRefIndex, + record: Pick, +): { imported: boolean; ref: ImportedSessionRef | null } { + let imported = false; + let ref: ImportedSessionRef | null = null; + for (const id of [record.id, ...(record.lineageIds ?? [])]) { + const key = `${record.provider}:${id}`; + if (!index.refs.has(key)) continue; + imported = true; + const candidate = index.refs.get(key) ?? null; + if (refRank(candidate) > refRank(ref)) ref = candidate; + } + return { imported, ref }; +} + +/** + * ADE's own copies re-list as fresh provider sessions: a cross-cwd Claude fork + * writes a transplanted transcript under a new uuid into `~/.claude`, and a + * Codex chat fork mints a new thread. Both are already open in ADE under their + * original row, so listing them again offers to import ADE's work into ADE. + * + * Every id has to be ADE-created. When discovery collapses the user's original + * into the copy, that one row is also the only place the original appears; + * hiding it would drop the conversation instead of marking it imported. + */ +function isAdeCreatedArtifact( + index: ImportedRefIndex, + record: Pick, +): boolean { + return [record.id, ...(record.lineageIds ?? [])] + .every((id) => index.adeCreated.has(`${record.provider}:${id}`)); } function toolTypeForProvider(provider: ExternalSessionProvider): TerminalToolType { @@ -314,27 +372,39 @@ function targetKindForProvider(provider: ExternalSessionProvider): TerminalResum return provider === "codex" ? "thread" : "session"; } +/** + * The launch settings an import runs with, resolved once from the user's request + * and whatever discovery recovered from the session. Passed around whole: every + * consumer — the resume command, each fork command, the stored metadata — needs + * the same set, and the codex-only `codexComputerUse` is added at the one call + * site that can await it. + */ +type LaunchOverrides = { + model: string | null; + reasoningEffort: string | null; + fastMode: boolean | null; + permissionMode: TerminalResumeMetadata["launch"]["permissionMode"]; + codexApprovalPolicy: TerminalResumeMetadata["launch"]["codexApprovalPolicy"]; + codexSandbox: TerminalResumeMetadata["launch"]["codexSandbox"]; + codexConfigSource: TerminalResumeMetadata["launch"]["codexConfigSource"]; +}; + function metadataForImport(args: { provider: ExternalSessionProvider; targetId: string | null; originalTargetId: string; mode: "resume" | "fork"; - model?: string | null; - reasoningEffort?: string | null; - fastMode?: boolean | null; - permissionMode?: string | null; - codexApprovalPolicy?: TerminalResumeMetadata["launch"]["codexApprovalPolicy"]; - codexSandbox?: TerminalResumeMetadata["launch"]["codexSandbox"]; - codexConfigSource?: TerminalResumeMetadata["launch"]["codexConfigSource"]; + overrides: LaunchOverrides; }): TerminalResumeMetadata { + const overrides = args.overrides; const launch = { - ...(args.model ? { model: args.model } : {}), - ...(args.reasoningEffort ? { reasoningEffort: args.reasoningEffort } : {}), - ...(typeof args.fastMode === "boolean" ? { fastMode: args.fastMode } : {}), - ...(args.permissionMode ? { permissionMode: args.permissionMode as TerminalResumeMetadata["launch"]["permissionMode"] } : {}), - ...(args.codexApprovalPolicy ? { codexApprovalPolicy: args.codexApprovalPolicy } : {}), - ...(args.codexSandbox ? { codexSandbox: args.codexSandbox } : {}), - ...(args.codexConfigSource ? { codexConfigSource: args.codexConfigSource } : {}), + ...(overrides.model ? { model: overrides.model } : {}), + ...(overrides.reasoningEffort ? { reasoningEffort: overrides.reasoningEffort } : {}), + ...(typeof overrides.fastMode === "boolean" ? { fastMode: overrides.fastMode } : {}), + ...(overrides.permissionMode ? { permissionMode: overrides.permissionMode } : {}), + ...(overrides.codexApprovalPolicy ? { codexApprovalPolicy: overrides.codexApprovalPolicy } : {}), + ...(overrides.codexSandbox ? { codexSandbox: overrides.codexSandbox } : {}), + ...(overrides.codexConfigSource ? { codexConfigSource: overrides.codexConfigSource } : {}), }; return { provider: args.provider, @@ -354,45 +424,21 @@ async function forkCommandFor(args: { provider: ExternalSessionProvider; metadata: TerminalResumeMetadata; targetId: string; - model?: string | null; - reasoningEffort?: string | null; - fastMode?: boolean | null; - permissionMode?: string | null; - codexApprovalPolicy?: TerminalResumeMetadata["launch"]["codexApprovalPolicy"]; - codexSandbox?: TerminalResumeMetadata["launch"]["codexSandbox"]; - codexConfigSource?: TerminalResumeMetadata["launch"]["codexConfigSource"]; + overrides: LaunchOverrides; transplantedClaude: boolean; }): Promise { + const forkMetadata = { ...args.metadata, targetId: args.targetId }; + if (args.provider === "claude") { - const command = buildTrackedCliResumeCommand( - { ...args.metadata, targetId: args.targetId }, - { - model: args.model, - reasoningEffort: args.reasoningEffort, - fastMode: args.fastMode, - permissionMode: args.permissionMode as TerminalResumeMetadata["launch"]["permissionMode"], - codexApprovalPolicy: args.codexApprovalPolicy, - codexSandbox: args.codexSandbox, - codexConfigSource: args.codexConfigSource, - }, - ); + const command = buildTrackedCliResumeCommand(forkMetadata, args.overrides); return args.transplantedClaude ? command : `${command} --fork-session`; } if (args.provider === "codex") { - const resume = buildTrackedCliResumeCommand( - { ...args.metadata, targetId: args.targetId }, - { - model: args.model, - reasoningEffort: args.reasoningEffort, - fastMode: args.fastMode, - permissionMode: args.permissionMode as TerminalResumeMetadata["launch"]["permissionMode"], - codexApprovalPolicy: args.codexApprovalPolicy, - codexSandbox: args.codexSandbox, - codexConfigSource: args.codexConfigSource, - codexComputerUse: await resolveCodexComputerUseMcpConfig(), - }, - ); + const resume = buildTrackedCliResumeCommand(forkMetadata, { + ...args.overrides, + codexComputerUse: await resolveCodexComputerUseMcpConfig(), + }); return withCodexNoAltScreen(resume.replace(/\bresume\b/u, "fork")); } @@ -401,25 +447,18 @@ async function forkCommandFor(args: { } if (args.provider === "opencode") { - const resume = buildTrackedCliResumeCommand( - { ...args.metadata, targetId: args.targetId }, - { - model: args.model, - reasoningEffort: args.reasoningEffort, - fastMode: args.fastMode, - permissionMode: args.permissionMode as TerminalResumeMetadata["launch"]["permissionMode"], - codexApprovalPolicy: args.codexApprovalPolicy, - codexSandbox: args.codexSandbox, - codexConfigSource: args.codexConfigSource, - }, - ); - return `${resume} --fork`; + return `${buildTrackedCliResumeCommand(forkMetadata, args.overrides)} --fork`; } throw new Error("Cursor sessions cannot be forked."); } export function createExternalSessionsService(args: ExternalSessionsServiceArgs) { + const importedStore = args.importedSessionStore ?? createImportedSessionStore({ + ...(args.homeDir ? { homeDir: args.homeDir } : {}), + ...(args.env ? { env: args.env } : {}), + logger: args.logger, + }); // 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; @@ -520,7 +559,7 @@ export function createExternalSessionsService(args: ExternalSessionsServiceArgs) : requestedProviders; if (providers.length === 0) return []; const requestedLaneCwd = rawArgs.laneId ? resolveLaneCwd(args.laneService, rawArgs.laneId) : null; - const requestedCwd = rawArgs.cwd?.trim() ? realish(rawArgs.cwd) : requestedLaneCwd; + const requestedCwd = rawArgs.cwd?.trim() ? realishPath(rawArgs.cwd) : requestedLaneCwd; const scopeRoots = projectScoped ? deriveProjectScopeRoots(args.projectRoot) : []; const discoveryArgs: ExternalSessionDiscoveryArgs = { homeDir: args.homeDir, @@ -546,18 +585,32 @@ export function createExternalSessionsService(args: ExternalSessionsServiceArgs) } })); - const imported = await importedSessionRefs(args.sessionService, args.chatImportedRefsProvider, args.logger); + const imported = await importedSessionRefs( + args.sessionService, + args.chatImportedRefsProvider, + importedStore, + args.logger, + ); const activeCutoffMs = Date.now() - 2 * 60_000; + const liveClaudeIds = providers.includes("claude") + ? liveClaudeSessionIds({ homeDir: args.homeDir, env: args.env }) + : null; const cwdExistenceCache = new Map(); const discovered = sortDiscoveryRecords(settled.flat(), discoveryLimit * providers.length); - const scoped = projectScoped - ? discovered.filter((session) => isInProjectScope(session.cwd, scopeRoots)) + // `scopeRoots` is non-empty whenever `projectScoped`, so the util's + // empty-roots-means-everything behaviour is never reached here. + const inScope = projectScoped + ? discovered.filter((session) => cwdIsInScope(session.cwd, scopeRoots)) : discovered; + // An exact lookup names the session the caller wants and must resolve even + // when that session is an ADE-created copy. + const scoped = requestedSessionId + ? inScope + : inScope.filter((session) => !isAdeCreatedArtifact(imported, session)); - return scoped + const summaries = scoped .map((session): ExternalSessionSummary => { - const importKey = `${session.provider}:${session.id}`; - const importedRef = imported.get(importKey) ?? null; + const { imported: alreadyImported, ref: importedRef } = resolveImportedRef(imported, session); return { provider: session.provider, id: session.id, @@ -569,15 +622,19 @@ export function createExternalSessionsService(args: ExternalSessionsServiceArgs) updatedAt: session.updatedAt, messageCount: session.messageCount, launch: session.launch ?? null, - alreadyImported: importedRef != null, + alreadyImported, importedSessionRef: importedRef, - possiblyActive: typeof session.sourceMtimeMs === "number" && session.sourceMtimeMs >= activeCutoffMs, + // Claude publishes its own live-session registry, which answers "open + // right now" instead of "written in the last two minutes". + possiblyActive: session.provider === "claude" && liveClaudeIds + ? liveClaudeIds.has(session.id) + : typeof session.sourceMtimeMs === "number" && session.sourceMtimeMs >= activeCutoffMs, cwdMatchesRequestedLane: cwdMatches(session.cwd, requestedCwd), capabilities: capabilitiesFor(session.provider, session, cwdExistenceCache), }; }) - .sort((left, right) => (right.updatedAt ?? 0) - (left.updatedAt ?? 0)) - .slice(0, projectScoped ? limit : scoped.length); + .sort((left, right) => (right.updatedAt ?? 0) - (left.updatedAt ?? 0)); + return projectScoped ? summaries.slice(0, limit) : summaries; }; const findExternalSummary = async ( @@ -632,14 +689,20 @@ export function createExternalSessionsService(args: ExternalSessionsServiceArgs) const sessionId = validateExternalSessionId(provider, importArgs.sessionId); const laneId = importArgs.laneId.trim(); const laneCwd = resolveLaneCwd(args.laneService, laneId); - if (importArgs.target === "chat") { - if (provider !== "claude" && provider !== "codex") { - throw new Error(`Chat import is only available for Claude and Codex sessions, not ${provider}.`); - } - if (!args.chatImporter) { - throw new Error("Chat import unavailable: external chat importer is not configured."); - } - } + // Resolved before any discovery work, so an import that cannot run fails + // before it reads the provider's session store. Binding the importer here is + // also what lets the branch below use it without asserting it exists. + const chatImport = importArgs.target === "chat" + ? (() => { + if (provider !== "claude" && provider !== "codex") { + throw new Error(`Chat import is only available for Claude and Codex sessions, not ${provider}.`); + } + if (!args.chatImporter) { + throw new Error("Chat import unavailable: external chat importer is not configured."); + } + return { provider, importer: args.chatImporter }; + })() + : null; const summary = await findExternalSummary(provider, sessionId, laneCwd); if (!summary) { throw new Error(`${provider} external session '${sessionId}' was not found or is not resumable.`); @@ -647,17 +710,14 @@ export function createExternalSessionsService(args: ExternalSessionsServiceArgs) const sourceCwd = summary?.cwd ?? null; const enforceLaneScopeCwd = importArgs.enforceLaneScopeCwd?.trim(); if (enforceLaneScopeCwd) { - const scopeRoot = realish(enforceLaneScopeCwd); - if (!sourceCwd || !isPathInside(scopeRoot, realish(sourceCwd))) { + const scopeRoot = realishPath(enforceLaneScopeCwd); + if (!sourceCwd || !isPathInside(scopeRoot, realishPath(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 (chatImport) { + const { importer } = chatImport; if (!summary.capabilities.importToChat) { throw new Error(`${provider} session '${sessionId}' cannot be imported as an ADE chat.`); } @@ -669,13 +729,25 @@ export function createExternalSessionsService(args: ExternalSessionsServiceArgs) throw new Error("Claude sessions from another folder must be copied into the target lane; continuing the original across folders is not supported."); } const result = await importer.importExternalChatSession({ - provider, + provider: chatImport.provider, externalSessionId: sessionId, laneId, cwd: sourceCwd, fork: importArgs.mode === "fork", ...(summary?.title ? { title: summary.title } : {}), }); + // A fork import binds the chat to a provider id that did not exist when + // the user picked the row; both ids have to be marked, or the fork lists + // back as a fresh session and the original loses its badge. + const providerTargetId = result.providerTargetId?.trim() || null; + importedStore.record({ + provider, + externalId: sessionId, + targetId: providerTargetId && providerTargetId !== sessionId ? providerTargetId : null, + kind: "chat", + adeSessionId: result.chatSessionId, + mode: importArgs.mode === "fork" ? "fork" : "continue", + }); return { kind: "chat", chatSessionId: result.chatSessionId, @@ -712,7 +784,7 @@ export function createExternalSessionsService(args: ExternalSessionsServiceArgs) } if (provider === "claude") { if (!sourceCwd) throw new Error("Claude fork import requires the source session cwd."); - if (realish(sourceCwd) !== realish(laneCwd)) { + if (realishPath(sourceCwd) !== realishPath(laneCwd)) { const transplant = await transplantClaudeSession({ sessionId, sourceCwd, @@ -733,7 +805,7 @@ export function createExternalSessionsService(args: ExternalSessionsServiceArgs) runCwd = laneCwd; } else if (provider === "opencode") { if (!sourceCwd) throw new Error("OpenCode fork import requires the source session cwd."); - if (realish(sourceCwd) !== realish(laneCwd)) { + if (realishPath(sourceCwd) !== realishPath(laneCwd)) { throw new Error("OpenCode sessions cannot be copied into a different lane folder."); } metadataTargetId = null; @@ -741,68 +813,55 @@ export function createExternalSessionsService(args: ExternalSessionsServiceArgs) } } - const resolvedModel = importArgs.model?.trim() || summary.launch?.model?.trim() || null; - const resolvedReasoningEffort = importArgs.reasoningEffort?.trim() - || summary.launch?.reasoningEffort?.trim() - || null; - const resolvedPermissionMode = importArgs.permissionMode?.trim() - || summary.launch?.permissionMode?.trim() - || null; - const resolvedFastMode = typeof importArgs.fastMode === "boolean" - ? importArgs.fastMode - : summary.launch?.fastMode ?? summary.launch?.codexFastMode ?? null; + // An explicit permission mode replaces the discovered codex permission + // triple wholesale; keeping half of each would launch a mix the user never + // chose. const preserveDiscoveredCodexPermissions = provider === "codex" && importArgs.permissionMode == null; - const resolvedCodexApprovalPolicy = preserveDiscoveredCodexPermissions - ? summary.launch?.codexApprovalPolicy ?? null - : null; - const resolvedCodexSandbox = preserveDiscoveredCodexPermissions - ? summary.launch?.codexSandbox ?? null - : null; - const resolvedCodexConfigSource = preserveDiscoveredCodexPermissions - ? summary.launch?.codexConfigSource ?? null - : null; + const overrides: LaunchOverrides = { + model: importArgs.model?.trim() || summary.launch?.model?.trim() || null, + reasoningEffort: importArgs.reasoningEffort?.trim() + || summary.launch?.reasoningEffort?.trim() + || null, + fastMode: typeof importArgs.fastMode === "boolean" + ? importArgs.fastMode + : summary.launch?.fastMode ?? summary.launch?.codexFastMode ?? null, + permissionMode: (importArgs.permissionMode?.trim() + || summary.launch?.permissionMode?.trim() + || null) as TerminalResumeMetadata["launch"]["permissionMode"], + codexApprovalPolicy: preserveDiscoveredCodexPermissions + ? summary.launch?.codexApprovalPolicy ?? null + : null, + codexSandbox: preserveDiscoveredCodexPermissions + ? summary.launch?.codexSandbox ?? null + : null, + codexConfigSource: preserveDiscoveredCodexPermissions + ? summary.launch?.codexConfigSource ?? null + : null, + }; const metadata = metadataForImport({ provider, targetId: metadataTargetId, originalTargetId: sessionId, mode: importArgs.mode, - model: resolvedModel, - reasoningEffort: resolvedReasoningEffort, - fastMode: resolvedFastMode, - permissionMode: resolvedPermissionMode, - codexApprovalPolicy: resolvedCodexApprovalPolicy, - codexSandbox: resolvedCodexSandbox, - codexConfigSource: resolvedCodexConfigSource, + overrides, }); const startupCommand = importArgs.mode === "resume" ? buildTrackedCliResumeCommand(metadata, { - model: resolvedModel, - reasoningEffort: resolvedReasoningEffort, - fastMode: resolvedFastMode, - permissionMode: resolvedPermissionMode as TerminalResumeMetadata["launch"]["permissionMode"], - codexApprovalPolicy: resolvedCodexApprovalPolicy, - codexSandbox: resolvedCodexSandbox, - codexConfigSource: resolvedCodexConfigSource, + ...overrides, ...(provider === "codex" ? { codexComputerUse: await resolveCodexComputerUseMcpConfig() } : {}), }) : await forkCommandFor({ provider, metadata, targetId: launchTargetId, - model: resolvedModel, - reasoningEffort: resolvedReasoningEffort, - fastMode: resolvedFastMode, - permissionMode: resolvedPermissionMode, - codexApprovalPolicy: resolvedCodexApprovalPolicy, - codexSandbox: resolvedCodexSandbox, - codexConfigSource: resolvedCodexConfigSource, + overrides, transplantedClaude, }); const ptyArgs: PtyCreateArgs = { laneId, cwd: runCwd, - allowExternalCwd: realish(runCwd) !== realish(laneCwd), + allowExternalCwd: realishPath(runCwd) !== realishPath(laneCwd), cols: 120, rows: 36, title: summary?.title ?? `${provider} ${importArgs.mode} ${sessionId.slice(0, 8)}`, @@ -814,6 +873,16 @@ export function createExternalSessionsService(args: ExternalSessionsServiceArgs) }; const created = await args.ptyService.create(ptyArgs); + importedStore.record({ + provider, + externalId: sessionId, + // Only a Claude transplant produces a new provider id ADE knows here; a + // Codex/Droid CLI fork mints its id inside the launched process. + targetId: launchTargetId !== sessionId ? launchTargetId : null, + kind: "cli", + adeSessionId: created.sessionId, + mode: importArgs.mode === "fork" ? "fork" : "continue", + }); args.logger.info?.("external_sessions.imported_cli", { provider, sessionId, diff --git a/apps/desktop/src/main/services/externalSessions/importedSessionStore.ts b/apps/desktop/src/main/services/externalSessions/importedSessionStore.ts new file mode 100644 index 0000000000000000000000000000000000000000..f328c52f9529e73db4c83194fe6c0f4a0acc6b0d GIT binary patch literal 8849 zcmcIqe{bBz5$)gl6q`el-lY60@PTnJ@To3f>-rEiK=?uyJjEAO1~%{rLe)^*bwTZc6SkJdI|a~a~1t}wRZ zr8c9fv8Hu?QP#Giup}A+%(XLxt6WQktof$0tHPOOQ`XpEsxm-Bp0r%bf*L{%^uPT> zFtuAp&F;zqi_3ybYX`%L*;)tbHorA(BbRD?c|#4-@er|atu1V8GYHz>ac{-B?PlrB zxwz96XK$egHeISU=onv^BlB*@7+Be)_cV~IQQL((RNKNJ)+{Sq)+V&RtZz(cU@p^C z)OQAF;dxnAW@$Zal2HlMNt(t@hw-Raa7=d!F59^@pEm)HuH{Me_ccA_d=%;8EzXMv zLLnOdcqp0fw|Tp=6(HePbujBWjL8F|_#BRf%W%ECS#HdK{`GG<55e$OCDKJ$PxtSd z#^Yt{ffLvQel3!lBMvh2{R&3F9!RU(vR>I1$O`~OU3Buq?bL*qh41O_riOyi~ z)d4UNB>|#3pYxn&=QLRk=fTCeD(%}^f8*NT6z09DaX!s_i)fgdQ9ifr2#-g3RU({> zv4x8O=(q9tg5i&4sN(Bd@N{K+t)(&>WO00#1#sd=#|+pj;lZ`X`^+m4RS=jL6< zIj1eI6NBykf&Oq4=;Zn7k5?CG-@LeZatVX~ug!JUXe(B(0p-LUp}AZ(H9W zhT#g3s0J}!+13;#9?!9X*DG7$xWO5huTGzx=^4?xJ2UVBDaCX?87~l0^=fhL{A4N^ zS4!VEuhj;xnM?w_H;1#Bb8%_8J^V|r$E2b_gu$vR`pzkG<~9J_`}}_7H0#T47=PbaEqHvJdlx#yd6Axj@EHuLkU;`Yz2O=wHn?0#E1ii1Ys&L`9Z2<74 zf}A_&SY3ffgjxfY6CunP^Jd|MvgHUX7bDJqFr-A-kEa!lLykt(0Igs|NUQ)MP1yKD zs;UG#MR|lImQt9rzACYhvTRWR2-mJ%J7lL4IRK<3_vvEN@iN?Z;o6l4^2zjlHWTFK z5<%1nkZBq`t1n7M7~9#n%fq%F?QZJK1njBoSCscBV&D!H2U3TcIJKE0K_Ou8LOI>Cns5s z*XFaMBQrWX`|D}gttIM(m-5ucsNdWIe6D}FvfWP}&u!`Qg#Uxv_ z<-$D=dJnEncj@JI5{7zUVLb4DGLB}@3lDTVQXsy6KlH#JqAO{Ctlmgm)?4nwTfhmc z8veZ9zQCsy2Qi(-yq3821`&WLLqyL?A6g9pmO7-bg&@ww@DZyU}dtDLzS9A!D9_=FEvwLES)1WZ-ouMD~ zNz$>Wf5CdnMhUFA!9Dr33qX&~#0Cw(87jlAh++h8$K)`R&K>+xRU5-g(22r+S(Q2P#>@g?v3Oz9h};a$U6ys7${gHDItMdGRnkiTpu)<~FwGQf zW{9H6p|OkWlBEwa*VVT#o}ZZo%Ohq18pN}e(7P+IKzr!P+hzRg$%&DxW=XY^o_WnQ zl`C8-K8-TceU8K!SvvY8L-DszuF#d}H@;>5z{0o(_b}!}V4r!1>)g3&fZ{RK@r2KDZ_c} zp0%$S-`Q$~wj;I`j1oRzft41fHacQS_er*49DSziaRK&Ds9W^6q=e@B4GL;>Uhj=m zNm&`RdXhDh6nDtNRd9?xwQ#H|j-n_ep_kUTrL8{QdK^zd8y0o7>E|yK51)7I!D0- zt&$-2#3CfM#qZOV25-I*L>ni?6#vOeZ$>p)4v5%-hND-L|Uiq zmNFj$aN;($=f45Q!{z@^^hz=R3G5E_7($l&vN&Im`+rG=d^SQPn*pN`6+79Q`g5 zSDcnUw>cVXw-+$d;;=E5`OG(6bO$X^*uH>0qHTLr37k!>*^MvTa$);qM#7?#aB>?HmK zt$=n>hFh-U*cP)ZX@t8(ZHrU%>}tbbb5UnpMT>Xb zSTfx3LyL@_`w~sdaXpoh(&wBhhz;#q)<220d%rkkqqH>r9@gRP2>TuS)j0gilZoQ8pAA3z<+3MF2+JtA@I!xEe3VRpsI_<|Rw z>;iCT17g!J1|(B~F4aI!%Pw2GYktnepsp~O(C6LI6a*-Qh(9SKe$(Ye`OOh+&o#vM zgO@Hcho(%aL_Ug9F=Z(Ur{tEj=GJZS8lA2dYvRI(YZeY4%lN@F3Cy4(Mx5hV+esL9 zB2?9h4C^3$U{95kEmvW#Sb2DmZVfmoF75iH;Rl zeT>%o0^{LyHw|!wCQHt{h)<|@vh3|_#PN=ERG9&+QohQ>kMM_H;)X({#QK1 z6-;xTaY6htJWL6%=SVO;$J8K5{=vX+E#eav#%LN3PV_|5)r0Ze>OjQ{`u literal 0 HcmV?d00001 diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index cab3c3054..f294c5511 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -37,10 +37,7 @@ import { resolveClaudeCodeExecutable } from "../ai/claudeCodeExecutable"; import { buildProviderConnections } from "../ai/providerConnectionStatus"; import { browseProjectDirectories } from "../projects/projectBrowserService"; import { getProjectDetail } from "../projects/projectDetailService"; -import { - inspectProjectPathCached, - invalidateProjectPathInspectionCache, -} from "../projects/projectPathInspector"; +import { inspectProjectPathCached } from "../projects/projectPathInspector"; import { deleteTerminalSessionWithRuntimeCleanup } from "../sessions/deleteTerminalSession"; import { parseSettleOverrideArg, @@ -112,9 +109,6 @@ import { AttentionAccountCoordinator } from "../attention/attentionAccountCoordi import type { ApplyConflictProposalArgs, BatchAssessmentResult, - AttachLaneArgs, - AdoptAttachedLaneArgs, - UnregisteredLaneCandidate, AppInfo, AppWelcomeVideoState, AppResourceUsageSnapshot, @@ -431,7 +425,6 @@ import type { KeybindingsSnapshot, ImportBranchLaneArgs, OnboardingDetectionResult, - OnboardingExistingLaneCandidate, OnboardingHelpState, OnboardingStatus, LaneLinearIssue, @@ -5271,12 +5264,6 @@ export function registerIpc({ return await ctx.onboardingService.detectDefaults(); }); - ipcMain.handle(IPC.onboardingDetectExistingLanes, async (): Promise => { - const ctx = getCtx(); - if (!ctx.onboardingService) return []; - return await ctx.onboardingService.detectExistingLanes(); - }); - ipcMain.handle(IPC.onboardingSetDismissed, async (_event, arg: { dismissed: boolean }): Promise => { const ctx = getCtx(); if (!ctx.onboardingService) { @@ -5796,28 +5783,6 @@ export function registerIpc({ return await ctx.laneService.resolveBranchDrift(arg); }); - ipcMain.handle(IPC.lanesAttach, async (_event, arg: AttachLaneArgs): Promise => { - const ctx = ensureLaneContext(); - const lane = await ctx.laneService.attach(arg); - invalidateProjectPathInspectionCache(); - await ensureActiveLanePortLease(ctx, lane.id); - notifyLaneCreated(ctx, lane); - return lane; - }); - - ipcMain.handle(IPC.lanesListUnregisteredWorktrees, async (): Promise => { - const ctx = ensureLaneContext(); - return ctx.laneService.listUnregisteredWorktrees(); - }); - - ipcMain.handle(IPC.lanesAdoptAttached, async (_event, arg: AdoptAttachedLaneArgs): Promise => { - const ctx = ensureLaneContext(); - const lane = await ctx.laneService.adoptAttached(arg); - await ensureActiveLanePortLease(ctx, lane.id); - notifyLaneCreated(ctx, lane); - return lane; - }); - ipcMain.handle(IPC.lanesRename, async (_event, arg: RenameLaneArgs): Promise => { const ctx = ensureLaneContext(); ctx.laneService.rename(arg); diff --git a/apps/desktop/src/main/services/ipc/runtimeBridge.ts b/apps/desktop/src/main/services/ipc/runtimeBridge.ts index 6e4092a76..650e514e1 100644 --- a/apps/desktop/src/main/services/ipc/runtimeBridge.ts +++ b/apps/desktop/src/main/services/ipc/runtimeBridge.ts @@ -60,7 +60,6 @@ import { RemoteTargetRegistry } from "../remoteRuntime/remoteTargetRegistry"; import { DesktopPairedMachineStore } from "../remoteRuntime/syncPairedMachineStore"; import { parseRemoteRuntimePairingInput } from "../remoteRuntime/pairingInput"; import { hasKnownSshHostKeyForTarget } from "../remoteRuntime/sshTransport"; -import { invalidateProjectPathInspectionCache } from "../projects/projectPathInspector"; import { shouldSendPtyDataToWebContents } from "../pty/ptyDataSubscriptions"; import { getSharedAccountAuthService } from "../../../../../ade-cli/src/services/account/sharedAccountAuthService"; import { resolveMachineAdeLayout } from "../../../../../ade-cli/src/services/projects/machineLayout"; @@ -70,16 +69,6 @@ import { type RuntimeEventWindowSubscription, } from "./runtimeEventSubscriptionRegistry"; -// Lane attach/adopt performed through the runtime action path never touches the -// in-process IPC.lanesAttach handler, so the project-path inspection cache must -// be invalidated here too or it can serve a pre-attach result for up to its TTL. -const LANE_INSPECTION_INVALIDATING_ACTIONS = new Set(["attach", "adoptAttached"]); -function invalidateInspectionCacheForLaneAction(domain: string, action: string): void { - if (domain === "lane" && LANE_INSPECTION_INVALIDATING_ACTIONS.has(action)) { - invalidateProjectPathInspectionCache(); - } -} - type RuntimeBridgeArgs = { appVersion: string; /** @@ -1068,7 +1057,6 @@ export function registerRuntimeBridge({ projectId, actionRequest, ); - invalidateInspectionCacheForLaneAction(domain, action); return result; }, ); @@ -1205,7 +1193,6 @@ export function registerRuntimeBridge({ rootPath, actionRequest, ); - invalidateInspectionCacheForLaneAction(domain, action); return result; }, ); diff --git a/apps/desktop/src/main/services/lanes/laneListSnapshotService.test.ts b/apps/desktop/src/main/services/lanes/laneListSnapshotService.test.ts index 444e7b1e6..18ff54179 100644 --- a/apps/desktop/src/main/services/lanes/laneListSnapshotService.test.ts +++ b/apps/desktop/src/main/services/lanes/laneListSnapshotService.test.ts @@ -48,6 +48,37 @@ describe("laneListSnapshotService", () => { }); }); + // Shipped iOS builds decode LaneListSnapshot with `adoptableAttached` as a + // non-optional Bool, and the host updates before the phone does. The field is + // deprecated and always false, but an emitter that stops sending the KEY + // blanks the lane list on every not-yet-updated device. It stays REQUIRED on + // the TypeScript type for exactly that reason; since no TypeScript surface + // reads it, a cleanup pass could still drop type and emitters together in one + // green step, so assert on the serialized payload by literal key name. + it("still emits the deprecated adoptableAttached key for older clients", async () => { + const services = makeHarness({ + laneId: "lane-1", + status: "running", + runtimeState: "idle", + toolType: "codex", + lastOutputPreview: "Turn completed", + }); + + const [snapshot] = await buildLaneListSnapshots( + services as any, + [{ id: "lane-1", name: "Lane 1", laneType: "worktree", archivedAt: null }] as any, + { + includeConflictStatus: false, + includeRebaseSuggestions: false, + includeAutoRebaseStatus: false, + }, + ); + + const wire = JSON.parse(JSON.stringify(snapshot)); + expect(Object.keys(wire)).toContain("adoptableAttached"); + expect(wire.adoptableAttached).toBe(false); + }); + it("counts chat pending input separately from CLI attention heuristics", async () => { const services = { ...makeHarness({ diff --git a/apps/desktop/src/main/services/lanes/laneListSnapshotService.ts b/apps/desktop/src/main/services/lanes/laneListSnapshotService.ts index dcaf9ab3c..f7258edd1 100644 --- a/apps/desktop/src/main/services/lanes/laneListSnapshotService.ts +++ b/apps/desktop/src/main/services/lanes/laneListSnapshotService.ts @@ -401,6 +401,9 @@ export async function buildLaneListSnapshots( autoRebaseStatus: autoRebaseByLaneId.get(lane.id) ?? null, conflictStatus: conflictByLaneId.get(lane.id) ?? null, stateSnapshot: stateByLaneId.get(lane.id) ?? null, - adoptableAttached: lane.laneType === "attached" && lane.archivedAt == null, + // Deprecated, always false. Shipped iOS builds decode this as a + // non-optional Bool; dropping the key blanks their lane list. See the + // field's doc comment in shared/types/lanes.ts. + adoptableAttached: false, })); } diff --git a/apps/desktop/src/main/services/lanes/laneService.test.ts b/apps/desktop/src/main/services/lanes/laneService.test.ts index 5603fc556..b8988e38d 100644 --- a/apps/desktop/src/main/services/lanes/laneService.test.ts +++ b/apps/desktop/src/main/services/lanes/laneService.test.ts @@ -13,6 +13,17 @@ vi.mock("../git/git", () => ({ import { getHeadSha, runGit, runGitOrThrow } from "../git/git"; +/** + * A fixture repo root in the same path space real git reports from. `os.tmpdir()` + * is a symlink on macOS, and these tests fabricate git's output from the paths + * they build — git itself always answers with symlinks resolved, so an + * unresolved fixture root would model something git never produces. Tests that + * need a symlinked root build one explicitly. + */ +function makeTempRepoRoot(prefix: string): string { + return fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), prefix))); +} + function createLogger() { return { debug: () => {}, @@ -109,7 +120,7 @@ describe("laneService createFromUnstaged", () => { }); it("includes worktree availability in lane summaries", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-worktree-available-")); + const repoRoot = makeTempRepoRoot("ade-lane-service-worktree-available-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); try { await seedProjectAndStack(db, { projectId: "proj-worktree-available", repoRoot }); @@ -160,7 +171,7 @@ describe("laneService createFromUnstaged", () => { }); it("preserves agent summaries during status-only snapshot refreshes", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-status-snapshot-")); + const repoRoot = makeTempRepoRoot("ade-lane-service-status-snapshot-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); try { await seedProjectAndStack(db, { projectId: "proj-status-snapshot", repoRoot }); @@ -216,7 +227,7 @@ describe("laneService createFromUnstaged", () => { // Regression: unchanged status polls used to rewrite updated_at on every // summary read, replicating a CRDT row change to phones every poll and // hammering the mobile Lanes list with no-op invalidations. - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-noop-snapshot-")); + const repoRoot = makeTempRepoRoot("ade-lane-service-noop-snapshot-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); try { await seedProjectAndStack(db, { projectId: "proj-noop-snapshot", repoRoot }); @@ -279,7 +290,7 @@ describe("laneService createFromUnstaged", () => { }); it("recreates the primary lane when the only stored primary lane is archived", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-primary-archived-")); + const repoRoot = makeTempRepoRoot("ade-lane-service-primary-archived-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); const now = "2026-03-11T12:00:00.000Z"; db.run( @@ -322,7 +333,7 @@ describe("laneService createFromUnstaged", () => { }); it("notifies when a new lane is linked to a Linear issue", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-linear-card-")); + const repoRoot = makeTempRepoRoot("ade-lane-service-linear-card-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); const now = "2026-05-12T20:00:00.000Z"; db.run( @@ -369,7 +380,7 @@ describe("laneService createFromUnstaged", () => { }); it("links Linear issues that do not belong to a Linear project", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-linear-projectless-")); + const repoRoot = makeTempRepoRoot("ade-lane-service-linear-projectless-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); await seedProjectAndStack(db, { projectId: "proj-linear-projectless", repoRoot }); @@ -432,7 +443,7 @@ describe("laneService createFromUnstaged", () => { }); it("moves unstaged and untracked changes into a new child lane", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-rescue-success-")); + const repoRoot = makeTempRepoRoot("ade-lane-service-rescue-success-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); await seedProjectAndStack(db, { projectId: "proj-rescue-success", repoRoot }); @@ -525,7 +536,7 @@ describe("laneService createFromUnstaged", () => { }); it("rejects the rescue flow when staged changes exist", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-rescue-staged-")); + const repoRoot = makeTempRepoRoot("ade-lane-service-rescue-staged-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); await seedProjectAndStack(db, { projectId: "proj-rescue-staged", repoRoot }); @@ -556,7 +567,7 @@ describe("laneService createFromUnstaged", () => { }); it("allows rescuing unstaged changes from the primary lane even when it is behind remote", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-rescue-primary-")); + const repoRoot = makeTempRepoRoot("ade-lane-service-rescue-primary-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); await seedProjectAndStack(db, { projectId: "proj-rescue-primary", repoRoot }); @@ -646,7 +657,7 @@ describe("laneService createFromUnstaged", () => { }); it("restores the source work and removes the new lane when applying in the target lane fails", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-rescue-rollback-")); + const repoRoot = makeTempRepoRoot("ade-lane-service-rescue-rollback-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); await seedProjectAndStack(db, { projectId: "proj-rescue-rollback", repoRoot }); @@ -737,7 +748,7 @@ describe("laneService createFromUnstaged", () => { }); it("rejects the rescue flow when there are no unstaged changes", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-rescue-empty-")); + const repoRoot = makeTempRepoRoot("ade-lane-service-rescue-empty-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); await seedProjectAndStack(db, { projectId: "proj-rescue-empty", repoRoot }); @@ -776,7 +787,7 @@ describe("laneService automatic lane identity", () => { }); it("serializes duplicate completions and renames the exact temporary branch once", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-auto-lane-identity-")); + const repoRoot = makeTempRepoRoot("ade-auto-lane-identity-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); try { await seedProjectAndStack(db, { projectId: "proj-auto-identity", repoRoot }); @@ -865,7 +876,7 @@ describe("laneService create", () => { }); it("creates an unparented lane from the requested base branch", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-create-root-")); + const repoRoot = makeTempRepoRoot("ade-lane-service-create-root-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); const now = "2026-03-11T12:00:00.000Z"; @@ -939,7 +950,7 @@ describe("laneService create", () => { }); it("does not let a concurrent list adopt a half-created worktree as a duplicate lane", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-create-race-")); + const repoRoot = makeTempRepoRoot("ade-lane-service-create-race-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); const now = "2026-03-11T12:00:00.000Z"; @@ -1054,7 +1065,7 @@ describe("laneService create", () => { }); it("creates an unparented lane from an explicit start point", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-create-start-point-")); + const repoRoot = makeTempRepoRoot("ade-lane-service-create-start-point-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); const now = "2026-03-11T12:00:00.000Z"; let worktreeStartPoint: string | null = null; @@ -1128,7 +1139,7 @@ describe("laneService create", () => { }); it("creates a fresh lane from primary using the project default base by default", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-create-from-primary-")); + const repoRoot = makeTempRepoRoot("ade-lane-service-create-from-primary-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); const now = "2026-03-11T12:00:00.000Z"; @@ -1208,7 +1219,7 @@ describe("laneService create", () => { }); it("creates a root lane from the default base when the requested parent is primary", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-create-primary-parent-")); + const repoRoot = makeTempRepoRoot("ade-lane-service-create-primary-parent-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); const now = "2026-03-11T12:00:00.000Z"; @@ -1291,7 +1302,7 @@ describe("laneService list repairs", () => { }); it("repairs legacy root ADE lanes back to the default base when they have no open PR", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-repair-root-base-")); + const repoRoot = makeTempRepoRoot("ade-lane-service-repair-root-base-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); const now = "2026-03-11T12:00:00.000Z"; @@ -1379,7 +1390,7 @@ describe("laneService list repairs", () => { }); it("dedupes duplicate lane rows sharing a managed worktree, keeping the creator row", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-repair-dup-")); + const repoRoot = makeTempRepoRoot("ade-lane-service-repair-dup-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); const worktreesDir = path.join(repoRoot, "worktrees"); const sharedWorktreePath = path.join(worktreesDir, "claude-pre-launch-review-ba241a46"); @@ -1499,6 +1510,899 @@ describe("laneService list repairs", () => { }); }); +describe("laneService worktree reconcile", () => { + const INSERT_LANE = ` + insert into lanes( + id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, + attached_root_path, is_edit_protected, parent_lane_id, color, icon, tags_json, status, created_at, archived_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `; + const NOW = "2026-08-01T12:00:00.000Z"; + + beforeEach(() => { + vi.mocked(getHeadSha).mockReset(); + vi.mocked(runGit).mockReset(); + vi.mocked(runGitOrThrow).mockReset(); + }); + + async function makeProject(projectId: string, prefix: string) { + // Resolved, because that is the only kind of path real git ever reports and + // these fixtures stand in for its output. `os.tmpdir()` is itself a symlink + // on macOS, so without this the fixtures would sit in a path space git + // never produces. + const repoRoot = makeTempRepoRoot(prefix); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + db.run( + "insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) values (?, ?, ?, ?, ?, ?)", + [projectId, repoRoot, "demo", "main", NOW, NOW], + ); + db.run(INSERT_LANE, ["lane-main", projectId, "Main", null, "primary", "main", "main", repoRoot, null, 1, null, null, null, null, "active", NOW, null]); + return { db, repoRoot, worktreesDir: path.join(repoRoot, ".ade", "worktrees") }; + } + + function porcelain(entries: Array<{ path: string; branch?: string; bare?: boolean; prunable?: string }>): string { + return entries + .map((entry) => + [ + `worktree ${entry.path}`, + "HEAD 1111111", + ...(entry.bare ? ["bare"] : []), + ...(entry.branch ? [`branch refs/heads/${entry.branch}`] : []), + ...(entry.prunable ? [`prunable ${entry.prunable}`] : []), + "", + ].join("\n"), + ) + .join("\n"); + } + + /** + * `git worktree list` plus the one-shot ownership probe are the only git calls + * a statusless lane list needs. Real git answers both `--show-toplevel` and + * `--git-common-dir` with symlinks resolved no matter which spelling of the + * cwd it was handed, so the stub resolves `opts.cwd` the same way. + * `commonDir` defaults to the project's own `.git`: a root checkout, which + * owns every worktree of the repository. Pass a directory outside the project + * root to model a project whose root is itself a linked worktree. + */ + function stubGit(listWorktrees: () => string | Error, commonDir?: string) { + const resolvedCwd = (cwd: unknown) => { + const raw = String(cwd ?? ""); + try { + return fs.realpathSync(raw); + } catch { + return raw; + } + }; + vi.mocked(runGit).mockImplementation(async (args: string[], opts?: { cwd?: string }) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; + if (args[0] === "rev-parse" && args[1] === "--abbrev-ref" && args[2] === "HEAD") { + return { exitCode: 0, stdout: "main\n", stderr: "" }; + } + if (args[0] === "rev-parse" && args[1] === "--path-format=absolute") { + const topLevel = resolvedCwd(opts?.cwd); + const resolvedCommonDir = commonDir ?? path.join(topLevel, ".git"); + const lines = args.slice(2).map((flag) => (flag === "--show-toplevel" ? topLevel : resolvedCommonDir)); + if (!lines.length) throw new Error(`Unexpected git call: ${args.join(" ")}`); + return { exitCode: 0, stdout: `${lines.join("\n")}\n`, stderr: "" }; + } + throw new Error(`Unexpected git call: ${args.join(" ")}`); + }); + vi.mocked(runGitOrThrow).mockImplementation(async (args: string[]) => { + if (args[0] === "worktree" && args[1] === "list") { + const result = listWorktrees(); + if (result instanceof Error) throw result; + return result as any; + } + throw new Error(`Unexpected git call: ${args.join(" ")}`); + }); + } + + it("adopts every git worktree as a lane, wherever it lives, and skips prunable ones", async () => { + const { db, repoRoot, worktreesDir } = await makeProject("proj-adopt-all", "ade-lane-adopt-all-"); + const externalPath = path.resolve(path.join(repoRoot, "..", "external-checkout")); + const managedPath = path.join(worktreesDir, "managed-1234abcd"); + const prunablePath = path.resolve(path.join(repoRoot, "..", "already-gone")); + try { + stubGit(() => + porcelain([ + { path: repoRoot, branch: "main" }, + { path: externalPath, branch: "feature/external" }, + { path: managedPath, branch: "ade/managed-1234abcd" }, + { path: prunablePath, branch: "feature/gone", prunable: "gitdir file points to non-existent location" }, + ]), + ); + + const service = createLaneService({ + db, + projectRoot: repoRoot, + projectId: "proj-adopt-all", + defaultBaseRef: "main", + worktreesDir, + }); + + const lanes = await service.list({ includeStatus: false }); + + expect(lanes.find((lane) => lane.branchRef === "feature/external")).toMatchObject({ + laneType: "worktree", + baseRef: "main", + worktreePath: externalPath, + }); + expect(lanes.find((lane) => lane.branchRef === "ade/managed-1234abcd")).toMatchObject({ + laneType: "worktree", + worktreePath: managedPath, + }); + // Prunable worktrees are already half-gone; adopting one would resurrect + // a lane that the next `git worktree prune` deletes. + expect(lanes.some((lane) => lane.branchRef === "feature/gone")).toBe(false); + // The repository root stays the primary lane and is never adopted twice. + expect(lanes.filter((lane) => lane.worktreePath === repoRoot)).toHaveLength(1); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + // A project can be rooted at a linked worktree — WorktreeOpenDialog's "Open as + // a separate project instead", plus grandfathered projects. `git worktree list` + // run from there reports the whole repository, so an unscoped adopt would give + // this project lane rows for the main checkout and every sibling project's + // worktree — and a lane row is what the delete rails check before removing a + // folder. + it("adopts nothing outside its own .ade/worktrees when the project root is itself a linked worktree", async () => { + const { db, repoRoot, worktreesDir } = await makeProject("proj-linked-root", "ade-lane-linked-root-"); + const mainCheckout = path.resolve(path.join(repoRoot, "..", "main-checkout")); + const siblingProject = path.resolve(path.join(repoRoot, "..", "sibling-project")); + const ownManagedPath = path.join(worktreesDir, "own-1234abcd"); + try { + stubGit( + () => + porcelain([ + { path: mainCheckout, branch: "main" }, + { path: repoRoot, branch: "feature/this-project" }, + { path: siblingProject, branch: "feature/sibling" }, + { path: ownManagedPath, branch: "ade/own-1234abcd" }, + ]), + path.join(mainCheckout, ".git"), + ); + + const service = createLaneService({ + db, + projectRoot: repoRoot, + projectId: "proj-linked-root", + defaultBaseRef: "main", + worktreesDir, + }); + + const lanes = await service.list({ includeStatus: false }); + + expect(lanes.find((lane) => lane.branchRef === "ade/own-1234abcd")).toMatchObject({ + laneType: "worktree", + worktreePath: ownManagedPath, + }); + expect(lanes.some((lane) => lane.worktreePath === mainCheckout)).toBe(false); + expect(lanes.some((lane) => lane.worktreePath === siblingProject)).toBe(false); + expect(lanes.some((lane) => lane.branchRef === "feature/sibling")).toBe(false); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + // The mirror image: the repo-wide listing does not speak for paths this + // project does not own, so it must not be used to declare their rows vanished. + it("does not reap a lane whose worktree lies outside its own .ade/worktrees when rooted at a linked worktree", async () => { + const { db, repoRoot, worktreesDir } = await makeProject("proj-linked-reap", "ade-lane-linked-reap-"); + const mainCheckout = path.resolve(path.join(repoRoot, "..", "main-checkout")); + const foreignPath = path.resolve(path.join(repoRoot, "..", "someone-elses-worktree")); + const ownGonePath = path.join(worktreesDir, "own-gone-aaaabbbb"); + try { + db.run(INSERT_LANE, ["lane-foreign", "proj-linked-reap", "Foreign", null, "worktree", "main", "ade/foreign", foreignPath, null, 0, null, null, null, null, "active", NOW, null]); + db.run(INSERT_LANE, ["lane-own-gone", "proj-linked-reap", "Own gone", null, "worktree", "main", "ade/own-gone", ownGonePath, null, 0, null, null, null, null, "active", NOW, null]); + stubGit( + () => porcelain([{ path: mainCheckout, branch: "main" }, { path: repoRoot, branch: "feature/this-project" }]), + path.join(mainCheckout, ".git"), + ); + + const service = createLaneService({ + db, + projectRoot: repoRoot, + projectId: "proj-linked-reap", + defaultBaseRef: "main", + worktreesDir, + }); + + const lanes = await service.list({ includeStatus: false }); + + expect(lanes.some((lane) => lane.id === "lane-foreign")).toBe(true); + expect(db.get("select id from lanes where id = ?", ["lane-foreign"])).toMatchObject({ id: "lane-foreign" }); + // A lane this project does own is still reaped normally. + expect(lanes.some((lane) => lane.id === "lane-own-gone")).toBe(false); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + // git answers every path question with symlinks already resolved, while ADE + // holds whatever the user picked — routinely a symlinked parent such as + // `~/Projects` pointing at an external volume. Comparing the two spellings + // directly made the ownership probe read the repo's own checkout as foreign, + // scoping the project down to `managed-only` and then failing even that test, + // so adoption and reaping both went silently dead for the whole project. + it("adopts and reaps through a symlinked project root", async () => { + const projectId = "proj-symlink-root"; + const realBase = makeTempRepoRoot("ade-lane-symlink-real-"); + const linkHome = makeTempRepoRoot("ade-lane-symlink-link-"); + const linkBase = path.join(linkHome, "projects"); + fs.symlinkSync(realBase, linkBase, "dir"); + + const realRoot = path.join(realBase, "repo"); + // Every path ADE is configured with goes through the symlink. + const linkRoot = path.join(linkBase, "repo"); + const linkWorktreesDir = path.join(linkRoot, ".ade", "worktrees"); + const realManaged = path.join(realRoot, ".ade", "worktrees", "adopted-1234abcd"); + const realExternal = path.join(realBase, "external-checkout"); + // A lane row written before the fix, spelled through the symlink, whose + // folder is gone from both git and disk. + const linkGone = path.join(linkWorktreesDir, "gone-aaaabbbb"); + + fs.mkdirSync(realManaged, { recursive: true }); + fs.mkdirSync(realExternal, { recursive: true }); + + const db = await openKvDb(path.join(realRoot, "kv.sqlite"), createLogger()); + try { + db.run( + "insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) values (?, ?, ?, ?, ?, ?)", + [projectId, linkRoot, "demo", "main", NOW, NOW], + ); + db.run(INSERT_LANE, ["lane-main", projectId, "Main", null, "primary", "main", "main", linkRoot, null, 1, null, null, null, null, "active", NOW, null]); + db.run(INSERT_LANE, ["lane-gone", projectId, "Gone", null, "worktree", "main", "ade/gone", linkGone, null, 0, null, null, null, null, "active", NOW, null]); + + stubGit(() => + porcelain([ + { path: realRoot, branch: "main" }, + { path: realManaged, branch: "ade/adopted-1234abcd" }, + { path: realExternal, branch: "feature/external" }, + ]), + ); + + const service = createLaneService({ + db, + projectRoot: linkRoot, + projectId, + defaultBaseRef: "main", + worktreesDir: linkWorktreesDir, + }); + + const lanes = await service.list({ includeStatus: false }); + + // `repository` scope, so a worktree outside `.ade/worktrees` is adopted too. + expect(lanes.find((lane) => lane.branchRef === "ade/adopted-1234abcd")).toMatchObject({ + laneType: "worktree", + worktreePath: realManaged, + }); + expect(lanes.find((lane) => lane.branchRef === "feature/external")).toMatchObject({ + laneType: "worktree", + worktreePath: realExternal, + }); + expect(lanes.some((lane) => lane.id === "lane-gone")).toBe(false); + expect(db.get("select id from lanes where id = ?", ["lane-gone"])).toBeNull(); + // The repository's own checkout stays the single primary lane under both + // of its spellings — resolving paths must not stop excluding it. + expect(lanes.filter((lane) => lane.worktreePath === realRoot || lane.worktreePath === linkRoot)).toHaveLength(1); + expect(lanes.filter((lane) => lane.laneType === "primary")).toHaveLength(1); + } finally { + db.close(); + fs.rmSync(linkHome, { recursive: true, force: true }); + fs.rmSync(realBase, { recursive: true, force: true }); + } + }); + + it("removes a lane whose worktree is gone from both git and disk, purging its sessions", async () => { + const { db, repoRoot, worktreesDir } = await makeProject("proj-reap", "ade-lane-reap-"); + const gonePath = path.join(worktreesDir, "gone-aaaabbbb"); + const lifecycleEvents: any[] = []; + try { + db.run(INSERT_LANE, ["lane-gone", "proj-reap", "Gone", null, "worktree", "main", "ade/gone", gonePath, null, 0, null, null, null, null, "active", NOW, null]); + db.run( + "insert into claude_sessions(session_id, lane_id, title, created_at, updated_at) values (?, ?, ?, ?, ?)", + ["chat-gone", "lane-gone", "Chat in the removed lane", NOW, NOW], + ); + db.run( + "insert into terminal_sessions(id, lane_id, title, status, started_at, transcript_path) values (?, ?, ?, 'exited', ?, ?)", + ["pty-gone", "lane-gone", "Terminal in the removed lane", NOW, "/tmp/gone.log"], + ); + stubGit(() => porcelain([{ path: repoRoot, branch: "main" }])); + + const service = createLaneService({ + db, + projectRoot: repoRoot, + projectId: "proj-reap", + defaultBaseRef: "main", + worktreesDir, + onLifecycleEvent: (event) => lifecycleEvents.push(event), + }); + + const lanes = await service.list({ includeStatus: false }); + + expect(lanes.some((lane) => lane.id === "lane-gone")).toBe(false); + expect(db.get("select id from lanes where id = ?", ["lane-gone"])).toBeNull(); + expect(db.get("select session_id from claude_sessions where session_id = ?", ["chat-gone"])).toBeNull(); + expect(db.get("select id from terminal_sessions where id = ?", ["pty-gone"])).toBeNull(); + expect(lifecycleEvents).toContainEqual( + expect.objectContaining({ type: "lane-deleted", laneId: "lane-gone" }), + ); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + // The reap deletes the same rows a user-initiated delete does, so it owes the + // same file cleanup: without it every proof file the lane wrote is orphaned on + // disk with no row left pointing at it. + it("removes the reaped lane's proof files along with its rows", async () => { + const { db, repoRoot, worktreesDir } = await makeProject("proj-reap-proof", "ade-lane-reap-proof-"); + const gonePath = path.join(worktreesDir, "gone-proof-aaaabbbb"); + const artifactsDir = path.join(repoRoot, ".ade", "artifacts", "computer-use"); + const proofFile = path.join(artifactsDir, "reaped.png"); + try { + db.run(INSERT_LANE, ["lane-gone", "proj-reap-proof", "Gone", null, "worktree", "main", "ade/gone", gonePath, null, 0, null, null, null, null, "active", NOW, null]); + fs.mkdirSync(artifactsDir, { recursive: true }); + fs.writeFileSync(proofFile, "reaped"); + db.run( + `insert into computer_use_artifacts( + id, project_id, artifact_kind, backend_style, backend_name, source_tool_name, + original_type, title, description, uri, storage_kind, mime_type, metadata_json, + lane_id, created_at + ) values (?, 'proj-reap-proof', 'screenshot', 'manual', 'ade-cli', null, null, ?, null, ?, 'file', null, '{}', 'lane-gone', ?)`, + ["reaped", "reaped", ".ade/artifacts/computer-use/reaped.png", NOW], + ); + stubGit(() => porcelain([{ path: repoRoot, branch: "main" }])); + + const service = createLaneService({ + db, + projectRoot: repoRoot, + projectId: "proj-reap-proof", + defaultBaseRef: "main", + worktreesDir, + }); + + await service.list({ includeStatus: false }); + + expect(db.get("select id from lanes where id = ?", ["lane-gone"])).toBeNull(); + expect(db.get("select id from computer_use_artifacts where id = ?", ["reaped"])).toBeNull(); + expect(fs.existsSync(proofFile)).toBe(false); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("keeps a lane when only one of the two signals says the worktree is gone", async () => { + const { db, repoRoot, worktreesDir } = await makeProject("proj-reap-partial", "ade-lane-reap-partial-"); + const onDiskPath = path.join(worktreesDir, "on-disk-aaaabbbb"); + const registeredPath = path.join(worktreesDir, "registered-ccccdddd"); + try { + // Directory still on disk, but git no longer registers it. + fs.mkdirSync(onDiskPath, { recursive: true }); + db.run(INSERT_LANE, ["lane-on-disk", "proj-reap-partial", "On disk", null, "worktree", "main", "ade/on-disk", onDiskPath, null, 0, null, null, null, null, "active", NOW, null]); + // Registered by git (as prunable), but the directory is gone. + db.run(INSERT_LANE, ["lane-registered", "proj-reap-partial", "Registered", null, "worktree", "main", "ade/registered", registeredPath, null, 0, null, null, null, null, "active", NOW, null]); + stubGit(() => + porcelain([ + { path: repoRoot, branch: "main" }, + { path: registeredPath, branch: "ade/registered", prunable: "gitdir file points to non-existent location" }, + ]), + ); + + const service = createLaneService({ + db, + projectRoot: repoRoot, + projectId: "proj-reap-partial", + defaultBaseRef: "main", + worktreesDir, + }); + + const lanes = await service.list({ includeStatus: false }); + + expect(lanes.map((lane) => lane.id)).toEqual(expect.arrayContaining(["lane-on-disk", "lane-registered"])); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("never removes lanes when git worktree list fails", async () => { + const { db, repoRoot, worktreesDir } = await makeProject("proj-reap-git-fail", "ade-lane-reap-git-fail-"); + try { + db.run(INSERT_LANE, ["lane-a", "proj-reap-git-fail", "A", null, "worktree", "main", "ade/a", path.join(worktreesDir, "a-11112222"), null, 0, null, null, null, null, "active", NOW, null]); + db.run(INSERT_LANE, ["lane-b", "proj-reap-git-fail", "B", null, "worktree", "main", "ade/b", path.join(worktreesDir, "b-33334444"), null, 0, null, null, null, null, "active", NOW, null]); + stubGit(() => new Error("fatal: not a git repository")); + + const service = createLaneService({ + db, + projectRoot: repoRoot, + projectId: "proj-reap-git-fail", + defaultBaseRef: "main", + worktreesDir, + }); + + const lanes = await service.list({ includeStatus: false }); + + expect(lanes.map((lane) => lane.id).sort()).toEqual(["lane-a", "lane-b", "lane-main"]); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("never removes an archived lane whose folder was reclaimed on purpose", async () => { + const { db, repoRoot, worktreesDir } = await makeProject("proj-reap-archived", "ade-lane-reap-archived-"); + try { + db.run(INSERT_LANE, [ + "lane-reclaimed", "proj-reap-archived", "Reclaimed", null, "worktree", "main", "ade/reclaimed", + path.join(worktreesDir, "reclaimed-aaaabbbb"), null, 0, null, null, null, null, "archived", NOW, NOW, + ]); + stubGit(() => porcelain([{ path: repoRoot, branch: "main" }])); + + const service = createLaneService({ + db, + projectRoot: repoRoot, + projectId: "proj-reap-archived", + defaultBaseRef: "main", + worktreesDir, + }); + + await service.list({ includeStatus: false }); + + expect(db.get("select status from lanes where id = ?", ["lane-reclaimed"])).toMatchObject({ status: "archived" }); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); +}); + +describe("laneService delete outside .ade/worktrees", () => { + const INSERT_LANE = ` + insert into lanes( + id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, + attached_root_path, is_edit_protected, parent_lane_id, color, icon, tags_json, status, created_at, archived_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `; + const NOW = "2026-08-01T12:00:00.000Z"; + + beforeEach(() => { + vi.mocked(getHeadSha).mockReset(); + vi.mocked(runGit).mockReset(); + vi.mocked(runGitOrThrow).mockReset(); + }); + + async function setup(opts: { + projectId: string; + prefix: string; + laneType?: "worktree" | "attached"; + /** Where the lane's worktree lives; defaults to an external directory. */ + makeWorktreePath?: (repoRoot: string) => string; + createDirectory?: boolean; + }) { + const repoRoot = makeTempRepoRoot(opts.prefix); + const worktreesDir = path.join(repoRoot, ".ade", "worktrees"); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + db.run( + "insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) values (?, ?, ?, ?, ?, ?)", + [opts.projectId, repoRoot, "demo", "main", NOW, NOW], + ); + db.run(INSERT_LANE, ["lane-main", opts.projectId, "Main", null, "primary", "main", "main", repoRoot, null, 1, null, null, null, null, "active", NOW, null]); + // A sibling of the repo, i.e. a worktree the user created outside ADE. + const worktreePath = (opts.makeWorktreePath + ?? ((root: string) => path.resolve(root, "..", `${path.basename(root)}-external`)))(repoRoot); + if (opts.createDirectory !== false) fs.mkdirSync(worktreePath, { recursive: true }); + db.run(INSERT_LANE, [ + "lane-external", opts.projectId, "External", null, opts.laneType ?? "worktree", "main", "feature/external", + worktreePath, opts.laneType === "attached" ? worktreePath : null, 0, null, null, null, null, "active", NOW, null, + ]); + const events: any[] = []; + const service = createLaneService({ + db, + projectRoot: repoRoot, + projectId: opts.projectId, + defaultBaseRef: "main", + worktreesDir, + onDeleteEvent: (event) => events.push(event), + }); + return { db, service, repoRoot, worktreesDir, worktreePath, events }; + } + + /** Clean worktree, `git worktree remove` behaves like the real thing. */ + function stubGitForDelete(args: { + worktreePath: string; + removeExitCode?: number; + removeStderr?: string; + topLevel?: string | null; + }) { + vi.mocked(runGit).mockImplementation(async (gitArgs: string[], opts?: { cwd?: string }) => { + const laneBranchGitStub = defaultLaneBranchGitStub(gitArgs); + if (laneBranchGitStub) return laneBranchGitStub; + if (gitArgs[0] === "rev-parse" && gitArgs[1] === "--path-format=absolute" && gitArgs[2] === "--show-toplevel") { + if (args.topLevel === null) return { exitCode: 128, stdout: "", stderr: "not a git repository" }; + return { exitCode: 0, stdout: `${args.topLevel ?? opts?.cwd ?? ""}\n`, stderr: "" }; + } + if (gitArgs[0] === "status") return { exitCode: 0, stdout: "", stderr: "" }; + if (gitArgs[0] === "worktree" && gitArgs[1] === "remove") { + const exitCode = args.removeExitCode ?? 0; + if (exitCode === 0) fs.rmSync(args.worktreePath, { recursive: true, force: true }); + return { exitCode, stdout: "", stderr: args.removeStderr ?? "" }; + } + if (gitArgs[0] === "worktree" && gitArgs[1] === "prune") return { exitCode: 0, stdout: "", stderr: "" }; + if (gitArgs[0] === "show-ref") return { exitCode: 1, stdout: "", stderr: "" }; + return { exitCode: 0, stdout: "", stderr: "" }; + }); + vi.mocked(runGitOrThrow).mockImplementation(async (gitArgs: string[]) => { + if (gitArgs[0] === "worktree" && gitArgs[1] === "list") return "" as any; + return { exitCode: 0, stdout: "", stderr: "" } as any; + }); + } + + it("really removes a worktree that lives outside .ade/worktrees", async () => { + const { db, service, worktreePath, events } = await setup({ + projectId: "proj-delete-external", + prefix: "ade-lane-delete-external-", + }); + try { + stubGitForDelete({ worktreePath }); + + await service.delete({ laneId: "lane-external", deleteBranch: false }); + + expect(fs.existsSync(worktreePath)).toBe(false); + expect(db.get("select id from lanes where id = ?", ["lane-external"])).toBeNull(); + const last = events[events.length - 1]; + expect(last.progress.overallStatus).toBe("completed"); + expect(last.progress.steps.find((step: any) => step.name === "git_worktree_remove")?.status).toBe("completed"); + } finally { + db.close(); + } + }); + + it("really removes the worktree of a legacy attached lane row", async () => { + const { db, service, worktreePath, events } = await setup({ + projectId: "proj-delete-attached", + prefix: "ade-lane-delete-attached-", + laneType: "attached", + }); + try { + stubGitForDelete({ worktreePath }); + + await service.delete({ laneId: "lane-external", deleteBranch: false }); + + expect(fs.existsSync(worktreePath)).toBe(false); + expect(db.get("select id from lanes where id = ?", ["lane-external"])).toBeNull(); + expect(events[events.length - 1].progress.steps.some((step: any) => step.name === "git_worktree_remove")).toBe(true); + } finally { + db.close(); + } + }); + + it("refuses to remove an external lane folder reached through a symlink", async () => { + const { db, service, repoRoot, worktreePath } = await setup({ + projectId: "proj-delete-symlink", + prefix: "ade-lane-delete-symlink-", + createDirectory: false, + }); + const realDirectory = path.resolve(repoRoot, "..", `${path.basename(repoRoot)}-real-external`); + try { + fs.mkdirSync(realDirectory, { recursive: true }); + fs.writeFileSync(path.join(realDirectory, "work.txt"), "user work\n", "utf8"); + fs.symlinkSync(realDirectory, worktreePath, "dir"); + stubGitForDelete({ worktreePath }); + + await expect(service.delete({ laneId: "lane-external", deleteBranch: false })).rejects.toThrow( + "ADE will not remove a lane folder through a symbolic link.", + ); + + expect(fs.existsSync(path.join(realDirectory, "work.txt"))).toBe(true); + expect(fs.existsSync(worktreePath)).toBe(true); + expect(vi.mocked(runGit).mock.calls.some(([gitArgs]) => gitArgs[0] === "worktree" && gitArgs[1] === "remove")).toBe(false); + expect(db.get("select id from lanes where id = ?", ["lane-external"])).toMatchObject({ id: "lane-external" }); + } finally { + db.close(); + fs.rmSync(realDirectory, { recursive: true, force: true }); + } + }); + + it("refuses to delete an external folder that is not a git worktree root", async () => { + const { db, service, worktreePath } = await setup({ + projectId: "proj-delete-unverified", + prefix: "ade-lane-delete-unverified-", + }); + try { + fs.writeFileSync(path.join(worktreePath, "work.txt"), "user work\n", "utf8"); + stubGitForDelete({ worktreePath, topLevel: null }); + + await expect(service.delete({ laneId: "lane-external", deleteBranch: false })).rejects.toThrow( + /no longer points at its Git worktree root/i, + ); + + expect(fs.existsSync(path.join(worktreePath, "work.txt"))).toBe(true); + expect(db.get("select id from lanes where id = ?", ["lane-external"])).toMatchObject({ id: "lane-external" }); + } finally { + db.close(); + fs.rmSync(worktreePath, { recursive: true, force: true }); + } + }); + + it("surfaces a failed git worktree remove instead of deleting an external folder itself", async () => { + const { db, service, worktreePath } = await setup({ + projectId: "proj-delete-git-failure", + prefix: "ade-lane-delete-git-failure-", + }); + try { + fs.writeFileSync(path.join(worktreePath, "work.txt"), "user work\n", "utf8"); + stubGitForDelete({ + worktreePath, + removeExitCode: 128, + removeStderr: "fatal: validation failed, cannot remove working tree", + }); + + await expect(service.delete({ laneId: "lane-external", deleteBranch: false })).rejects.toThrow( + /cannot remove working tree/i, + ); + + expect(fs.existsSync(path.join(worktreePath, "work.txt"))).toBe(true); + expect(db.get("select id from lanes where id = ?", ["lane-external"])).toMatchObject({ id: "lane-external" }); + expect( + db.get( + "select worktree_path from local_worktree_residual_cleanups where project_id = ? and worktree_path = ?", + ["proj-delete-git-failure", worktreePath], + ), + ).toBeNull(); + } finally { + db.close(); + fs.rmSync(worktreePath, { recursive: true, force: true }); + } + }); +}); + +// A lane row adopted from `git worktree list` holds the resolved spelling of its +// folder, while ADE is configured with whatever the user picked — routinely a +// symlinked parent. Every guard that decides "is this ADE's own worktree folder" +// has to accept both spellings, or ADE stops recognising the worktrees it made. +describe("laneService symlinked project root path space", () => { + const INSERT_LANE = ` + insert into lanes( + id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, + attached_root_path, is_edit_protected, parent_lane_id, color, icon, tags_json, status, created_at, archived_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `; + const NOW = "2026-08-01T12:00:00.000Z"; + + beforeEach(() => { + vi.mocked(getHeadSha).mockReset(); + vi.mocked(runGit).mockReset(); + vi.mocked(runGitOrThrow).mockReset(); + }); + + /** Project configured through `linkBase -> realBase`, exactly as a user's `~/Projects` symlink. */ + async function makeSymlinkedProject(projectId: string, prefix: string) { + const realBase = makeTempRepoRoot(`${prefix}real-`); + const linkHome = makeTempRepoRoot(`${prefix}link-`); + const linkBase = path.join(linkHome, "projects"); + fs.symlinkSync(realBase, linkBase, "dir"); + + const realRoot = path.join(realBase, "repo"); + const linkRoot = path.join(linkBase, "repo"); + fs.mkdirSync(path.join(realRoot, ".ade", "worktrees"), { recursive: true }); + + const db = await openKvDb(path.join(realRoot, "kv.sqlite"), createLogger()); + db.run( + "insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) values (?, ?, ?, ?, ?, ?)", + [projectId, linkRoot, "demo", "main", NOW, NOW], + ); + db.run(INSERT_LANE, ["lane-main", projectId, "Main", null, "primary", "main", "main", linkRoot, null, 1, null, null, null, null, "active", NOW, null]); + return { + db, + realBase, + linkHome, + realRoot, + linkRoot, + // What ADE is configured with, versus what git reports. + linkWorktreesDir: path.join(linkRoot, ".ade", "worktrees"), + realWorktreesDir: path.join(realRoot, ".ade", "worktrees"), + cleanup: () => { + db.close(); + fs.rmSync(linkHome, { recursive: true, force: true }); + fs.rmSync(realBase, { recursive: true, force: true }); + }, + }; + } + + it("recovers a managed worktree spelled the way git reports it, and still refuses one outside the folder", async () => { + const projectId = "proj-symlink-delete"; + const fixture = await makeSymlinkedProject(projectId, "ade-lane-symlink-delete-"); + const managedPath = path.join(fixture.realWorktreesDir, "managed-1234abcd"); + const externalPath = path.join(fixture.realBase, "external-checkout"); + try { + for (const dir of [managedPath, externalPath]) { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "work.txt"), "work\n", "utf8"); + } + fixture.db.run(INSERT_LANE, ["lane-managed", projectId, "Managed", null, "worktree", "main", "ade/managed", managedPath, null, 0, null, null, null, null, "active", NOW, null]); + fixture.db.run(INSERT_LANE, ["lane-external", projectId, "External", null, "worktree", "main", "feature/external", externalPath, null, 0, null, null, null, null, "active", NOW, null]); + // `git worktree remove` fails the way a half-deleted worktree makes it + // fail — the stale state the filesystem fallback exists for. + vi.mocked(runGit).mockImplementation(async (gitArgs: string[], opts?: { cwd?: string }) => { + const laneBranchGitStub = defaultLaneBranchGitStub(gitArgs); + if (laneBranchGitStub) return laneBranchGitStub; + if (gitArgs[0] === "rev-parse" && gitArgs[1] === "--path-format=absolute") { + // Real git resolves symlinks whichever spelling of the cwd it is handed. + let topLevel = String(opts?.cwd ?? ""); + try { + topLevel = fs.realpathSync(topLevel); + } catch { + // A cwd that no longer exists answers with itself. + } + const lines = gitArgs.slice(2).map((flag) => + (flag === "--show-toplevel" ? topLevel : path.join(fixture.realRoot, ".git"))); + return { exitCode: 0, stdout: `${lines.join("\n")}\n`, stderr: "" }; + } + if (gitArgs[0] === "worktree" && gitArgs[1] === "remove") { + return { exitCode: 128, stdout: "", stderr: "fatal: validation failed, cannot remove working tree" }; + } + if (gitArgs[0] === "show-ref") return { exitCode: 1, stdout: "", stderr: "" }; + return { exitCode: 0, stdout: "", stderr: "" }; + }); + vi.mocked(runGitOrThrow).mockImplementation(async (gitArgs: string[]) => { + if (gitArgs[0] === "worktree" && gitArgs[1] === "list") return "" as any; + return { exitCode: 0, stdout: "", stderr: "" } as any; + }); + + const service = createLaneService({ + db: fixture.db, + projectRoot: fixture.linkRoot, + projectId, + defaultBaseRef: "main", + worktreesDir: fixture.linkWorktreesDir, + }); + + await service.delete({ laneId: "lane-managed", deleteBranch: false }); + + // ADE's own folder, so the stale-state failure escalates to a real removal. + expect(fs.existsSync(managedPath)).toBe(false); + expect(fixture.db.get("select id from lanes where id = ?", ["lane-managed"])).toBeNull(); + + // A sibling of the project root is not ADE's to delete, under either spelling. + await expect(service.delete({ laneId: "lane-external", deleteBranch: false })).rejects.toThrow( + /cannot remove working tree/i, + ); + expect(fs.existsSync(path.join(externalPath, "work.txt"))).toBe(true); + expect(fixture.db.get("select id from lanes where id = ?", ["lane-external"])).toMatchObject({ id: "lane-external" }); + expect( + fixture.db.get( + "select worktree_path from local_worktree_residual_cleanups where project_id = ? and worktree_path = ?", + [projectId, externalPath], + ), + ).toBeNull(); + } finally { + fixture.cleanup(); + } + }); + + // The residual sweep walks the configured spelling of `.ade/worktrees` while + // the "leave this alone" sets are built from git and from lane rows, which + // hold the resolved one. A live worktree that reads as unclaimed is one empty + // moment away from being swept as an unknown residual. + it("does not sweep a live worktree whose lane row spells it the way git does", async () => { + const projectId = "proj-symlink-sweep"; + const fixture = await makeSymlinkedProject(projectId, "ade-lane-symlink-sweep-"); + const savedPath = path.join(fixture.realWorktreesDir, "live-1234abcd"); + try { + // Empty and old enough to look like an abandoned residual to the sweep. + fs.mkdirSync(savedPath, { recursive: true }); + const longAgo = new Date(Date.now() - 60 * 60_000); + fs.utimesSync(savedPath, longAgo, longAgo); + fixture.db.run(INSERT_LANE, ["lane-live", projectId, "Live", null, "worktree", "main", "ade/live", savedPath, null, 0, null, null, null, null, "active", NOW, null]); + + vi.mocked(runGit).mockImplementation(async (gitArgs: string[], opts?: { cwd?: string }) => { + const laneBranchGitStub = defaultLaneBranchGitStub(gitArgs); + if (laneBranchGitStub) return laneBranchGitStub; + if (gitArgs[0] === "rev-parse" && gitArgs[1] === "--path-format=absolute") { + let topLevel = String(opts?.cwd ?? ""); + try { + topLevel = fs.realpathSync(topLevel); + } catch { + // A cwd that no longer exists answers with itself. + } + const lines = gitArgs.slice(2).map((flag) => + (flag === "--show-toplevel" ? topLevel : path.join(fixture.realRoot, ".git"))); + return { exitCode: 0, stdout: `${lines.join("\n")}\n`, stderr: "" }; + } + return { exitCode: 0, stdout: "", stderr: "" }; + }); + vi.mocked(runGitOrThrow).mockImplementation(async (gitArgs: string[]) => { + // Registered by the lane row alone, so the row's spelling is the only + // thing standing between this folder and the sweep. + if (gitArgs[0] === "worktree" && gitArgs[1] === "list") { + return `worktree ${fixture.realRoot}\nHEAD 1111111\nbranch refs/heads/main\n\n` as any; + } + return { exitCode: 0, stdout: "", stderr: "" } as any; + }); + + const service = createLaneService({ + db: fixture.db, + projectRoot: fixture.linkRoot, + projectId, + defaultBaseRef: "main", + worktreesDir: fixture.linkWorktreesDir, + }); + + await service.list({ includeStatus: false }); + + expect(fs.existsSync(savedPath)).toBe(true); + expect(fixture.db.get("select id from lanes where id = ?", ["lane-live"])).toMatchObject({ id: "lane-live" }); + } finally { + fixture.cleanup(); + } + }); + + it("restores an archived lane at its saved path instead of relocating it to a fresh one", async () => { + const projectId = "proj-symlink-restore"; + const fixture = await makeSymlinkedProject(projectId, "ade-lane-symlink-restore-"); + // Adopted from git, so the row holds the resolved spelling; the folder is + // gone, which is the only case that can relocate the worktree. + const savedPath = path.join(fixture.realWorktreesDir, "archived-1234abcd"); + const addedPaths: string[] = []; + try { + fixture.db.run(INSERT_LANE, ["lane-archived", projectId, "Archived", null, "worktree", "main", "ade/archived", savedPath, null, 0, null, null, null, null, "archived", NOW, NOW]); + + vi.mocked(runGit).mockImplementation(async (gitArgs: string[]) => { + const laneBranchGitStub = defaultLaneBranchGitStub(gitArgs); + if (laneBranchGitStub) return laneBranchGitStub; + if (gitArgs[0] === "rev-parse" && gitArgs[1] === "--path-format=absolute") { + const lines = gitArgs.slice(2).map((flag) => + flag === "--show-toplevel" ? fixture.realRoot : path.join(fixture.realRoot, ".git")); + return { exitCode: 0, stdout: `${lines.join("\n")}\n`, stderr: "" }; + } + return { exitCode: 0, stdout: "", stderr: "" }; + }); + vi.mocked(runGitOrThrow).mockImplementation(async (gitArgs: string[]) => { + if (gitArgs[0] === "worktree" && gitArgs[1] === "add") { + // `ade/*` branches only exist on origin in these fixtures, so restore + // takes the `worktree add -b origin/` form. + const target = (gitArgs[2] === "-b" ? gitArgs[4] : gitArgs[2]) ?? ""; + addedPaths.push(target); + fs.mkdirSync(target, { recursive: true }); + return { exitCode: 0, stdout: "", stderr: "" } as any; + } + if (gitArgs[0] === "worktree" && gitArgs[1] === "list") { + return `worktree ${fixture.realRoot}\nHEAD 1111111\nbranch refs/heads/main\n\n` as any; + } + return { exitCode: 0, stdout: "", stderr: "" } as any; + }); + + const service = createLaneService({ + db: fixture.db, + projectRoot: fixture.linkRoot, + projectId, + defaultBaseRef: "main", + worktreesDir: fixture.linkWorktreesDir, + }); + + const result = await service.unarchive({ laneId: "lane-archived" }); + + expect(result.worktreeRecreated).toBe(true); + expect(addedPaths).toEqual([savedPath]); + expect(fixture.db.get<{ worktree_path: string }>( + "select worktree_path from lanes where id = ?", + ["lane-archived"], + )?.worktree_path).toBe(savedPath); + } finally { + fixture.cleanup(); + } + }); +}); + describe("laneService importBranch", () => { beforeEach(() => { vi.mocked(getHeadSha).mockReset(); @@ -1507,7 +2411,7 @@ describe("laneService importBranch", () => { }); it("surfaces unregistered managed .ade worktrees during lane list", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-list-managed-orphan-")); + const repoRoot = makeTempRepoRoot("ade-lane-service-list-managed-orphan-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); await seedProjectAndStack(db, { projectId: "proj-list-managed-orphan", repoRoot }); const worktreesDir = path.join(repoRoot, ".ade", "worktrees"); @@ -1555,7 +2459,7 @@ describe("laneService importBranch", () => { }); it("imports a branch from an explicit non-origin remote", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-import-upstream-")); + const repoRoot = makeTempRepoRoot("ade-lane-service-import-upstream-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); await seedProjectAndStack(db, { projectId: "proj-import-upstream", repoRoot }); @@ -1625,7 +2529,7 @@ describe("laneService importBranch", () => { }); it("rejects duplicate imported branches before creating a local tracking branch", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-import-duplicate-")); + const repoRoot = makeTempRepoRoot("ade-lane-service-import-duplicate-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); await seedProjectAndStack(db, { projectId: "proj-import-duplicate", repoRoot }); const now = "2026-03-11T12:05:00.000Z"; @@ -1672,7 +2576,7 @@ describe("laneService importBranch", () => { }); it("restores a managed orphan worktree before rejecting an import duplicate", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-import-managed-orphan-")); + const repoRoot = makeTempRepoRoot("ade-lane-service-import-managed-orphan-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); await seedProjectAndStack(db, { projectId: "proj-import-managed-orphan", repoRoot }); const worktreesDir = path.join(repoRoot, ".ade", "worktrees"); @@ -1682,6 +2586,10 @@ describe("laneService importBranch", () => { if (args[0] === "show-ref" && args[1] === "--verify" && args[3] === "refs/heads/ade/dashboard-f6949524") { return { exitCode: 0, stdout: "", stderr: "" }; } + // Ownership probe: this project root is the repository's own checkout. + if (args[0] === "rev-parse" && args[1] === "--path-format=absolute" && args[2] === "--git-common-dir") { + return { exitCode: 0, stdout: `${path.join(repoRoot, ".git")}\n`, stderr: "" }; + } throw new Error(`Unexpected git call: ${args.join(" ")}`); }); vi.mocked(runGitOrThrow).mockImplementation(async (args: string[]) => { @@ -1724,8 +2632,8 @@ describe("laneService importBranch", () => { expect(vi.mocked(runGitOrThrow).mock.calls.some(([args]) => args[0] === "worktree" && args[1] === "add")).toBe(false); }); - it("reports an actionable error when a branch is already checked out outside ADE lanes", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-import-external-orphan-")); + it("adopts a worktree checked out outside .ade/worktrees instead of importing its branch again", async () => { + const repoRoot = makeTempRepoRoot("ade-lane-service-import-external-orphan-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); await seedProjectAndStack(db, { projectId: "proj-import-external-orphan", repoRoot }); const externalPath = path.join(repoRoot, "..", "feature-taken"); @@ -1743,6 +2651,11 @@ describe("laneService importBranch", () => { if (args[0] === "show-ref" && args[1] === "--verify" && args[3] === "refs/heads/feature/taken") { return { exitCode: 0, stdout: "", stderr: "" }; } + // Ownership probe: this project root is the repository's own checkout, so + // adoption reaches worktrees outside `.ade/worktrees` such as this one. + if (args[0] === "rev-parse" && args[1] === "--path-format=absolute" && args[2] === "--git-common-dir") { + return { exitCode: 0, stdout: `${path.join(repoRoot, ".git")}\n`, stderr: "" }; + } throw new Error(`Unexpected git call: ${args.join(" ")}`); }); vi.mocked(runGitOrThrow).mockImplementation(async (args: string[]) => { @@ -1770,13 +2683,24 @@ describe("laneService importBranch", () => { }); await expect(service.importBranch({ branchRef: "origin/feature/taken" })).rejects.toThrow( - `Branch 'feature/taken' is already checked out at '${path.resolve(externalPath)}'`, + "Lane already exists for branch 'feature/taken'", ); + expect( + db.get<{ branch_ref: string; worktree_path: string; lane_type: string; status: string }>( + "select branch_ref, worktree_path, lane_type, status from lanes where project_id = ? and branch_ref = ?", + ["proj-import-external-orphan", "feature/taken"], + ), + ).toMatchObject({ + branch_ref: "feature/taken", + worktree_path: path.resolve(externalPath), + lane_type: "worktree", + status: "active", + }); expect(vi.mocked(runGitOrThrow).mock.calls.some(([args]) => args[0] === "worktree" && args[1] === "add")).toBe(false); }); it("reuses an existing local branch when importing a remote-qualified ref", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-import-local-existing-")); + const repoRoot = makeTempRepoRoot("ade-lane-service-import-local-existing-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); await seedProjectAndStack(db, { projectId: "proj-import-local-existing", repoRoot }); @@ -1836,7 +2760,7 @@ describe("laneService importBranch", () => { }); it("absorbs raced recovered rows while importing a branch", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-import-race-")); + const repoRoot = makeTempRepoRoot("ade-lane-service-import-race-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); const projectId = "proj-import-race"; const racedLaneId = "raced-import-row"; @@ -1917,7 +2841,7 @@ describe("laneService importBranch", () => { }); it("removes a created tracking branch when worktree setup fails during import", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-import-cleanup-")); + const repoRoot = makeTempRepoRoot("ade-lane-service-import-cleanup-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); await seedProjectAndStack(db, { projectId: "proj-import-cleanup", repoRoot }); @@ -1977,7 +2901,7 @@ describe("laneService rebaseStart", () => { }); it("skips rebasing when the parent head is already an ancestor of the lane head", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-skip-")); + const repoRoot = makeTempRepoRoot("ade-lane-service-skip-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); await seedProjectAndStack(db, { projectId: "proj-skip", repoRoot }); const logs: string[] = []; @@ -2019,7 +2943,7 @@ describe("laneService rebaseStart", () => { }); it("rebases an unparented lane against its stored base branch", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-root-base-")); + const repoRoot = makeTempRepoRoot("ade-lane-service-root-base-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); const now = "2026-03-11T12:00:00.000Z"; db.run( @@ -2088,7 +3012,7 @@ describe("laneService rebaseStart", () => { }); it("persists and restores the overridden base branch for PR-target rebases", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-root-override-")); + const repoRoot = makeTempRepoRoot("ade-lane-service-root-override-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); const now = "2026-03-11T12:00:00.000Z"; db.run( @@ -2164,7 +3088,7 @@ describe("laneService rebaseStart", () => { }); it("rejects overlapping rebase runs for the same stack while one is active", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-overlap-")); + const repoRoot = makeTempRepoRoot("ade-lane-service-overlap-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); await seedProjectAndStack(db, { projectId: "proj-overlap", repoRoot }); @@ -2219,7 +3143,7 @@ describe("laneService rebaseStart", () => { }); it("rebases an unparented lane onto an override branch and persists the new base ref", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-root-override-")); + const repoRoot = makeTempRepoRoot("ade-lane-service-root-override-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); await seedProjectAndStack(db, { projectId: "proj-root-override", repoRoot }); const now = "2026-03-11T12:00:00.000Z"; @@ -2291,7 +3215,7 @@ describe("laneService rebaseStart", () => { }); it("rebases against the primary lane remote tracking ref when it is available", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-primary-remote-")); + const repoRoot = makeTempRepoRoot("ade-lane-service-primary-remote-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); await seedProjectAndStack(db, { projectId: "proj-primary-remote", repoRoot }); @@ -2346,7 +3270,7 @@ describe("laneService rebaseStart", () => { }); it("falls back to origin/ for a detached root lane", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-origin-fallback-")); + const repoRoot = makeTempRepoRoot("ade-lane-service-origin-fallback-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); await seedProjectAndStack(db, { projectId: "proj-origin-fallback", repoRoot }); const logs: string[] = []; @@ -2408,7 +3332,7 @@ describe("laneService rebaseStart", () => { }); it("fails when neither origin/ nor the local base branch can be resolved for a detached root lane", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-all-remote-fail-")); + const repoRoot = makeTempRepoRoot("ade-lane-service-all-remote-fail-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); await seedProjectAndStack(db, { projectId: "proj-all-remote-fail", repoRoot }); const logs: string[] = []; @@ -2457,7 +3381,7 @@ describe("laneService rebaseStart", () => { }); it("uses parent HEAD directly for non-primary (worktree) parent without remote resolution", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-worktree-parent-")); + const repoRoot = makeTempRepoRoot("ade-lane-service-worktree-parent-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); await seedProjectAndStack(db, { projectId: "proj-worktree-parent", repoRoot }); const logs: string[] = []; @@ -2516,7 +3440,7 @@ describe("laneService rebaseStart", () => { }); it("fails the rebase run when the detached root lane base branch cannot be resolved", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-unresolvable-")); + const repoRoot = makeTempRepoRoot("ade-lane-service-unresolvable-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); await seedProjectAndStack(db, { projectId: "proj-unresolvable", repoRoot }); @@ -2553,7 +3477,7 @@ describe("laneService rebaseStart", () => { }); it("includes the resolved base-branch label in skip logs for detached root lanes", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-skip-label-")); + const repoRoot = makeTempRepoRoot("ade-lane-service-skip-label-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); await seedProjectAndStack(db, { projectId: "proj-skip-label", repoRoot }); const logs: string[] = []; @@ -2605,7 +3529,7 @@ describe("laneService rebaseStart", () => { }); it("fails the rebase run when the worktree has uncommitted changes", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-dirty-")); + const repoRoot = makeTempRepoRoot("ade-lane-service-dirty-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); await seedProjectAndStack(db, { projectId: "proj-dirty", repoRoot }); const logs: string[] = []; @@ -2649,7 +3573,7 @@ describe("laneService rebaseStart", () => { }); it("uses deduplicated candidate refs when upstream equals origin/", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-dedup-")); + const repoRoot = makeTempRepoRoot("ade-lane-service-dedup-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); await seedProjectAndStack(db, { projectId: "proj-dedup", repoRoot }); @@ -2708,7 +3632,7 @@ describe("laneService reparent", () => { }); it("uses the primary lane's remote tracking ref when reparenting under primary", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-reparent-primary-")); + const repoRoot = makeTempRepoRoot("ade-lane-service-reparent-primary-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); await seedProjectAndStack(db, { projectId: "proj-reparent-primary", repoRoot }); @@ -2773,7 +3697,7 @@ describe("laneService reparent", () => { }); it("reparents onto stackBaseBranchRef when provided", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-reparent-stack-base-")); + const repoRoot = makeTempRepoRoot("ade-lane-service-reparent-stack-base-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); await seedProjectAndStack(db, { projectId: "proj-reparent-stack-base", repoRoot }); @@ -2832,7 +3756,7 @@ describe("laneService reparent", () => { }); it("restores the active branch profile when reparent rebase fails", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-reparent-rollback-")); + const repoRoot = makeTempRepoRoot("ade-lane-service-reparent-rollback-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); await seedProjectAndStack(db, { projectId: "proj-reparent-rollback", repoRoot }); @@ -2886,7 +3810,7 @@ describe("laneService reparent", () => { }); it("skips git rebase when parent and base ref are unchanged", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-reparent-noop-")); + const repoRoot = makeTempRepoRoot("ade-lane-service-reparent-noop-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); await seedProjectAndStack(db, { projectId: "proj-reparent-noop", repoRoot }); @@ -2920,7 +3844,7 @@ describe("laneService createChild", () => { }); it("createChild links existing app dependency installs into the worktree", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-child-deps-")); + const repoRoot = makeTempRepoRoot("ade-lane-service-child-deps-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); await seedProjectAndStack(db, { projectId: "proj-child-deps", repoRoot }); fs.mkdirSync(path.join(repoRoot, "apps", "desktop", "node_modules"), { recursive: true }); @@ -2970,7 +3894,7 @@ describe("laneService createChild", () => { }); it("createChild from primary anchors the new lane to the project default base", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-child-primary-default-base-")); + const repoRoot = makeTempRepoRoot("ade-lane-service-child-primary-default-base-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); const now = "2026-03-11T12:00:00.000Z"; @@ -3047,7 +3971,7 @@ describe("laneService createChild", () => { }); it("createChild with baseBranchRef tracks remote-only branch and bases lane on it", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-child-base-override-")); + const repoRoot = makeTempRepoRoot("ade-lane-service-child-base-override-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); await seedProjectAndStack(db, { projectId: "proj-child-base-override", repoRoot }); @@ -3132,7 +4056,7 @@ describe("laneService updateAppearance color uniqueness", () => { }); async function setup() { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-color-uniqueness-")); + const repoRoot = makeTempRepoRoot("ade-lane-color-uniqueness-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); await seedProjectAndStack(db, { projectId: "proj-color-uniqueness", repoRoot }); const service = createLaneService({ @@ -3234,7 +4158,7 @@ describe("laneService stale worktree status", () => { }); it("does not report main checkout status for a stale lane directory inside the repo", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-stale-status-")); + const repoRoot = makeTempRepoRoot("ade-lane-stale-status-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); await seedProjectAndStack(db, { projectId: "proj-stale-status", repoRoot }); const childPath = path.join(repoRoot, "child"); @@ -3318,7 +4242,7 @@ describe("laneService delete teardown + cancellation + streaming", () => { } async function setupWithLane(opts: { teardown: ReturnType; events: any[]; createWorktree?: boolean }) { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-delete-")); + const repoRoot = makeTempRepoRoot("ade-lane-delete-"); const worktreesDir = path.join(repoRoot, "worktrees"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); const projectId = "proj-delete"; @@ -4288,7 +5212,7 @@ describe("laneService - branchSwitch", () => { }); it("ensures and returns a profile for the lane's current branch_ref", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-list-profile-")); + const repoRoot = makeTempRepoRoot("ade-bsw-list-profile-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); try { seedBswProject(db, { projectId: "proj-1", repoRoot }); @@ -4314,7 +5238,7 @@ describe("laneService - branchSwitch", () => { }); it("throws when the lane is missing", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-list-missing-")); + const repoRoot = makeTempRepoRoot("ade-bsw-list-missing-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); try { seedBswProject(db, { projectId: "proj-1", repoRoot }); @@ -4335,7 +5259,7 @@ describe("laneService - branchSwitch", () => { }); it("updates the lane's branch_ref AND upserts a matching branch profile", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-update-bref-")); + const repoRoot = makeTempRepoRoot("ade-bsw-update-bref-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); try { seedBswProject(db, { projectId: "proj-1", repoRoot }); @@ -4373,7 +5297,7 @@ describe("laneService - branchSwitch", () => { }); it("rejects when laneId is empty", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-prev-laneid-")); + const repoRoot = makeTempRepoRoot("ade-bsw-prev-laneid-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); try { seedBswProject(db, { projectId: "proj-1", repoRoot }); @@ -4386,7 +5310,7 @@ describe("laneService - branchSwitch", () => { }); it("rejects when branchName is empty", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-prev-branchname-")); + const repoRoot = makeTempRepoRoot("ade-bsw-prev-branchname-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); try { seedBswProject(db, { projectId: "proj-1", repoRoot }); @@ -4400,7 +5324,7 @@ describe("laneService - branchSwitch", () => { }); it("rejects when the lane is archived", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-prev-archived-")); + const repoRoot = makeTempRepoRoot("ade-bsw-prev-archived-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); try { seedBswProject(db, { projectId: "proj-1", repoRoot }); @@ -4417,7 +5341,7 @@ describe("laneService - branchSwitch", () => { }); it("flags dirty worktree, duplicate owner, and active terminal sessions", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-prev-flags-")); + const repoRoot = makeTempRepoRoot("ade-bsw-prev-flags-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); try { seedBswProject(db, { projectId: "proj-1", repoRoot }); @@ -4458,7 +5382,7 @@ describe("laneService - branchSwitch", () => { }); it("strips remote prefix when only the remote ref exists", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-prev-remote-")); + const repoRoot = makeTempRepoRoot("ade-bsw-prev-remote-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); try { seedBswProject(db, { projectId: "proj-1", repoRoot }); @@ -4484,7 +5408,7 @@ describe("laneService - branchSwitch", () => { }); it("returns mode=create without consulting refs when explicitly requested", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-prev-create-")); + const repoRoot = makeTempRepoRoot("ade-bsw-prev-create-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); try { seedBswProject(db, { projectId: "proj-1", repoRoot }); @@ -4517,7 +5441,7 @@ describe("laneService - branchSwitch", () => { }); it("refuses to switch when the lane has uncommitted changes", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-switch-dirty-")); + const repoRoot = makeTempRepoRoot("ade-bsw-switch-dirty-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); try { seedBswProject(db, { projectId: "proj-1", repoRoot }); @@ -4541,7 +5465,7 @@ describe("laneService - branchSwitch", () => { }); it("refuses to switch to a branch that is already active in another lane", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-switch-dup-")); + const repoRoot = makeTempRepoRoot("ade-bsw-switch-dup-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); try { seedBswProject(db, { projectId: "proj-1", repoRoot }); @@ -4563,7 +5487,7 @@ describe("laneService - branchSwitch", () => { }); it("refuses to switch when active work exists and acknowledgeActiveWork is not set", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-switch-active-")); + const repoRoot = makeTempRepoRoot("ade-bsw-switch-active-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); try { seedBswProject(db, { projectId: "proj-1", repoRoot }); @@ -4590,7 +5514,7 @@ describe("laneService - branchSwitch", () => { }); it("checks out an existing local branch and updates the lane row + branch profile", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-switch-existing-")); + const repoRoot = makeTempRepoRoot("ade-bsw-switch-existing-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); try { seedBswProject(db, { projectId: "proj-1", repoRoot }); @@ -4630,7 +5554,7 @@ describe("laneService - branchSwitch", () => { }); it("creates a new branch via 'checkout -b' when mode='create'", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-switch-create-")); + const repoRoot = makeTempRepoRoot("ade-bsw-switch-create-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); try { seedBswProject(db, { projectId: "proj-1", repoRoot }); @@ -4678,7 +5602,7 @@ describe("laneService - branchSwitch", () => { }); it("rejects mode='create' when baseRef is missing", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-switch-create-no-base-")); + const repoRoot = makeTempRepoRoot("ade-bsw-switch-create-no-base-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); try { seedBswProject(db, { projectId: "proj-1", repoRoot }); @@ -4696,7 +5620,7 @@ describe("laneService - branchSwitch", () => { }); it("rejects mode='create' when the target branch already exists locally", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-switch-create-exists-")); + const repoRoot = makeTempRepoRoot("ade-bsw-switch-create-exists-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); try { seedBswProject(db, { projectId: "proj-1", repoRoot }); @@ -4726,7 +5650,7 @@ describe("laneService - branchSwitch", () => { }); it("preserves PR rows whose head_branch matches the new branch and deletes stale ones", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-switch-pr-detach-")); + const repoRoot = makeTempRepoRoot("ade-bsw-switch-pr-detach-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); try { seedBswProject(db, { projectId: "proj-1", repoRoot }); @@ -4810,7 +5734,7 @@ describe("laneService session-scoped Linear issue links", () => { } it("persists and lists Linear issues attached to a standalone session with no lane", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-session-standalone-")); + const repoRoot = makeTempRepoRoot("ade-lane-service-session-standalone-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); try { await seedProjectAndStack(db, { projectId: "proj-session-standalone", repoRoot }); @@ -4849,7 +5773,7 @@ describe("laneService session-scoped Linear issue links", () => { }); it("attaches multiple issues in one batch call", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-session-batch-")); + const repoRoot = makeTempRepoRoot("ade-lane-service-session-batch-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); try { await seedProjectAndStack(db, { projectId: "proj-session-batch", repoRoot }); @@ -4880,7 +5804,7 @@ describe("laneService session-scoped Linear issue links", () => { }); it("mirrors a session attach into the lane link table when the session has a lane", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-session-laned-")); + const repoRoot = makeTempRepoRoot("ade-lane-service-session-laned-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); try { await seedProjectAndStack(db, { projectId: "proj-session-laned", repoRoot }); @@ -4945,7 +5869,7 @@ describe("laneService session-scoped Linear issue links", () => { }); it("detaches an issue from both the session and the mirrored lane link", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-session-detach-")); + const repoRoot = makeTempRepoRoot("ade-lane-service-session-detach-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); try { await seedProjectAndStack(db, { projectId: "proj-session-detach", repoRoot }); @@ -4980,7 +5904,7 @@ describe("laneService session-scoped Linear issue links", () => { }); it("detaches all issues from a session when issueId is omitted", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-session-detach-all-")); + const repoRoot = makeTempRepoRoot("ade-lane-service-session-detach-all-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); try { await seedProjectAndStack(db, { projectId: "proj-session-detach-all", repoRoot }); @@ -5011,7 +5935,7 @@ describe("laneService session-scoped Linear issue links", () => { }); it("re-attaching the same issue+role replaces rather than duplicates", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-session-dedupe-")); + const repoRoot = makeTempRepoRoot("ade-lane-service-session-dedupe-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); try { await seedProjectAndStack(db, { projectId: "proj-session-dedupe", repoRoot }); @@ -5040,7 +5964,7 @@ describe("laneService session-scoped Linear issue links", () => { }); it("skips issues missing required fields without persisting them", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-session-invalid-")); + const repoRoot = makeTempRepoRoot("ade-lane-service-session-invalid-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); try { await seedProjectAndStack(db, { projectId: "proj-session-invalid", repoRoot }); @@ -5079,7 +6003,7 @@ describe("laneService unlinkLinearIssues", () => { } it("removes a specific non-primary lane link, leaving the primary intact", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-unlink-one-")); + const repoRoot = makeTempRepoRoot("ade-lane-service-unlink-one-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); try { await seedProjectAndStack(db, { projectId: "proj-unlink-one", repoRoot }); @@ -5119,7 +6043,7 @@ describe("laneService unlinkLinearIssues", () => { }); it("removes all non-primary links when issueId is omitted but never the primary", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-unlink-all-")); + const repoRoot = makeTempRepoRoot("ade-lane-service-unlink-all-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); try { await seedProjectAndStack(db, { projectId: "proj-unlink-all", repoRoot }); @@ -5169,7 +6093,7 @@ describe("laneService createWorktreeLane orphan cleanup", () => { }); it("removes the created worktree and branch when the lane-row insert fails", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-create-cleanup-")); + const repoRoot = makeTempRepoRoot("ade-lane-service-create-cleanup-"); const realDb = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); const now = "2026-05-12T20:00:00.000Z"; realDb.run( @@ -5251,94 +6175,6 @@ describe("laneService createWorktreeLane orphan cleanup", () => { }); }); -describe("laneService attach", () => { - beforeEach(() => { - vi.mocked(getHeadSha).mockReset(); - vi.mocked(runGit).mockReset(); - vi.mocked(runGitOrThrow).mockReset(); - }); - - it("emits a lane-created lifecycle event after attaching a worktree", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-attach-event-")); - const attachedPath = path.join(repoRoot, "attached"); - fs.mkdirSync(attachedPath); - const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); - try { - const now = "2026-07-13T12:00:00.000Z"; - db.run( - "insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) values (?, ?, ?, ?, ?, ?)", - ["proj-attach", repoRoot, "demo", "main", now, now], - ); - const commonDir = path.join(repoRoot, ".git"); - vi.mocked(runGitOrThrow).mockImplementation(async (args: string[], opts?: { cwd?: string }) => { - if (args.includes("--show-toplevel")) return path.resolve(opts?.cwd ?? attachedPath); - if (args.includes("--git-common-dir")) return commonDir; - throw new Error(`Unexpected git call: ${args.join(" ")}`); - }); - vi.mocked(runGit).mockImplementation(async (args: string[]) => { - const laneBranchGitStub = defaultLaneBranchGitStub(args); - if (laneBranchGitStub) return laneBranchGitStub; - if (args.includes("--show-toplevel")) { - return { exitCode: 0, stdout: `${attachedPath}\n`, stderr: "" }; - } - if (args[0] === "rev-parse" && args[1] === "--abbrev-ref" && args[2] === "HEAD") { - return { exitCode: 0, stdout: "feat-attached\n", stderr: "" }; - } - if (args.includes("@{upstream}")) { - return { exitCode: 1, stdout: "", stderr: "no upstream" }; - } - if (args[0] === "status") return { exitCode: 0, stdout: "", stderr: "" }; - if (args[0] === "rev-list") return { exitCode: 0, stdout: "0\t0\n", stderr: "" }; - if (args.includes("--git-dir")) { - return { - exitCode: 0, - stdout: `${path.join(commonDir, "worktrees", "attached")}\n`, - stderr: "", - }; - } - if (args[0] === "push") return { exitCode: 0, stdout: "", stderr: "" }; - throw new Error(`Unexpected git call: ${args.join(" ")}`); - }); - - const onLifecycleEvent = vi.fn(); - const service = createLaneService({ - db, - projectRoot: repoRoot, - projectId: "proj-attach", - defaultBaseRef: "main", - worktreesDir: path.join(repoRoot, "worktrees"), - onLifecycleEvent, - logger: createLogger(), - }); - - const summary = await service.attach({ - name: "Attached feature", - attachedPath, - }); - - expect(onLifecycleEvent).toHaveBeenCalledTimes(1); - expect(onLifecycleEvent).toHaveBeenCalledWith({ - type: "lane-created", - laneId: summary.id, - laneName: summary.name, - color: summary.color, - lane: summary, - }); - await expect(service.attach({ - name: "Attached feature again", - attachedPath, - })).rejects.toMatchObject({ - code: "lane_already_linked", - message: - "lane_already_linked: This worktree is already linked as lane 'Attached feature'.", - }); - } finally { - db.close(); - fs.rmSync(repoRoot, { recursive: true, force: true }); - } - }); -}); - describe("laneService rename", () => { const RENAME_NOW = "2026-06-29T12:00:00.000Z"; @@ -5390,7 +6226,7 @@ describe("laneService rename", () => { } it("updates the lane display name", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-rename-")); + const repoRoot = makeTempRepoRoot("ade-lane-rename-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); try { seedRenameProject(db, { projectId: "proj-rename", repoRoot }); @@ -5435,7 +6271,7 @@ describe("laneService rename", () => { }); it("does not emit lifecycle events when archive and unarchive do not change status", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-archive-noop-")); + const repoRoot = makeTempRepoRoot("ade-lane-archive-noop-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); try { seedRenameProject(db, { projectId: "proj-archive-noop", repoRoot }); @@ -5482,7 +6318,7 @@ describe("laneService rename", () => { }); it("rejects duplicate lane names case-insensitively", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-rename-dup-")); + const repoRoot = makeTempRepoRoot("ade-lane-rename-dup-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); try { seedRenameProject(db, { projectId: "proj-rename-dup", repoRoot }); @@ -5521,7 +6357,7 @@ describe("laneService rename", () => { }); it("rejects renaming the primary lane", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-rename-primary-")); + const repoRoot = makeTempRepoRoot("ade-lane-rename-primary-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); try { seedRenameProject(db, { projectId: "proj-rename-primary", repoRoot }); @@ -5601,7 +6437,7 @@ describe("laneService branch drift", () => { } it("surfaces branchDrift on the lane summary when HEAD wandered off branch_ref", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-drift-detect-")); + const repoRoot = makeTempRepoRoot("ade-lane-drift-detect-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); await seedProjectAndStack(db, { projectId: "proj-drift-detect", repoRoot }); const childPath = path.join(repoRoot, "child"); @@ -5631,7 +6467,7 @@ describe("laneService branch drift", () => { }); it("switch-back refuses on a dirty worktree and leaves branch_ref untouched", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-drift-dirty-")); + const repoRoot = makeTempRepoRoot("ade-lane-drift-dirty-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); await seedProjectAndStack(db, { projectId: "proj-drift-dirty", repoRoot }); const childPath = path.join(repoRoot, "child"); @@ -5659,7 +6495,7 @@ describe("laneService branch drift", () => { }); it("switch-back checks the recorded branch back out on a clean worktree", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-drift-switchback-")); + const repoRoot = makeTempRepoRoot("ade-lane-drift-switchback-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); await seedProjectAndStack(db, { projectId: "proj-drift-switchback", repoRoot }); const childPath = path.join(repoRoot, "child"); @@ -5691,7 +6527,7 @@ describe("laneService branch drift", () => { }); it("keep-head re-points branch_ref and the branch-derived lane name in one write", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-drift-keep-")); + const repoRoot = makeTempRepoRoot("ade-lane-drift-keep-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); await seedProjectAndStack(db, { projectId: "proj-drift-keep", repoRoot }); // The lane name is literally advertising the branch it tracks. @@ -5732,7 +6568,7 @@ describe("laneService branch drift", () => { }); it("keep-head preserves a hand-written lane name", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-drift-keep-name-")); + const repoRoot = makeTempRepoRoot("ade-lane-drift-keep-name-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); await seedProjectAndStack(db, { projectId: "proj-drift-keep-name", repoRoot }); // A hand-written name — it advertises no branch, so it must survive. @@ -5758,7 +6594,7 @@ describe("laneService branch drift", () => { }); it("rejects a resolution whose expected HEAD no longer matches the worktree", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-drift-stale-")); + const repoRoot = makeTempRepoRoot("ade-lane-drift-stale-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); await seedProjectAndStack(db, { projectId: "proj-drift-stale", repoRoot }); const childPath = path.join(repoRoot, "child"); @@ -5785,7 +6621,7 @@ describe("laneService branch drift", () => { }); it("refuses when the lane is already on its recorded branch", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-drift-none-")); + const repoRoot = makeTempRepoRoot("ade-lane-drift-none-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); await seedProjectAndStack(db, { projectId: "proj-drift-none", repoRoot }); const childPath = path.join(repoRoot, "child"); diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index 6d895c492..77a5474ee 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -8,6 +8,7 @@ import { detachPullRequestRowsByIds, detachPullRequestRowsForLane } from "../prs import { isWithinDir, normalizeBranchName, resolvePathWithinRoot } from "../shared/utils"; import { fetchRemoteTrackingBranch } from "../shared/remoteTrackingBranch"; import { detectConflictKind } from "../git/gitConflictState"; +import { invalidateProjectPathInspectionCache } from "../projects/projectPathInspector"; import { shouldLaneTrackParent } from "../../../shared/laneBaseResolution"; import { PRIMARY_LANE_COLOR, allocateLaneColor } from "../../../shared/laneColorPalette"; import { linearIssueBranchName, sanitizeLinearIssueBranchName } from "../../../shared/linearIssueBranch"; @@ -22,10 +23,8 @@ import type { Logger } from "../logging/logger"; import { createWorktreeResidualCleanup } from "./worktreeResidualCleanup"; import { createLaneWorktreeLockService } from "./laneWorktreeLockService"; import type { - AdoptAttachedLaneArgs, ArchiveAndReclaimLaneArgs, ArchiveAndReclaimLaneResult, - AttachLaneArgs, CreateChildLaneArgs, CreateLaneArgs, CreateLaneFromUnstagedArgs, @@ -70,11 +69,9 @@ import type { PushMode, SessionLinearIssueLink, StackChainItem, - UnregisteredLaneCandidate, UpdateLaneAppearanceArgs } from "../../../shared/types"; import { resolveAdeLayout } from "../../../shared/adeLayout"; -import { codedError, encodeCodedErrorMessage } from "../../../shared/codedError"; import { detectLaneBranchDrift, laneNameAdvertisesBranch, @@ -410,14 +407,33 @@ async function readWorktreeDirty(worktreePath: string, timeoutMs = 8_000): Promi return result.stdout.trim().length > 0; } +async function isSymbolicLinkPath(targetPath: string): Promise { + try { + return (await fs.promises.lstat(targetPath)).isSymbolicLink(); + } catch { + return false; + } +} + async function assertExpectedGitWorktreeRoot(worktreePath: string): Promise { if (!(await isExpectedGitWorktreeRoot(worktreePath))) { throw new Error(STALE_WORKTREE_ROOT_MESSAGE); } } -type GitWorktreeInfo = UnregisteredLaneCandidate & { +/** One entry of `git worktree list --porcelain`. Service-internal: no caller outside laneService sees a worktree that is not already a lane. */ +type GitWorktreeInfo = { + path: string; + /** Branch the worktree has checked out, "" for a detached HEAD. */ + branch: string; isBare: boolean; + /** + * Git reported this worktree as removable (`prunable `) — its + * directory or gitdir pointer is already gone. Adoption must skip these or + * every `lanes.list` would resurrect a lane row for a worktree that the next + * `git worktree prune` deletes. + */ + isPrunable: boolean; }; function worktreeStdout(value: unknown): string { @@ -442,13 +458,16 @@ function parseGitWorktreePorcelain(stdout: string): GitWorktreeInfo[] { let wtPath = ""; let branch = ""; let isBare = false; + let isPrunable = false; for (const line of lines) { if (line.startsWith("worktree ")) wtPath = line.slice("worktree ".length).trim(); if (line.startsWith("branch ")) branch = line.slice("branch ".length).trim().replace(/^refs\/heads\//, ""); if (line === "bare") isBare = true; + // Git emits a bare `prunable` or `prunable ` attribute. + if (line === "prunable" || line.startsWith("prunable ")) isPrunable = true; } if (!wtPath) continue; - worktrees.push({ path: normAbs(wtPath), branch, isBare }); + worktrees.push({ path: normAbs(wtPath), branch, isBare, isPrunable }); } return worktrees; @@ -466,7 +485,7 @@ function worktreeDirSuffix(worktreePath: string): string { return basename.includes("-") ? basename.split("-").pop() ?? "" : ""; } -function inferLaneNameFromManagedWorktree(candidate: UnregisteredLaneCandidate): string { +function inferLaneNameFromWorktree(candidate: { path: string; branch: string }): string { const basename = path.basename(candidate.path).trim(); const branchSlug = candidate.branch.trim().replace(/^ade\//, ""); const slug = (branchSlug || basename).replace(/-[0-9a-f]{8}$/i, ""); @@ -1547,6 +1566,23 @@ export function createLaneService({ laneListCache.clear(); }; + /** + * For the lane lifecycle mutations — create, import, adopt, reap, delete, + * archive, reclaim, restore. `project.inspectPath` caches a git + filesystem + * probe of a path (repo root? linked worktree? ADE-managed?) for 10s — down + * to the lane row that claims it — and these are the operations that add, + * remove, or recreate the worktree that probe describes, or change which lane + * row owns a path, so both caches drop together or the worktree-open gate + * answers from a pre-mutation picture. Ordinary edits (rename, appearance, + * branch profiles) call `invalidateLaneListCache` alone — except that the + * inspector's lane row carries appearance fields too (`color`), so an edit + * that rewrites those in the database drops both caches as well. + */ + const invalidateLanePathCaches = (): void => { + invalidateLaneListCache(); + invalidateProjectPathInspectionCache(); + }; + const normalizeBranchKey = (ref: string): string => normalizeBranchName(ref).trim(); @@ -1931,6 +1967,37 @@ export function createLaneService({ const normalizedProjectRoot = normAbs(projectRoot); const normalizedWorktreesDir = normAbs(worktreesDir); + const normalizedPrimaryWorktreePath = normAbs(primaryWorktreePath); + + /** + * git answers every path question with symlinks already resolved — `git + * worktree list --porcelain`, `rev-parse --show-toplevel` and + * `--git-common-dir` all do. ADE's own paths come from whatever the user + * picked, which is routinely a symlink (`~/Projects` pointing at an external + * volume). Comparing the two spellings directly makes every git-reported path + * look foreign, so the whole worktree reconcile is done in realpath space. + */ + const canonicalPath = (value: string): string => stablePathThroughExistingAncestor(value); + + // Resolved once: a service instance's roots cannot change identity, and the + // reconcile compares against them on the `lanes.list` hot path. + const canonicalProjectRoot = canonicalPath(normalizedProjectRoot); + const canonicalWorktreesDir = canonicalPath(normalizedWorktreesDir); + const canonicalPrimaryWorktreePath = canonicalPath(normalizedPrimaryWorktreePath); + + /** + * Both spellings of the three folders that are never a removable or adoptable + * lane worktree. Guards match against the set rather than a single spelling so + * resolving paths can only ever widen what ADE refuses to touch. + */ + const protectedRootPaths = new Set([ + normalizedProjectRoot, + normalizedWorktreesDir, + normalizedPrimaryWorktreePath, + canonicalProjectRoot, + canonicalWorktreesDir, + canonicalPrimaryWorktreePath, + ]); // Worktree paths / branch refs with an in-flight `git worktree add`. A new // worktree is visible to `git worktree list` before its lane row is @@ -1939,31 +2006,25 @@ export function createLaneService({ const pendingWorktreeCreationPaths = new Set(); const pendingWorktreeCreationBranches = new Set(); + // Callers reach this set from both path spaces — ADE's own spelling on the + // create path, git's resolved one on the reconcile path — so both are stored. + // Missing a pending create here reaps or re-adopts a half-built worktree. const trackPendingWorktreeCreation = (worktreePath: string, branchRef: string): (() => void) => { - const pathKey = normAbs(worktreePath); + const pathKeys = [normAbs(worktreePath), canonicalPath(worktreePath)]; const branchKey = normalizeBranchKey(branchRef); - pendingWorktreeCreationPaths.add(pathKey); + for (const key of pathKeys) pendingWorktreeCreationPaths.add(key); if (branchKey) pendingWorktreeCreationBranches.add(branchKey); return () => { - pendingWorktreeCreationPaths.delete(pathKey); + for (const key of pathKeys) pendingWorktreeCreationPaths.delete(key); if (branchKey) pendingWorktreeCreationBranches.delete(branchKey); }; }; const isPendingWorktreeCreation = (worktreePath: string, branchRef: string): boolean => pendingWorktreeCreationPaths.has(normAbs(worktreePath)) || + pendingWorktreeCreationPaths.has(canonicalPath(worktreePath)) || (normalizeBranchKey(branchRef).length > 0 && pendingWorktreeCreationBranches.has(normalizeBranchKey(branchRef))); - const getGitTopLevel = async (cwd: string): Promise => { - const top = await runGitOrThrow(["rev-parse", "--path-format=absolute", "--show-toplevel"], { cwd, timeoutMs: 10_000 }); - return normAbs(top.trim()); - }; - - const getGitCommonDir = async (cwd: string): Promise => { - const commonDir = await runGitOrThrow(["rev-parse", "--path-format=absolute", "--git-common-dir"], { cwd, timeoutMs: 10_000 }); - return normAbs(commonDir.trim()); - }; - const listGitWorktrees = async (): Promise => { const result = await runGitOrThrow( ["worktree", "list", "--porcelain"], @@ -1992,6 +2053,7 @@ export function createLaneService({ projectId, projectRoot, worktreesDir, + canonicalWorktreesDir, logger, listGitWorktrees, isPendingWorktreeCreation: (worktreePath) => pendingWorktreeCreationPaths.has(normAbs(worktreePath)), @@ -2005,33 +2067,155 @@ export function createLaneService({ return worktrees.find((wt) => !wt.isBare && normalizeBranchKey(wt.branch) === normalizedBranch) ?? null; }; - const listUnregisteredWorktreeCandidates = async (): Promise => { - const worktrees = await listGitWorktrees(); + /** + * Which of the repository's worktrees this project may claim as its lanes. + * + * `git worktree list` is repository-wide, and a project root can itself be a + * linked worktree — WorktreeOpenDialog's "Open as a separate project instead" + * opens one that way (`skipWorktreeGate`), and older projects were + * grandfathered in. Run from such a root, the listing reports the repo's MAIN + * checkout and every sibling project's worktree. Adopting those would hand + * project B a lane row for project A's worktree, and a lane row is precisely + * what every delete rail checks before removing a folder — so B could really + * delete A's worktree, and reap A's chats along with it. + * + * A root checkout owns the whole repository. A project rooted at a linked + * worktree owns only what lives under its own `.ade/worktrees`. + */ + type WorktreeOwnershipScope = "repository" | "managed-only"; + + const probeWorktreeOwnershipScope = async (): Promise => { + // A root checkout's common dir is its own `.git`. A linked worktree's points + // back into whichever checkout owns the repository. Asking git for the + // top-level alongside it keeps the comparison entirely inside git's own + // (symlink-resolved) path space, so a symlinked project root cannot + // misclassify as `managed-only` and silently disable adoption and reaping. + try { + const combined = await runGit( + ["rev-parse", "--path-format=absolute", "--show-toplevel", "--git-common-dir"], + { cwd: projectRoot, timeoutMs: 10_000 }, + ); + if (combined.exitCode === 0) { + const [topLevel, commonDir] = combined.stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length); + if (topLevel && commonDir) { + return isWithinDir(normAbs(topLevel), normAbs(commonDir)) ? "repository" : "managed-only"; + } + } + } catch { + // Fall through: a bare repo has no top-level, so the combined probe fails + // outright. The single-flag probe still answers, against ADE's own root. + } + + try { + const result = await runGit( + ["rev-parse", "--path-format=absolute", "--git-common-dir"], + { cwd: projectRoot, timeoutMs: 10_000 }, + ); + if (result.exitCode !== 0) return null; + const rawCommonDir = result.stdout.split(/\r?\n/)[0]?.trim() ?? ""; + if (!rawCommonDir.length) return null; + // No git-side top-level to compare against, so fall back to ADE's own + // root — resolved, so the two sides still share a path space. + return isWithinDir(canonicalProjectRoot, canonicalPath(rawCommonDir)) ? "repository" : "managed-only"; + } catch { + return null; + } + }; + + let cachedWorktreeOwnershipScope: WorktreeOwnershipScope | null = null; + let inFlightWorktreeOwnershipProbe: Promise | null = null; + + /** + * Memoized because it sits on the `lanes.list` hot path and a project root + * cannot change identity for the life of the service — one `git rev-parse` + * per service instance at most. An indeterminate probe (git missing, timeout) + * is deliberately NOT cached and scopes down: skipping adoption for one call + * is self-healing, adopting another project's worktree is not. + */ + const resolveWorktreeOwnershipScope = async (): Promise => { + if (cachedWorktreeOwnershipScope) return cachedWorktreeOwnershipScope; + const probe = inFlightWorktreeOwnershipProbe ?? probeWorktreeOwnershipScope(); + inFlightWorktreeOwnershipProbe = probe; + let resolved: WorktreeOwnershipScope | null = null; + try { + resolved = await probe; + } finally { + if (inFlightWorktreeOwnershipProbe === probe) inFlightWorktreeOwnershipProbe = null; + } + if (resolved) cachedWorktreeOwnershipScope = resolved; + return resolved ?? "managed-only"; + }; + + /** + * A worktree this project owns outright: its own `.ade/worktrees/`. + * Both sides are resolved so a symlinked project root does not make every + * managed worktree look like it belongs to someone else. + */ + const isPathUnderManagedWorktreesDir = (worktreePath: string): boolean => { + const candidate = canonicalPath(worktreePath); + return candidate !== canonicalWorktreesDir && isWithinDir(canonicalWorktreesDir, candidate); + }; + + /** + * Every git worktree of this repository that no lane row claims yet. Bare + * repos, the project root / primary checkout, and worktrees git has already + * marked prunable are never candidates; neither is a worktree whose + * `git worktree add` is still in flight (see `trackPendingWorktreeCreation`), + * nor — when this project root is a linked worktree — anything outside this + * project's own `.ade/worktrees` (see `resolveWorktreeOwnershipScope`). + * Callers that already hold a `git worktree list` snapshot pass it in so the + * lane-list repair block shells out to git exactly once. + */ + const listUnregisteredWorktreeCandidates = async ( + prefetchedWorktrees?: GitWorktreeInfo[], + ): Promise => { + const worktrees = prefetchedWorktrees ?? await listGitWorktrees(); + const ownershipScope = await resolveWorktreeOwnershipScope(); + // Lane rows store whatever spelling ADE wrote at create time; git reports + // the resolved one. Both spellings are indexed, because a claimed worktree + // that reads as unregistered here is adopted a second time. const registeredPaths = new Set( db.all<{ worktree_path: string }>( "select worktree_path from lanes where project_id = ?", [projectId] - ).map((row) => normAbs(row.worktree_path)) + ).flatMap((row) => [normAbs(row.worktree_path), canonicalPath(row.worktree_path)]) ); - return worktrees.filter( - (wt) => - !wt.isBare && - wt.path !== normalizedProjectRoot && - !registeredPaths.has(wt.path) && - !isPendingWorktreeCreation(wt.path, wt.branch) - ); + return worktrees + .map((wt) => { + const resolved = canonicalPath(wt.path); + return resolved === wt.path ? wt : { ...wt, path: resolved }; + }) + .filter( + (wt) => + !wt.isBare && + !wt.isPrunable && + !protectedRootPaths.has(wt.path) && + (ownershipScope === "repository" || isPathUnderManagedWorktreesDir(wt.path)) && + !registeredPaths.has(wt.path) && + !isPendingWorktreeCreation(wt.path, wt.branch) + ); }; - const recoverManagedWorktreeRows = async (): Promise => { - const candidates = await listUnregisteredWorktreeCandidates(); + /** + * Adopt every unregistered git worktree as a lane. ADE-created worktrees under + * `.ade/worktrees` and worktrees the user created anywhere else are treated + * identically: one worktree, one lane, one lane type. + */ + const recoverManagedWorktreeRows = async ( + prefetchedWorktrees?: GitWorktreeInfo[], + ): Promise => { + const candidates = await listUnregisteredWorktreeCandidates(prefetchedWorktrees); let recoveredCount = 0; for (const candidate of candidates) { const worktreePath = normAbs(candidate.path); const branchRef = candidate.branch.trim(); + // A detached-HEAD worktree has no branch to bind a lane to. if (!branchRef) continue; - if (path.dirname(worktreePath) !== normalizedWorktreesDir) continue; // A create may have started after the candidate snapshot was taken. if (isPendingWorktreeCreation(worktreePath, branchRef)) continue; @@ -2049,7 +2233,7 @@ export function createLaneService({ const laneId = randomUUID(); const now = new Date().toISOString(); - const displayName = inferLaneNameFromManagedWorktree(candidate); + const displayName = inferLaneNameFromWorktree(candidate); const laneColor = allocateLaneColorForProject(); db.run( ` @@ -2074,7 +2258,7 @@ export function createLaneService({ } if (recoveredCount > 0) { - invalidateLaneListCache(); + invalidateLanePathCaches(); logger.info("laneService.recovered_managed_worktrees", { projectRoot, count: recoveredCount, @@ -2083,6 +2267,113 @@ export function createLaneService({ return recoveredCount; }; + /** + * Drop lane rows whose worktree no longer exists. This is the mirror image of + * adoption: a worktree removed outside ADE (`git worktree remove`, or a lane + * deleted from another machine) stops being a lane here too. + * + * Deliberately conservative — it touches nothing on disk and never runs git: + * + * - the caller must hand in a `git worktree list` snapshot that actually + * succeeded, so a failing git binary can never mass-delete lanes; + * - both signals must agree (git no longer registers the path AND the + * directory is gone) — a worktree git still lists, even as prunable, is + * left alone; + * - archived lanes are never reaped: `archiveAndReclaim` removes the folder + * on purpose and keeps the row, branch, and chats; + * - lanes with an in-flight create / delete / reclaim are skipped, because + * their path is legitimately absent mid-operation; + * - only rows this project owns are eligible. The rows themselves are already + * project-scoped (they come from `lanes where project_id = ?`), but under + * `managed-only` ownership the `git worktree list` those rows are checked + * against spans sibling projects, so a row pointing outside this project's + * `.ade/worktrees` is left alone rather than judged by a listing that does + * not speak for it. + */ + const removeVanishedWorktreeLanes = ( + worktrees: GitWorktreeInfo[], + ownershipScope: WorktreeOwnershipScope, + ): number => { + // A git worktree list that does not even contain the repository itself is + // not a trustworthy picture of the world (unreachable repo root, mocked or + // truncated output). Deleting rows from it would be data loss. + const registeredPaths = new Set(worktrees.filter((wt) => !wt.isBare).map((wt) => canonicalPath(wt.path))); + const repoRootRegistered = + registeredPaths.has(canonicalProjectRoot) || registeredPaths.has(canonicalPrimaryWorktreePath); + if (!repoRootRegistered) return 0; + if (!fs.existsSync(normalizedProjectRoot)) return 0; + + const rows = db.all<{ id: string; name: string; color: string | null; worktree_path: string; branch_ref: string }>( + ` + select id, name, color, worktree_path, branch_ref + from lanes + where project_id = ? + and lane_type != 'primary' + and status != 'archived' + `, + [projectId], + ); + + const removed: Array<{ id: string; name: string; color: string | null }> = []; + try { + for (const row of rows) { + // `normAbs("")` is the process cwd, so the emptiness check has to happen + // on the stored value rather than the normalised one. + const storedPath = (row.worktree_path ?? "").trim(); + if (!storedPath.length) continue; + const worktreePath = canonicalPath(storedPath); + if (protectedRootPaths.has(worktreePath) || protectedRootPaths.has(normAbs(storedPath))) continue; + if (ownershipScope === "managed-only" && !isPathUnderManagedWorktreesDir(worktreePath)) continue; + if (registeredPaths.has(worktreePath)) continue; + if (isPendingWorktreeCreation(worktreePath, row.branch_ref)) continue; + if (laneReclaimInFlight.has(row.id)) continue; + if (deleteProgressByLaneId.get(row.id)?.overallStatus === "running") continue; + // Either spelling still being on disk means the worktree is not gone. + if (fs.existsSync(worktreePath) || fs.existsSync(normAbs(storedPath))) continue; + + // Same ordering as the user-initiated delete: read the proof file paths + // before the rows go, unlink only after the transaction commits, so a + // rollback cannot leave files deleted out from under surviving rows. + // Without this the reap orphans every proof artifact the lane wrote. + const laneArtifactFiles = collectLaneArtifactFilePaths(row.id); + db.run("begin immediate"); + try { + cleanupLaneDatabaseRows(row.id); + db.run("commit"); + } catch (error) { + try { + db.run("rollback"); + } catch { + // surface the original error below + } + throw error; + } + removed.push({ id: row.id, name: row.name, color: row.color }); + removeLaneArtifactFiles(row.id, laneArtifactFiles); + } + } finally { + // Row deletions commit one at a time, so a mid-loop failure leaves earlier + // rows already gone. Announcing in `finally` keeps the caches and the + // lane-deleted events consistent with what the database actually says. + if (removed.length > 0) { + invalidateLanePathCaches(); + logger.info("laneService.removed_vanished_worktree_lanes", { + projectRoot, + count: removed.length, + }); + for (const lane of removed) { + broadcastLifecycleEvent({ + type: "lane-deleted", + laneId: lane.id, + laneName: lane.name, + color: lane.color, + }); + } + } + } + return removed.length; + }; + /** * Fold a duplicate lane's user-visible data onto the keeper, then cascade the * duplicate row away. Sessions (and their session-scoped Linear links), child @@ -2181,7 +2472,7 @@ export function createLaneService({ ` select id, worktree_path, created_at from lanes - where project_id = ? and lane_type = 'worktree' and status != 'archived' + where project_id = ? and lane_type != 'primary' and status != 'archived' `, [projectId] ); @@ -2227,7 +2518,7 @@ export function createLaneService({ } if (removedCount > 0) { - invalidateLaneListCache(); + invalidateLanePathCaches(); logger.info("laneService.repaired_duplicate_worktree_lanes", { projectRoot, count: removedCount, @@ -2235,28 +2526,6 @@ export function createLaneService({ } }; - const ensureAttachableWorktreeRoot = async (candidatePath: string): Promise => { - const resolvedPath = normAbs(candidatePath); - let worktreeRoot = ""; - let candidateCommonDir = ""; - try { - worktreeRoot = await getGitTopLevel(resolvedPath); - candidateCommonDir = await getGitCommonDir(resolvedPath); - } catch { - throw new Error("Attached lane path must be a valid git worktree root"); - } - if (worktreeRoot !== resolvedPath) { - throw new Error("Attached lane path must point to the root of a worktree (not a subdirectory)"); - } - if (resolvedPath === normalizedProjectRoot) { - throw new Error("Primary repository root is already tracked as the Primary lane"); - } - const projectCommonDir = await getGitCommonDir(normalizedProjectRoot); - if (candidateCommonDir !== projectCommonDir) { - throw new Error("Attached lane path must belong to the current project repository"); - } - }; - /** Look up the active (non-archived) primary lane. */ const getActivePrimaryLane = (): { id: string; branch_ref: string } | undefined => { return db.get<{ id: string; branch_ref: string }>( @@ -2280,7 +2549,7 @@ export function createLaneService({ // overwhelmingly common already-correct path. if (!existing.color) { db.run("update lanes set color = ? where id = ?", [PRIMARY_LANE_COLOR, existing.id]); - invalidateLaneListCache(); + invalidateLanePathCaches(); } return; } @@ -2301,7 +2570,7 @@ export function createLaneService({ // one lane colour worth memorising (see `PRIMARY_LANE_COLOR`). [laneId, projectId, "Primary", "Main repository workspace", defaultBaseRef, branchRef, primaryWorktreePath, PRIMARY_LANE_COLOR, now] ); - invalidateLaneListCache(); + invalidateLanePathCaches(); }; const syncPrimaryLaneBranchRef = async (): Promise => { @@ -2520,9 +2789,15 @@ export function createLaneService({ logger.warn("laneService.residualWorktreeCleanup_failed", { error: err instanceof Error ? err.message : String(err) }); } try { - await recoverManagedWorktreeRows(); + // One `git worktree list` feeds both directions of the reconcile: adopt + // worktrees that have no lane, drop lanes whose worktree is gone. If git + // fails, neither runs — reaping on a failed listing would delete every + // lane in the project. + const gitWorktrees = await listGitWorktrees(); + await recoverManagedWorktreeRows(gitWorktrees); + removeVanishedWorktreeLanes(gitWorktrees, await resolveWorktreeOwnershipScope()); } catch (err) { - logger.warn("laneService.recoverManagedWorktreeRows_failed", { error: err instanceof Error ? err.message : String(err) }); + logger.warn("laneService.reconcileWorktreeLanes_failed", { error: err instanceof Error ? err.message : String(err) }); } try { repairDuplicateManagedWorktreeLanes(); @@ -2791,7 +3066,7 @@ export function createLaneService({ linearIssue = args.linearIssue ? upsertLaneLinearIssue(laneId, args.linearIssue, branchRef) : null; - invalidateLaneListCache(); + invalidateLanePathCaches(); } catch (error) { await cleanupCreatedWorktreeLaneAfterCreateFailure({ @@ -2956,7 +3231,7 @@ export function createLaneService({ const operationToken = randomUUID(); const acquired = laneStorageWorktreeLocks.acquire({ ...args, - worktreePath: stablePathThroughExistingAncestor(args.worktreePath), + worktreePath: canonicalPath(args.worktreePath), ownerKind: "storage_lifecycle", ownerSessionId: operationToken, token: operationToken, @@ -3435,10 +3710,6 @@ export function createLaneService({ }); }, - async listUnregisteredWorktrees(): Promise { - return listUnregisteredWorktreeCandidates(); - }, - getStateSnapshot(laneId: string): LaneStateSnapshotSummary | null { const row = db.get( ` @@ -4092,9 +4363,9 @@ export function createLaneService({ try { const checkedOutWorktree = await findGitWorktreeForBranch(branchRef); - if (checkedOutWorktree && checkedOutWorktree.path !== normalizedProjectRoot) { + if (checkedOutWorktree && canonicalPath(checkedOutWorktree.path) !== canonicalProjectRoot) { throw new Error( - `Branch '${branchRef}' is already checked out at '${checkedOutWorktree.path}'. Use Add existing worktrees to attach it as a lane, or remove/prune that worktree before importing again.` + `Branch '${branchRef}' is already checked out at '${checkedOutWorktree.path}'. That worktree becomes a lane on its own; remove or prune it before importing this branch again.` ); } } catch (err) { @@ -4179,7 +4450,7 @@ export function createLaneService({ } throw txError; } - invalidateLaneListCache(); + invalidateLanePathCaches(); // Best-effort push to establish upstream if not already tracking a remote try { @@ -5718,7 +5989,10 @@ export function createLaneService({ const deleteRisk = await laneServiceApi.getDeleteRisk(laneId); const normalizedRoot = normAbs(worktreesDir); const normalizedWorktree = normAbs(row.worktree_path); - const managedPath = row.lane_type === "worktree" && path.dirname(normalizedWorktree) === normalizedRoot; + // Reclaim only ever removes folders ADE created. That is a property of the + // path, not of the lane type — a lane pointing anywhere else is blocked + // below with `worktree_outside_managed_root`. + const managedPath = row.lane_type !== "primary" && path.dirname(normalizedWorktree) === normalizedRoot; const worktreeExists = managedPath && fs.existsSync(normalizedWorktree); const symlinkPath = managedPath ? await hasSymlinkInManagedPath(normalizedRoot, normalizedWorktree) @@ -5756,14 +6030,7 @@ export function createLaneService({ disposition: "blocked", }); } - if (row.lane_type === "attached") { - blockedReasons.push({ - code: "attached_lane", - message: "This folder is attached from outside ADE, so ADE will not remove it.", - disposition: "blocked", - }); - } - if (row.lane_type === "worktree" && !managedPath) { + if (row.lane_type !== "primary" && !managedPath) { blockedReasons.push({ code: "worktree_outside_managed_root", message: "The saved folder is outside ADE's managed worktree folder.", @@ -5844,9 +6111,6 @@ export function createLaneService({ throw new Error("This lane is already being deleted."); } if (row.lane_type === "primary") throw new Error("The primary lane cannot be reclaimed."); - if (row.lane_type === "attached") { - throw new Error("Attached folders are not ADE-managed and cannot be reclaimed."); - } const normalizedRoot = normAbs(worktreesDir); const normalizedWorktree = normAbs(row.worktree_path); if (path.dirname(normalizedWorktree) !== normalizedRoot) { @@ -5989,7 +6253,7 @@ export function createLaneService({ where project_id = ? and lane_id = ?`, [now, now, projectId, args.laneId], ); - invalidateLaneListCache(); + invalidateLanePathCaches(); broadcastLifecycleEvent({ type: "lane-reclaimed", laneId: args.laneId, @@ -6053,7 +6317,7 @@ export function createLaneService({ const now = new Date().toISOString(); db.run("update lanes set status = 'archived', archived_at = ? where id = ? and project_id = ?", [now, laneId, projectId]); - invalidateLaneListCache(); + invalidateLanePathCaches(); broadcastLifecycleEvent({ type: "lane-archived", laneId, @@ -6073,9 +6337,9 @@ export function createLaneService({ if (!lane) throw new Error(`Lane not found: ${laneId}`); return { lane, worktreeRecreated: false }; } - if (row.lane_type !== "worktree") { + if (row.lane_type === "primary") { commitLaneRestoreState(laneId); - invalidateLaneListCache(); + invalidateLanePathCaches(); const lane = await laneServiceApi.getSummary(laneId, { includeStatus: true }); if (!lane) throw new Error(`Lane not found: ${laneId}`); broadcastLifecycleEvent({ @@ -6089,22 +6353,31 @@ export function createLaneService({ const normalizedRoot = normAbs(worktreesDir); const savedPath = normAbs(row.worktree_path); - const canonicalPath = path.join(normalizedRoot, `${slugify(row.name)}-${laneId.slice(0, 8)}`); - const savedPathIsManaged = path.dirname(savedPath) === normalizedRoot; - const targetPath = savedPathIsManaged ? savedPath : canonicalPath; + const fallbackWorktreePath = path.join(normalizedRoot, `${slugify(row.name)}-${laneId.slice(0, 8)}`); + // Either spelling of the worktrees folder counts as managed: a row adopted + // from git holds the resolved one, and reading that as unmanaged would + // relocate ADE's own worktree to a freshly named path on restore. + const savedPathParent = path.dirname(savedPath); + const savedPathIsManaged = savedPathParent === normalizedRoot || savedPathParent === canonicalWorktreesDir; + const savedPathExists = fs.existsSync(savedPath); + // Recreation only ever happens inside `.ade/worktrees`. A lane whose + // folder still exists elsewhere on disk (a worktree the user made) is + // restored in place; one whose non-managed path is gone (a stale path + // from another machine) gets a fresh canonical worktree instead. + const targetPath = savedPathIsManaged || savedPathExists ? savedPath : fallbackWorktreePath; const storageLock = acquireStorageLifecycleLock({ laneId, worktreePath: targetPath, ownerLabel: `Restore lane: ${row.name}`, }); try { - if (savedPathIsManaged && fs.existsSync(savedPath)) { + if (savedPathExists) { const registered = await registeredWorktreeForLane(savedPath, row.branch_ref).catch(() => null); if (!registered) { throw new Error("The saved lane folder is occupied by a different or unregistered Git worktree."); } commitLaneRestoreState(laneId); - invalidateLaneListCache(); + invalidateLanePathCaches(); const lane = await laneServiceApi.getSummary(laneId, { includeStatus: true }); if (!lane) throw new Error(`Lane not found: ${laneId}`); broadcastLifecycleEvent({ @@ -6115,7 +6388,10 @@ export function createLaneService({ }); return { lane, worktreeRecreated: false }; } - if (await hasSymlinkInManagedPath(normalizedRoot, targetPath)) { + // Same spelling on both sides: `targetPath` is either the fresh path + // under the configured folder or a managed saved path under whichever + // spelling the row holds, and only the folder's own children matter. + if (await hasSymlinkInManagedPath(path.dirname(targetPath), targetPath)) { throw new Error("ADE will not restore a lane through a symbolic link."); } if (fs.existsSync(targetPath)) { @@ -6143,7 +6419,7 @@ export function createLaneService({ }); throw error; } - invalidateLaneListCache(); + invalidateLanePathCaches(); const lane = await laneServiceApi.getSummary(laneId, { includeStatus: true }); if (!lane) throw new Error(`Lane not found: ${laneId}`); broadcastLifecycleEvent({ @@ -6237,8 +6513,11 @@ export function createLaneService({ ? path.join(projectRoot, ".git", "worktrees", path.basename(row.worktree_path)) : ""; const worktreeRegistered = Boolean(worktreeMetadataPath) && fs.existsSync(worktreeMetadataPath); + // Every lane owns a real worktree now, wherever it lives — deleting one + // removes the checkout. Legacy `attached` rows are no exception; the + // safety rails below are what keep an arbitrary path safe to remove, not + // the lane type. const hasWorktree = - row.lane_type === "worktree" && Boolean(row.worktree_path) && (fs.existsSync(row.worktree_path) || worktreeRegistered); const stepNames: LaneDeleteStepName[] = []; @@ -6399,6 +6678,33 @@ export function createLaneService({ if (hasWorktree) { await runStep("git_worktree_remove", async () => { + const targetPath = normAbs(row.worktree_path); + // Rails that must hold for ANY worktree path, not just the ones ADE + // created: never the repository itself, never the worktrees folder, + // never through a symlink, and never a directory that is not the + // root of a Git worktree. + if (protectedRootPaths.has(targetPath) || protectedRootPaths.has(canonicalPath(targetPath))) { + throw new Error("ADE will not remove the project's own folder."); + } + const managedWorktreePath = residualWorktreeCleanup.isDirectManagedWorktreePath(targetPath); + if (managedWorktreePath) { + // A managed path is a direct child of the worktrees folder under + // one of its two spellings. Walking from that same spelling is + // what keeps a symlinked project root from reading as a redirect + // planted inside the folder. + if (await hasSymlinkInManagedPath(path.dirname(targetPath), targetPath)) { + throw new Error("ADE will not remove a lane folder through a symbolic link."); + } + } else { + if (await isSymbolicLinkPath(targetPath)) { + throw new Error("ADE will not remove a lane folder through a symbolic link."); + } + // Outside `.ade/worktrees` ADE never falls back to deleting files, + // so a directory that git does not recognise is left untouched. + if (fs.existsSync(targetPath) && !(await isExpectedGitWorktreeRoot(targetPath))) { + throw new Error(STALE_WORKTREE_ROOT_MESSAGE); + } + } return runGitWorktreeMutation(async () => { const removeArgs = ["worktree", "remove"]; if (force) removeArgs.push("--force"); @@ -6457,17 +6763,29 @@ export function createLaneService({ // state that blocks future deletes. const removeRes = await runGit(removeArgs, { cwd: projectRoot, timeoutMs: 60_000 }); if (removeRes.exitCode === 0) { - if (fs.existsSync(row.worktree_path)) { - return removeResidualDirectory(`${row.worktree_path} (removed residual files)`); + if (!fs.existsSync(row.worktree_path)) { + residualWorktreeCleanup.deleteRow(row.worktree_path); + return { detail: row.worktree_path }; } - residualWorktreeCleanup.deleteRow(row.worktree_path); - return { detail: row.worktree_path }; + if (!managedWorktreePath) { + // Git unregistered it but left files behind. They are the + // user's, outside ADE's storage — say so instead of deleting. + const message = `git removed the worktree but files remain at ${row.worktree_path}`; + recordNonFatalFailure("git_worktree_remove", message); + return { detail: `${row.worktree_path}; warning: ${message}` }; + } + return removeResidualDirectory(`${row.worktree_path} (removed residual files)`); + } + const original = (removeRes.stderr || removeRes.stdout || "").trim(); + if (!managedWorktreePath) { + // No filesystem escalation for a folder ADE does not own: the + // git failure is the answer the user gets. + throw new Error(original || `git worktree remove exited ${removeRes.exitCode}`); } // Recovery path: a previous failed delete (or this one's first attempt) // can leave the worktree dir present without its `.git` pointer file, or // the dir gone with stale metadata still registered. Either way: rm the // dir if any, then prune git's metadata. - const original = (removeRes.stderr || removeRes.stdout || "").trim(); return removeResidualDirectory( `${row.worktree_path} (recovered from stale state)`, `git worktree remove failed (${original})` @@ -6555,7 +6873,7 @@ export function createLaneService({ : { detail: `${removedProofFiles} of ${laneArtifactFiles.length} proof file(s) removed; see logs` }; }); - invalidateLaneListCache(); + invalidateLanePathCaches(); finalize(nonFatalFailures.length > 0 ? "completed_with_warnings" : "completed"); broadcastLifecycleEvent({ type: "lane-deleted", @@ -6752,196 +7070,6 @@ export function createLaneService({ }; }, - async attach(args: AttachLaneArgs): Promise { - const laneName = (args.name ?? "").trim(); - if (!laneName) throw new Error("Lane name is required"); - - const attachedPath = normAbs(args.attachedPath); - if (!fs.existsSync(attachedPath) || !fs.statSync(attachedPath).isDirectory()) { - throw new Error("Attached lane path must be an existing directory"); - } - await ensureAttachableWorktreeRoot(attachedPath); - - const branchRef = await detectBranchRef(attachedPath, defaultBaseRef); - const existingPath = db.get<{ id: string; name: string; status: string }>( - "select id, name, status from lanes where project_id = ? and worktree_path = ? limit 1", - [projectId, attachedPath] - ); - if (existingPath?.id) { - if (existingPath.status === "archived") { - throw codedError( - encodeCodedErrorMessage( - "lane_already_linked", - `This worktree is already linked as archived lane '${existingPath.name}'. Unarchive it instead.`, - ), - "lane_already_linked", - ); - } - throw codedError( - encodeCodedErrorMessage( - "lane_already_linked", - `This worktree is already linked as lane '${existingPath.name}'.`, - ), - "lane_already_linked", - ); - } - - const existingBranch = db.get<{ id: string; name: string; status: string; worktree_path: string }>( - "select id, name, status, worktree_path from lanes where project_id = ? and branch_ref = ? limit 1", - [projectId, branchRef] - ); - if (existingBranch?.id && normAbs(existingBranch.worktree_path) !== attachedPath) { - if (existingBranch.status === "archived") { - throw codedError( - encodeCodedErrorMessage( - "lane_already_linked", - `Branch '${branchRef}' is already linked to archived lane '${existingBranch.name}'. Unarchive it instead.`, - ), - "lane_already_linked", - ); - } - throw codedError( - encodeCodedErrorMessage( - "lane_already_linked", - `Branch '${branchRef}' is already linked to lane '${existingBranch.name}'.`, - ), - "lane_already_linked", - ); - } - - const laneId = randomUUID(); - const now = new Date().toISOString(); - - const parentLaneId = null; - const baseRef = defaultBaseRef; - const laneColor = allocateLaneColorForProject(); - - db.run( - ` - insert into lanes( - id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, - attached_root_path, is_edit_protected, parent_lane_id, color, icon, tags_json, status, created_at, archived_at - ) - values(?, ?, ?, ?, 'attached', ?, ?, ?, ?, 0, ?, ?, null, null, 'active', ?, null) - `, - [laneId, projectId, laneName, args.description ?? null, baseRef, branchRef, attachedPath, attachedPath, parentLaneId, laneColor, now] - ); - invalidateLaneListCache(); - - // Best-effort push to establish upstream if not already tracking a remote - try { - const upstreamCheck = await runGit(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], { cwd: attachedPath, timeoutMs: 5_000 }); - if (upstreamCheck.exitCode !== 0) { - await runGit(["push", "-u", "origin", branchRef], { cwd: attachedPath, timeoutMs: 60_000 }); - } - } catch { - // Non-fatal: lane works locally even without remote tracking - } - - const row = getLaneRow(laneId); - if (!row) throw new Error(`Failed to attach lane: ${laneId}`); - const rowsById = getRowsById(true); - const status = await computeLaneStatus(attachedPath, baseRef, branchRef); - const parentRow = parentLaneId ? getLaneRow(parentLaneId) : null; - const parentStatus = parentRow - ? await computeLaneStatus(parentRow.worktree_path, parentRow.base_ref, parentRow.branch_ref) - : null; - const summary = toLaneSummary({ - row, - status, - parentStatus, - childCount: 0, - stackDepth: computeStackDepth({ laneId, rowsById, memo: new Map() }) - }); - broadcastLifecycleEvent({ - type: "lane-created", - laneId: summary.id, - laneName: summary.name, - color: summary.color, - lane: summary, - }); - return summary; - }, - - async adoptAttached(args: AdoptAttachedLaneArgs): Promise { - const laneId = (args.laneId ?? "").trim(); - if (!laneId) throw new Error("laneId is required"); - - const row = getLaneRow(laneId); - if (!row) throw new Error(`Lane not found: ${laneId}`); - if (row.lane_type !== "attached") { - throw new Error("Only attached lanes can be moved into .ade/worktrees"); - } - if (row.status === "archived") { - throw new Error("Archived lanes cannot be moved. Unarchive first."); - } - - const currentPath = normAbs(row.worktree_path); - if (!fs.existsSync(currentPath) || !fs.statSync(currentPath).isDirectory()) { - throw new Error("Attached worktree path no longer exists on disk"); - } - await ensureAttachableWorktreeRoot(currentPath); - - const slug = slugify(row.name); - const defaultTarget = path.join(worktreesDir, `${slug}-${laneId.slice(0, 8)}`); - const normalizedWorktreesDir = normAbs(worktreesDir); - let targetPath = normAbs(defaultTarget); - - if (!isWithinDir(normalizedWorktreesDir, targetPath)) { - throw new Error("Failed to resolve destination under .ade/worktrees"); - } - - if (currentPath !== targetPath) { - if (fs.existsSync(targetPath)) { - targetPath = normAbs(path.join(worktreesDir, `${slug}-${randomUUID().slice(0, 8)}`)); - } - const existingTarget = db.get<{ id: string; name: string }>( - "select id, name from lanes where project_id = ? and worktree_path = ? and id != ? limit 1", - [projectId, targetPath, laneId] - ); - if (existingTarget?.id) { - throw new Error(`Destination path is already in use by lane '${existingTarget.name}'.`); - } - - await runGitWorktreeMutation(() => - runGitOrThrow(["worktree", "move", currentPath, targetPath], { - cwd: projectRoot, - timeoutMs: 120_000 - }) - ); - } - - db.run( - ` - update lanes - set lane_type = 'worktree', - worktree_path = ?, - attached_root_path = null - where id = ? and project_id = ? - `, - [targetPath, laneId, projectId] - ); - invalidateLaneListCache(); - - const updated = getLaneRow(laneId); - if (!updated) throw new Error(`Failed to update lane: ${laneId}`); - - const rowsById = getRowsById(true); - const parent = updated.parent_lane_id ? rowsById.get(updated.parent_lane_id) ?? null : null; - const status = await computeLaneStatus(updated.worktree_path, updated.base_ref, updated.branch_ref); - const parentStatus = parent - ? await computeLaneStatus(parent.worktree_path, parent.base_ref, parent.branch_ref) - : null; - - return toLaneSummary({ - row: updated, - status, - parentStatus, - childCount: getChildrenRows(updated.id, false).length, - stackDepth: computeStackDepth({ laneId: updated.id, rowsById, memo: new Map() }) - }); - }, - }; return laneServiceApi; diff --git a/apps/desktop/src/main/services/lanes/worktreeResidualCleanup.ts b/apps/desktop/src/main/services/lanes/worktreeResidualCleanup.ts index b0b19de44..57699d0b3 100644 --- a/apps/desktop/src/main/services/lanes/worktreeResidualCleanup.ts +++ b/apps/desktop/src/main/services/lanes/worktreeResidualCleanup.ts @@ -27,6 +27,14 @@ type WorktreeResidualCleanupDeps = { projectId: string; projectRoot: string; worktreesDir: string; + /** + * `worktreesDir` with symlinks resolved. git reports paths in realpath space + * and ADE's configured paths routinely are not, so a re-adopted lane row holds + * the canonical spelling of a folder this service owns. Both spellings name + * the same directory, so accepting either only ever re-admits ADE's own + * worktrees — never a folder outside them. + */ + canonicalWorktreesDir: string; logger: Logger; listGitWorktrees: () => Promise; isPendingWorktreeCreation: (worktreePath: string) => boolean; @@ -47,6 +55,7 @@ export function createWorktreeResidualCleanup({ projectId, projectRoot, worktreesDir, + canonicalWorktreesDir, logger, listGitWorktrees, isPendingWorktreeCreation, @@ -54,17 +63,36 @@ export function createWorktreeResidualCleanup({ }: WorktreeResidualCleanupDeps) { const normalizedProjectRoot = normAbs(projectRoot); const normalizedWorktreesDir = normAbs(worktreesDir); + const managedWorktreesDirs = Array.from( + new Set([normalizedWorktreesDir, normAbs(canonicalWorktreesDir)]), + ); + // Never a removable path, under either spelling of the folder itself. + const protectedDirs = new Set([normalizedProjectRoot, ...managedWorktreesDirs]); let lastSweepAt = 0; let activeSweep: Promise | null = null; const isDirectManagedWorktreePath = (worktreePath: string): boolean => { const normalized = normAbs(worktreePath); - return ( - normalized !== normalizedProjectRoot && - normalized !== normalizedWorktreesDir && - path.dirname(normalized) === normalizedWorktreesDir && - path.basename(normalized).trim().length > 0 - ); + if (protectedDirs.has(normalized)) return false; + if (!path.basename(normalized).trim().length) return false; + // Direct child only, and only of this project's own worktrees folder. The + // candidate is compared as written rather than resolved: a symlink pointing + // into the folder is not a worktree ADE created, and the delete rails refuse + // those separately. + return managedWorktreesDirs.includes(path.dirname(normalized)); + }; + + /** + * Every spelling under which a path can name the same managed worktree. Used + * to build the sets that say "this folder is alive, leave it alone", so a + * spelling mismatch can only ever spare a directory, never condemn one. A path + * outside the managed folder has one spelling here and is returned as given. + */ + const managedPathSpellings = (worktreePath: string): string[] => { + const normalized = normAbs(worktreePath); + if (!managedWorktreesDirs.includes(path.dirname(normalized))) return [normalized]; + const name = path.basename(normalized); + return managedWorktreesDirs.map((dir) => path.join(dir, name)); }; const normalizeCleanupError = (error: unknown): string => { @@ -206,12 +234,17 @@ export function createWorktreeResidualCleanup({ return; } - const gitWorktreePaths = new Set(gitWorktrees.filter((wt) => !wt.isBare).map((wt) => normAbs(wt.path))); + // Both spellings on the skip side: the sweep walks the configured folder + // while git reports the resolved one, so a set built from one spelling + // alone would fail to recognise a live worktree named in the other. + const gitWorktreePaths = new Set( + gitWorktrees.filter((wt) => !wt.isBare).flatMap((wt) => managedPathSpellings(wt.path)), + ); const laneWorktreePaths = new Set( db.all<{ worktree_path: string }>( "select worktree_path from lanes where project_id = ?", [projectId], - ).map((row) => normAbs(row.worktree_path)), + ).flatMap((row) => managedPathSpellings(row.worktree_path)), ); const rowsByPath = new Map(); @@ -301,6 +334,15 @@ export function createWorktreeResidualCleanup({ }; return { + /** + * True only for a direct child of the managed worktrees directory, in either + * of its spellings — a lane row re-adopted from git holds the resolved one. + * Residual filesystem cleanup — here and in the lane delete pipeline — is + * restricted to those paths: a worktree the user created elsewhere is their + * folder, so ADE asks git to remove it and never falls back to a recursive + * delete. + */ + isDirectManagedWorktreePath, deleteRow, recordFailure, retry, diff --git a/apps/desktop/src/main/services/onboarding/onboardingService.test.ts b/apps/desktop/src/main/services/onboarding/onboardingService.test.ts index 2c845712b..297375444 100644 --- a/apps/desktop/src/main/services/onboarding/onboardingService.test.ts +++ b/apps/desktop/src/main/services/onboarding/onboardingService.test.ts @@ -70,9 +70,7 @@ describe("onboardingService integration", () => { logger: createLogger(), projectRoot, projectId: "proj", - baseRef: "main", freshProject: false, - laneService: { list: async () => [] } as any, projectConfigService: createInMemoryProjectConfigService() }); @@ -111,9 +109,7 @@ describe("onboardingService help state", () => { logger: createLogger(), projectRoot: "/tmp/ade-onboarding-help", projectId: "proj", - baseRef: "main", freshProject: false, - laneService: { list: async () => [] } as any, projectConfigService: createInMemoryProjectConfigService() }); return { service, db }; @@ -154,9 +150,7 @@ describe("onboardingService help state", () => { logger: createLogger(), projectRoot: "/tmp/ade-onboarding-legacy", projectId: "proj", - baseRef: "main", freshProject: false, - laneService: { list: async () => [] } as any, projectConfigService: createInMemoryProjectConfigService(), }); diff --git a/apps/desktop/src/main/services/onboarding/onboardingService.ts b/apps/desktop/src/main/services/onboarding/onboardingService.ts index 1255a3c6b..4ee5ff5c9 100644 --- a/apps/desktop/src/main/services/onboarding/onboardingService.ts +++ b/apps/desktop/src/main/services/onboarding/onboardingService.ts @@ -2,17 +2,14 @@ import fs from "node:fs"; import path from "node:path"; import type { AdeDb } from "../state/kvDb"; import type { Logger } from "../logging/logger"; -import type { createLaneService } from "../lanes/laneService"; import type { createProjectConfigService } from "../config/projectConfigService"; import type { OnboardingDetectionIndicator, OnboardingDetectionResult, - OnboardingExistingLaneCandidate, OnboardingHelpState, OnboardingStatus, ProjectConfigFile } from "../../../shared/types"; -import { runGit, runGitOrThrow } from "../git/git"; import { dirExists, fileExists, nowIso } from "../shared/utils"; import { buildSuggestedConfig, parseGithubWorkflowRuns } from "./onboardingSuggestedConfig"; @@ -50,12 +47,10 @@ export function createOnboardingService(args: { logger: Logger; projectRoot: string; projectId: string; - baseRef: string; freshProject: boolean; - laneService: ReturnType; projectConfigService: ReturnType; }) { - const { db, logger, projectRoot, baseRef, freshProject, laneService, projectConfigService } = args; + const { db, logger, projectRoot, freshProject, projectConfigService } = args; const getStatus = (): OnboardingStatus => { const stored = db.getJson(STATUS_KEY); @@ -150,68 +145,11 @@ export function createOnboardingService(args: { }; }; - const detectExistingLanes = async (): Promise => { - const existing = await laneService.list({ includeArchived: true }); - const laneBranchRefs = new Set(existing.map((lane) => lane.branchRef)); - - const currentBranch = (await runGitOrThrow(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: projectRoot, timeoutMs: 8_000 })).trim(); - - const refs = await runGitOrThrow(["for-each-ref", "refs/heads", "--format=%(refname:short)"], { cwd: projectRoot, timeoutMs: 10_000 }); - const branchRefs = refs - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean) - .filter((ref) => !laneBranchRefs.has(ref)); - - const remoteRefs = await runGit(["for-each-ref", "refs/remotes/origin", "--format=%(refname:short)"], { cwd: projectRoot, timeoutMs: 10_000 }); - const remoteSet = new Set( - remoteRefs.exitCode === 0 - ? remoteRefs.stdout - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean) - .map((ref) => ref.replace(/^origin\//, "")) - : [] - ); - - const candidates: OnboardingExistingLaneCandidate[] = []; - for (const branchRef of branchRefs.slice(0, 200)) { - const counts = await runGit(["rev-list", "--left-right", "--count", `${baseRef}...${branchRef}`], { - cwd: projectRoot, - timeoutMs: 8_000 - }); - let behind = 0; - let ahead = 0; - if (counts.exitCode === 0) { - const parts = counts.stdout.trim().split(/\s+/).filter(Boolean); - behind = Number(parts[0] ?? 0) || 0; - ahead = Number(parts[1] ?? 0) || 0; - } - candidates.push({ - branchRef, - isCurrent: branchRef === currentBranch, - hasRemote: remoteSet.has(branchRef), - ahead, - behind - }); - } - - candidates.sort((a, b) => { - if (a.isCurrent !== b.isCurrent) return a.isCurrent ? -1 : 1; - const delta = b.ahead - a.ahead; - if (delta !== 0) return delta; - return a.branchRef.localeCompare(b.branchRef); - }); - - return candidates; - }; - return { getStatus, complete, setDismissed, detectDefaults, - detectExistingLanes, getHelpState, markGlossaryTermSeen, diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteRuntime.offlineRpc.integration.test.ts b/apps/desktop/src/main/services/remoteRuntime/remoteRuntime.offlineRpc.integration.test.ts index b1836ea94..be9c13614 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteRuntime.offlineRpc.integration.test.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteRuntime.offlineRpc.integration.test.ts @@ -171,7 +171,6 @@ function createRuntime(projectRoot: string) { logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, laneService: { list: vi.fn(async () => [{ id: "lane-main", name: "Main", archivedAt: null }]), - listUnregisteredWorktrees: vi.fn(async () => []), }, sessionService: { get: vi.fn(), diff --git a/apps/desktop/src/main/services/sessions/sessionService.test.ts b/apps/desktop/src/main/services/sessions/sessionService.test.ts index ef6972bef..6f4b212c2 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.test.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.test.ts @@ -1186,6 +1186,31 @@ describe("sessionService resume metadata", () => { activeDisposers.push(async () => db.close()); }); + it("returns every Claude session pointer a caller asks for past the old 500 clamp", async () => { + const projectRoot = makeProjectRoot("ade-session-service-"); + const dbPath = path.join(projectRoot, ".ade", "ade.db"); + const db = await openKvDb(dbPath, createLogger() as any); + insertProjectGraph(db); + const service = createSessionService({ db }); + + // External-session import marks already-imported Claude transcripts from + // this list; a silent clamp made older imports look un-imported. + for (let index = 0; index < 620; index += 1) { + service.upsertClaudeSessionPointer({ + sessionId: `sdk-${String(index).padStart(4, "0")}`, + laneId: "lane-1", + chatSessionId: null, + updatedAt: `2026-03-17T00:00:00.${String(index).padStart(3, "0")}Z`, + }); + } + + expect(service.listClaudeSessionPointers({ limit: 5000 })).toHaveLength(620); + expect(service.listClaudeSessionPointers({ limit: 10 })).toHaveLength(10); + expect(service.listClaudeSessionPointers()).toHaveLength(200); + + activeDisposers.push(async () => db.close()); + }); + it("keeps a chat bound to only one Claude SDK session pointer", async () => { const projectRoot = makeProjectRoot("ade-session-service-"); const dbPath = path.join(projectRoot, ".ade", "ade.db"); diff --git a/apps/desktop/src/main/services/sessions/sessionService.ts b/apps/desktop/src/main/services/sessions/sessionService.ts index c6d4ef43b..2b422ac3a 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.ts @@ -131,6 +131,8 @@ const SESSION_COLUMNS = ` s.chat_session_id as chatSessionId `; +export const CLAUDE_SESSION_POINTER_MAX_LIMIT = 5000; + const CLAUDE_SESSION_COLUMNS = ` c.session_id as sessionId, c.lane_id as laneId, @@ -803,8 +805,11 @@ export function createSessionService({ db }: { db: AdeDb }) { where.push("c.lane_id = ?"); params.push(laneId); } + // External-session import asks for every pointer it can get to decide + // which Claude transcripts are already in ADE; a 500-row silent clamp made + // older imports look un-imported without telling the caller. const limit = typeof args.limit === "number" && Number.isFinite(args.limit) && args.limit > 0 - ? Math.min(Math.trunc(args.limit), 500) + ? Math.min(Math.trunc(args.limit), CLAUDE_SESSION_POINTER_MAX_LIMIT) : 200; params.push(limit); const rows = db.all( diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts index b900aa184..a7e7399bf 100644 --- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts +++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts @@ -602,6 +602,27 @@ describe("createSyncRemoteCommandService", () => { expect(result.snapshots.map((entry) => entry.lane)).toEqual([lane]); }); + // This is the emitter phones actually read. Shipped iOS builds decode + // LaneListSnapshot with `adoptableAttached` as a non-optional Bool, and the + // host always updates before the phone does, so an emitter that stops + // sending the KEY blanks the lane list on every not-yet-updated device. + // The field is REQUIRED on `LaneListSnapshot` on purpose, so a dropped + // assignment fails typecheck — but nothing in TypeScript reads it, so a + // cleanup pass could delete it from the type and every emitter in one green + // step. Asserting on the serialized payload, by literal key name, is what + // makes that show up as a failing test instead of a silent wire break. + it("lanes.refreshSnapshots still emits the deprecated adoptableAttached key", async () => { + laneService.refreshSnapshots.mockResolvedValue({ lanes: [{ id: "lane-1", name: "Lane one" }] }); + + const result = await service.execute(makePayload("lanes.refreshSnapshots", {})) as { + snapshots: unknown[]; + }; + + const wire = JSON.parse(JSON.stringify(result.snapshots[0])); + expect(Object.keys(wire)).toContain("adoptableAttached"); + expect(wire.adoptableAttached).toBe(false); + }); + it("lanes.getDetail loads only the requested lane summary", async () => { const lane = { id: "lane-1", name: "Lane one" }; laneService.getSummary.mockResolvedValue(lane); @@ -713,12 +734,25 @@ describe("createSyncRemoteCommandService", () => { .rejects.toThrow(/branchName/); }); - it("lanes.listUnregisteredWorktrees routes to laneService", async () => { + // Every git worktree is a lane on sight, so there is nothing left to select + // or attach — but these three must stay registered (they are in + // MOBILE_SYNC_REQUIRED_REMOTE_COMMAND_ACTIONS, and dropping them would flip + // a healthy host into "limited" mode for every connected phone). + it("lanes.listUnregisteredWorktrees answers empty without touching laneService", async () => { const result = await service.execute(makePayload("lanes.listUnregisteredWorktrees")); - expect(laneService.listUnregisteredWorktrees).toHaveBeenCalledTimes(1); - expect(result).toEqual([ - { path: "/repo/.ade/unregistered-lanes/feature-one", branch: "feature/one" }, - ]); + expect(result).toEqual([]); + expect(laneService.listUnregisteredWorktrees).not.toHaveBeenCalled(); + }); + + it("lanes.attach and lanes.adoptAttached fail as unsupported", async () => { + await expect(service.execute(makePayload("lanes.attach", { + name: "wt", + attachedPath: "/repo/wt", + }))).rejects.toThrow(/no longer supported/i); + await expect(service.execute(makePayload("lanes.adoptAttached", { laneId: "lane-1" }))) + .rejects.toThrow(/no longer supported/i); + expect(laneService.attach).not.toHaveBeenCalled(); + expect(laneService.adoptAttached).not.toHaveBeenCalled(); }); it("lanes.rename parses laneId and name", async () => { diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 5a0840380..eaece045b 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -21,9 +21,6 @@ import type { ProjectSecretValueResult, BatchAssessmentResult, ApplyConflictProposalArgs, - AttachLaneArgs, - AdoptAttachedLaneArgs, - UnregisteredLaneCandidate, AppInfo, LocalRuntimeStatus, AppWelcomeVideoState, @@ -293,7 +290,6 @@ import type { KeybindingOverride, KeybindingsSnapshot, OnboardingDetectionResult, - OnboardingExistingLaneCandidate, OnboardingHelpState, OnboardingStatus, GitActionResult, @@ -1105,7 +1101,6 @@ declare global { onboarding: { getStatus: () => Promise; detectDefaults: () => Promise; - detectExistingLanes: () => Promise; setDismissed: (dismissed: boolean) => Promise; complete: () => Promise; markGlossaryTermSeen: ( @@ -1384,12 +1379,6 @@ declare global { resolveBranchDrift: ( args: ResolveLaneBranchDriftArgs, ) => Promise; - attach: (args: AttachLaneArgs) => Promise; - listUnregisteredWorktrees: () => Promise; - adoptAttached: ( - args: AdoptAttachedLaneArgs, - pin?: OpenProjectBinding | null, - ) => Promise; rename: (args: RenameLaneArgs, pin?: OpenProjectBinding | null) => Promise; reparent: ( args: ReparentLaneArgs, diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index c4286f30c..b0ec72010 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -64,9 +64,6 @@ import type { import type { BatchAssessmentResult, ApplyConflictProposalArgs, - AttachLaneArgs, - AdoptAttachedLaneArgs, - UnregisteredLaneCandidate, AppInfo, LocalRuntimeStatus, AppWelcomeVideoState, @@ -436,7 +433,6 @@ import type { KeybindingOverride, KeybindingsSnapshot, OnboardingDetectionResult, - OnboardingExistingLaneCandidate, OnboardingHelpState, OnboardingStatus, LaneLinearIssue, @@ -4500,10 +4496,6 @@ contextBridge.exposeInMainWorld("ade", { callProjectRuntimeActionOr("onboarding", "detectDefaults", {}, () => ipcRenderer.invoke(IPC.onboardingDetectDefaults), ), - detectExistingLanes: async (): Promise => - callProjectRuntimeActionOr("onboarding", "detectExistingLanes", {}, () => - ipcRenderer.invoke(IPC.onboardingDetectExistingLanes), - ), setDismissed: async (dismissed: boolean): Promise => callProjectRuntimeActionOr( "onboarding", @@ -5022,36 +5014,6 @@ contextBridge.exposeInMainWorld("ade", { clearGitReadCaches(); return result as ResolveLaneBranchDriftResult; }, - attach: async (args: AttachLaneArgs): Promise => { - clearGitReadCaches(); - const lane = await callProjectRuntimeActionOr( - "lane", - "attach", - { args }, - () => ipcRenderer.invoke(IPC.lanesAttach, args), - ); - clearGitReadCaches(); - return lane as LaneSummary; - }, - listUnregisteredWorktrees: async (): Promise => - callProjectRuntimeActionOr("lane", "listUnregisteredWorktrees", {}, () => - ipcRenderer.invoke(IPC.lanesListUnregisteredWorktrees), - ), - adoptAttached: async ( - args: AdoptAttachedLaneArgs, - pin?: OpenProjectBinding | null, - ): Promise => { - clearGitReadCaches(); - const lane = await callPinnedOrBoundRuntimeActionOr( - pin, - "lane", - "adoptAttached", - { args }, - () => ipcRenderer.invoke(IPC.lanesAdoptAttached, args), - ); - clearGitReadCaches(); - return lane as LaneSummary; - }, rename: async (args: RenameLaneArgs, pin?: OpenProjectBinding | null): Promise => { clearGitReadCaches(); if (pin) { diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index a3f16df11..3d22c3549 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -44,6 +44,8 @@ import { type AgentChatRecoverTurnResult, type AgentChatPrepareCrossMachineHandoffArgs, type AgentChatInterruptResult, + type LaneListSnapshot, + type LaneSummary, type OpenProjectBinding, type AgentChatRestoreCancelledQueueResult, type AgentChatResolveUnprocessedMessageArgs, @@ -1100,8 +1102,12 @@ function listMockAgentChatSummaries(args: any = {}): any[] { return rows; } -/** Returns a fresh snapshot object on every call to avoid shared-state leakage. */ -function makeLaneSnapshot(lane: any): any { +/** + * Returns a fresh snapshot object on every call to avoid shared-state leakage. + * Annotated (rather than `any`) so the mock's wire shape is compiler-checked + * against the real emitters — that is what catches a dropped `adoptableAttached`. + */ +function makeLaneSnapshot(lane: LaneSummary): LaneListSnapshot { const runtimeBucket = lane.id === "lane-auth" || lane.id === "lane-checkout" ? "running" @@ -1150,14 +1156,18 @@ function makeLaneSnapshot(lane: any): any { ? { laneId: lane.id, status: "conflict-active", - conflictCount: 2, - warningCount: 0, - updatedAt: now, - summary: "Mock conflict", + overlappingFileCount: 2, + peerConflictCount: 2, + lastPredictedAt: now, } : null, stateSnapshot: null, - adoptableAttached: lane.laneType === "attached" && lane.archivedAt == null, + // Mirrors the real snapshot builders: always false, never computed. The + // field is deliberately REQUIRED on `LaneListSnapshot` even though no + // TypeScript surface reads it — shipped iOS builds decode it as a + // non-optional Bool, so every emitter (this preview included) must keep + // sending the key, and requiredness is what makes the compiler say so. + adoptableAttached: false, }; } @@ -3619,7 +3629,6 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { freshProject: false, }), detectDefaults: resolved({} as any), - detectExistingLanes: resolved([]), setDismissed: resolvedArg({ completedAt: null, dismissedAt: new Date().toISOString(), @@ -4250,13 +4259,6 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { previousBranchRef: "main", activeWork: [], }), - attach: resolvedArg({ id: "mock", name: "mock" }), - adoptAttached: resolvedArg({ id: "mock", name: "mock" }), - listUnregisteredWorktrees: resolved([ - { path: "/Users/you/code/app-login", branch: "feat/login" }, - { path: "/Users/you/code/app-api-fix", branch: "bugfix/api-timeout" }, - { path: "/Users/you/experiments/spike", branch: "spike/new-renderer" }, - ]), rename: resolvedArg(undefined), reparent: resolvedArg({}), updateAppearance: resolvedArg(undefined), diff --git a/apps/desktop/src/renderer/components/automations/adeActionSchemas.ts b/apps/desktop/src/renderer/components/automations/adeActionSchemas.ts index cb2e4d7de..a706f7a06 100644 --- a/apps/desktop/src/renderer/components/automations/adeActionSchemas.ts +++ b/apps/desktop/src/renderer/components/automations/adeActionSchemas.ts @@ -103,24 +103,6 @@ export const ADE_ACTION_SCHEMAS: readonly AdeActionSchema[] = [ { name: "baseBranch", type: "string", description: "Base branch to track for sync status." }, ], }, - { - domain: "lane", - action: "attach", - label: "Attach external worktree", - description: "Register an existing checkout outside ADE as a managed lane.", - params: [ - { name: "name", type: "string", required: true }, - { name: "attachedPath", type: "string", required: true, description: "Absolute path to the existing worktree." }, - { name: "description", type: "string" }, - ], - }, - { - domain: "lane", - action: "adoptAttached", - label: "Adopt attached lane", - description: "Promote an attached worktree into a fully-managed ADE lane.", - params: [LANE_ID_PARAM], - }, { domain: "lane", action: "delete", @@ -182,13 +164,6 @@ export const ADE_ACTION_SCHEMAS: readonly AdeActionSchema[] = [ description: "Return the ancestor chain of the lane from root down to the lane itself.", params: [LANE_ID_PARAM], }, - { - domain: "lane", - action: "listUnregisteredWorktrees", - label: "List unregistered worktrees", - description: "Find worktrees on disk that ADE does not yet track as lanes.", - params: [], - }, { domain: "lane", action: "refreshSnapshots", diff --git a/apps/desktop/src/renderer/components/lanes/AdoptAttachedLaneConfirmDialog.tsx b/apps/desktop/src/renderer/components/lanes/AdoptAttachedLaneConfirmDialog.tsx deleted file mode 100644 index 85c987f7b..000000000 --- a/apps/desktop/src/renderer/components/lanes/AdoptAttachedLaneConfirmDialog.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import type { LaneSummary } from "../../../shared/types"; -import { - COLORS, - LABEL_STYLE, - MONO_FONT, - outlineButton, - primaryButton, -} from "./laneDesignTokens"; - -export function AdoptAttachedLaneConfirmDialog({ - open, - lane, - busy, - error, - onCancel, - onConfirm, -}: { - open: boolean; - lane: LaneSummary | null; - busy: boolean; - error: string | null; - onCancel: () => void; - onConfirm: () => void; -}) { - if (!open) return null; - - return ( -
-
-
MOVE ATTACHED LANE
-
- Move {lane?.name ?? "this lane"} into .ade/worktrees. -
-
- ADE uses git worktree move, so branch history and commits stay exactly the same. -
- {lane ? ( -
-
Current path
-
- {lane.worktreePath} -
-
- ) : null} - {error ? ( -
- {error} -
- ) : null} -
- - -
-
-
- ); -} diff --git a/apps/desktop/src/renderer/components/lanes/AttachLaneDialog.tsx b/apps/desktop/src/renderer/components/lanes/AttachLaneDialog.tsx deleted file mode 100644 index 26f89a715..000000000 --- a/apps/desktop/src/renderer/components/lanes/AttachLaneDialog.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import { Link, WarningCircle } from "@phosphor-icons/react"; -import { Button } from "../ui/Button"; -import { LaneDialogShell } from "./LaneDialogShell"; -import { SECTION_CLASS_NAME, LABEL_CLASS_NAME, INPUT_CLASS_NAME } from "./laneDialogTokens"; - -export function AttachLaneDialog({ - open, - onOpenChange, - attachName, - setAttachName, - attachPath, - setAttachPath, - attachDescription, - setAttachDescription, - busy, - error, - onSubmit -}: { - open: boolean; - onOpenChange: (open: boolean) => void; - attachName: string; - setAttachName: (v: string) => void; - attachPath: string; - setAttachPath: (v: string) => void; - attachDescription: string; - setAttachDescription: (v: string) => void; - busy: boolean; - error: string | null; - onSubmit: () => void; -}) { - return ( - -
-
-
- ADE will keep the existing files where they are and start tracking the worktree as a lane in this project. -
-
- -
- -
- -
- -

- Enter the absolute path to an existing Git worktree directory. -

-
- Example: /Users/you/repo-worktrees/feature-auth -
-
- -
-