diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index 36e487017..da8cd04e2 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -382,7 +382,8 @@ ade terminal resume --terminal session-id --text ade new chat --mode chat --lane lane-id --provider codex --model openai/gpt-5.6-sol --reasoning-effort xhigh --no-fast --permissions full-auto --prompt "fix failing tests" ade new chat --mode cli --lane lane-id --provider codex --model openai/gpt-5.6-sol --reasoning-effort xhigh --no-fast --permissions full-auto --prompt "fix failing tests" ade new chat --mode chat --lane auto --lane-name fix-checkout-flow --prompt "fix failing tests" -ade new chat --mode chat --lane lane-id --type subagent --prompt "repro the flake" # --type subagent|peer|none (chat mode only): cosmetic relationship + completion-report policy — subagent wakes the parent on completion, peer leaves a quiet note, none (default) is silent; a typed agent is still a full agent +ade new chat --mode chat --lane lane-id --type subagent --prompt "repro the flake" # --type subagent|peer|none: cosmetic relationship + completion-report policy — subagent wakes the parent on completion, peer leaves a quiet note, none (default) is silent; a typed agent is still a full agent +ade new chat --mode cli --lane lane-id --provider codex --type peer --parent chat-session-id --prompt "review the diff" # agent-provider CLI sessions record spawn lineage without becoming attached terminals; shell sessions do not record lineage ade chat list --lane lane-id --include-automation --no-archived --text ade chat create --lane lane-id --provider codex --model openai/gpt-5.6-sol --permissions full-auto --print-config --json ade chat create --lane lane-id --provider codex --no-parent # spawned chats default their parent to $ADE_CHAT_SESSION_ID; --parent overrides, --no-parent opts out diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index 14ca2a6d4..abc88e2f1 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -1763,6 +1763,61 @@ describe("adeRpcServer", () => { }); }); + it("persists CLI spawn lineage in resume metadata without assigning terminal ownership", async () => { + const fixture = createRuntime(); + fixture.runtime.sessionService.get.mockReturnValue({ + id: "session-1", + laneId: "lane-1", + ptyId: "pty-1", + tracked: true, + toolType: "codex", + title: "Codex", + status: "running", + resumeCommand: null, + resumeMetadata: { + provider: "codex", + targetKind: "thread", + targetId: null, + launch: { permissionMode: "default" }, + orchestrationParentSessionId: "parent-session-1", + spawnKind: "peer", + }, + chatSessionId: null, + orchestrationParentSessionId: "parent-session-1", + spawnKind: "peer", + }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + + await initialize(handler, { role: "orchestrator" }); + const response = await callTool(handler, "start_cli_session", { + laneId: "lane-1", + provider: "codex", + orchestrationParentSessionId: " parent-session-1 ", + spawnKind: "peer", + }); + + expect(response?.isError).toBeUndefined(); + const createCall = fixture.runtime.ptyService.create.mock.calls.at(-1)?.[0]; + expect(createCall).toEqual(expect.objectContaining({ + resumeMetadata: expect.objectContaining({ + provider: "codex", + targetKind: "thread", + orchestrationParentSessionId: "parent-session-1", + spawnKind: "peer", + }), + spawnLineage: { + parentChatSessionId: "parent-session-1", + spawnKind: "peer", + }, + })); + expect(createCall).not.toHaveProperty("chatSessionId"); + expect(response.structuredContent.session).toEqual(expect.objectContaining({ + orchestrationParentSessionId: "parent-session-1", + spawnKind: "peer", + chatSessionId: null, + })); + }); + it("preserves wide start_cli_session terminal dimensions", async () => { const fixture = createRuntime(); fixture.runtime.sessionService.get.mockReturnValue({ diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index dcd468ff2..a333d1103 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -59,7 +59,7 @@ import { type LaunchProfile, type TrackedCliLaunchCommand, } from "../../desktop/src/shared/cliLaunch"; -import type { AgentChatPermissionMode, TerminalSessionSummary } from "../../desktop/src/shared/types"; +import type { AgentChatPermissionMode, AgentChatSpawnKind, TerminalResumeMetadata, TerminalSessionSummary } from "../../desktop/src/shared/types"; import type { AdeRuntime } from "./bootstrap"; import { recordUsageInteraction, @@ -72,6 +72,7 @@ import { getSharedModelPickerStore } from "./services/modelPickerStore"; import { resolveLaneCreateRemoteBase } from "./services/laneCreateRemoteBase"; import { BUILT_IN_BROWSER_ACTOR_CAPABILITY_PARAM } from "./services/builtInBrowser/desktopBridgeMethods"; import { resolveCodexComputerUseMcpConfig } from "../../desktop/src/main/utils/codexComputerUse"; +import { parseTrackedCliLaunchConfig } from "../../desktop/src/main/utils/terminalSessionSignals"; // Cross-surface (desktop + TUI + iOS) model picker favorites & recents. // Backed by the per-project cr-sqlite CRR DB (runtime.db) so the three surfaces @@ -337,6 +338,8 @@ const TOOL_SPECS: ToolSpec[] = [ codexFastMode: { type: "boolean", deprecated: true }, cwd: { type: "string" }, chatSessionId: { type: "string" }, + orchestrationParentSessionId: { type: "string", minLength: 1 }, + spawnKind: { type: "string", enum: ["subagent", "peer", "none"] }, tracked: { type: "boolean", default: true } } } @@ -1708,6 +1711,18 @@ function parseCliSessionPermissionMode(value: unknown): AgentChatPermissionMode ); } +function parseCliSessionSpawnKind(value: unknown): AgentChatSpawnKind | null { + if (value == null) return null; + const spawnKind = asTrimmedString(value).toLowerCase(); + if (spawnKind === "subagent" || spawnKind === "peer" || spawnKind === "none") { + return spawnKind; + } + throw new JsonRpcError( + JsonRpcErrorCode.invalidParams, + "spawnKind must be one of subagent, peer, or none", + ); +} + function clampInteger(value: unknown, fallback: number, min: number, max: number): number { const raw = typeof value === "number" && Number.isFinite(value) ? value : fallback; return Math.max(min, Math.min(max, Math.floor(raw))); @@ -3589,6 +3604,10 @@ async function runTool(args: { const laneId = assertNonEmptyString(toolArgs.laneId, "laneId"); const provider = parseCliSessionProvider(toolArgs.provider); const permissionMode = parseCliSessionPermissionMode(toolArgs.permissionMode); + const orchestrationParentSessionId = toolArgs.orchestrationParentSessionId == null + ? null + : assertNonEmptyString(toolArgs.orchestrationParentSessionId, "orchestrationParentSessionId"); + const spawnKind = parseCliSessionSpawnKind(toolArgs.spawnKind); try { validateLaunchProfilePermissionMode(provider, permissionMode); } catch (err) { @@ -3634,6 +3653,17 @@ async function runTool(args: { ...(provider === "codex" ? { codexComputerUse } : {}), }); })(); + const toolType = LAUNCH_PROFILE_TOOL_TYPE[provider]; + const resumeMetadata: TerminalResumeMetadata | null = isCliProvider(provider) + ? { + provider, + targetKind: provider === "codex" ? "thread" : "session", + targetId: provider === "claude" ? preassignedSessionId ?? null : null, + launch: parseTrackedCliLaunchConfig(launchFields.startupCommand ?? "", toolType) ?? {}, + ...(orchestrationParentSessionId ? { orchestrationParentSessionId } : {}), + ...(spawnKind ? { spawnKind } : {}), + } + : null; const created = await ptyService.create({ ...(preassignedSessionId ? { sessionId: preassignedSessionId } : {}), @@ -3643,7 +3673,11 @@ async function runTool(args: { rows, title, tracked: toolArgs.tracked !== false, - toolType: LAUNCH_PROFILE_TOOL_TYPE[provider], + toolType, + ...(resumeMetadata ? { resumeMetadata } : {}), + ...(isCliProvider(provider) && orchestrationParentSessionId + ? { spawnLineage: { parentChatSessionId: orchestrationParentSessionId, spawnKind } } + : {}), ...(asOptionalTrimmedString(toolArgs.cwd) ? { cwd: asOptionalTrimmedString(toolArgs.cwd)! } : {}), ...(asOptionalTrimmedString(toolArgs.chatSessionId) ? { chatSessionId: asOptionalTrimmedString(toolArgs.chatSessionId) } : {}), ...launchFields, diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 37cf43a83..d073b88ab 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -2442,6 +2442,90 @@ describe("ADE CLI", () => { expect((staticPlan.value as { launch: Record }).launch.orchestrationParentSessionId) .toBe("parent-session-1"); }); + + it("ade new chat --mode cli records the env parent as spawn lineage without terminal ownership", () => { + process.env.ADE_CHAT_SESSION_ID = "parent-session-1"; + const plan = buildCliPlan([ + "new", "chat", + "--mode", "cli", + "--lane", "lane-1", + "--provider", "codex", + "--type", "peer", + "--print-config", + ]); + const staticPlan = expectStaticPlan(plan); + const launch = (staticPlan.value as { launch: Record }).launch; + expect(launch.orchestrationParentSessionId).toBe("parent-session-1"); + expect(launch.spawnKind).toBe("peer"); + expect(launch).not.toHaveProperty("chatSessionId"); + }); + + it("ade new chat --mode cli --no-parent leaves the terminal unlinked", () => { + process.env.ADE_CHAT_SESSION_ID = "parent-session-1"; + const plan = buildCliPlan([ + "new", "chat", + "--mode", "cli", + "--lane", "lane-1", + "--provider", "codex", + "--no-parent", + "--print-config", + ]); + const staticPlan = expectStaticPlan(plan); + const launch = (staticPlan.value as { launch: Record }).launch; + expect(launch).not.toHaveProperty("orchestrationParentSessionId"); + expect(launch).not.toHaveProperty("chatSessionId"); + }); + + it("--auto-create-lane keeps --parent as the spawn-lineage session, not the lane parent", () => { + delete process.env.ADE_CHAT_SESSION_ID; + const plan = buildCliPlan([ + "new", "chat", + "--mode", "chat", + "--auto-create-lane", + "--lane-name", "spawn-target", + "--provider", "codex", + "--parent", "parent-session-1", + "--print-config", + ]); + const staticPlan = expectStaticPlan(plan); + const value = staticPlan.value as { launch: Record; createLane?: Record }; + expect(value.launch.orchestrationParentSessionId).toBe("parent-session-1"); + expect(value.createLane ?? {}).not.toHaveProperty("parentLaneId"); + }); + + it("--auto-create-lane --parent-lane still sets the lane parent without touching lineage", () => { + delete process.env.ADE_CHAT_SESSION_ID; + const plan = buildCliPlan([ + "new", "chat", + "--mode", "chat", + "--auto-create-lane", + "--lane-name", "spawn-target", + "--provider", "codex", + "--parent-lane", "lane-parent-1", + "--print-config", + ]); + const staticPlan = expectStaticPlan(plan); + const value = staticPlan.value as { launch: Record; createLane?: Record }; + expect(value.createLane?.parentLaneId).toBe("lane-parent-1"); + expect(value.launch).not.toHaveProperty("orchestrationParentSessionId"); + }); + + it("does not record spawn lineage for plain shell terminals", () => { + process.env.ADE_CHAT_SESSION_ID = "parent-session-1"; + const plan = buildCliPlan([ + "new", "chat", + "--mode", "cli", + "--lane", "lane-1", + "--provider", "shell", + "--type", "peer", + "--print-config", + ]); + const staticPlan = expectStaticPlan(plan); + const launch = (staticPlan.value as { launch: Record }).launch; + expect(launch.provider).toBe("shell"); + expect(launch).not.toHaveProperty("orchestrationParentSessionId"); + expect(launch).not.toHaveProperty("spawnKind"); + }); }); it("prints chat create --prompt dry-run with the follow-up send", () => { @@ -5680,6 +5764,15 @@ describe("ADE CLI", () => { expect(newChatHelp.kind).toBe("help"); if (newChatHelp.kind !== "help") return; expect(newChatHelp.text).toContain("--type "); + expect(newChatHelp.text).toContain("Override with --parent "); + expect(newChatHelp.text).toContain("plain shell terminals don't record lineage"); + + const newHelp = buildCliPlan(["new", "--help"]); + expect(newHelp.kind).toBe("help"); + if (newHelp.kind !== "help") return; + expect(newHelp.text).toContain("--type "); + expect(newHelp.text).toContain("--parent "); + expect(newHelp.text).toContain("Plain shell terminals do not record lineage"); const laneCommandHelp = buildCliPlan(["help", "lanes"]); expect(laneCommandHelp.kind).toBe("help"); diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 123d3fadb..206534e44 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -1378,6 +1378,12 @@ const HELP_BY_COMMAND: Record = { --no-fast, --standard Disable fast service tier explicitly. --prompt First chat message or CLI initial input. + Spawn lineage: + In tracked agent shells, chat mode and agent-provider CLI mode default + their parent to $ADE_CHAT_SESSION_ID. Use --parent to override + it or --no-parent to opt out. CLI terminals record lineage without taking + attached-terminal ownership. Plain shell terminals do not record lineage. + Compatibility: ade chat create still creates persistent Work chats. ade shell start-cli still starts tracked provider CLI sessions. @@ -1397,6 +1403,12 @@ const HELP_BY_COMMAND: Record = { The command defaults to the current ADE lane when ADE_LANE_ID is set. Use --auto-create-lane or --lane auto to create a lane before launching. --type sets only the cosmetic relationship and completion-report policy; a typed agent is a full agent. subagent wakes the parent, peer leaves a quiet note, and none (default) sends no report. + + Spawn lineage: when run from a tracked agent shell (ADE_CHAT_SESSION_ID set), + the new session links back to the spawning chat. In CLI mode, the terminal + shows that spawn lineage on its own sidebar card and links back to the chat + (agent providers only — plain shell terminals don't record lineage). + Override with --parent ; opt out with --no-parent. `, lanes: `${ADE_BANNER} Lanes @@ -2535,12 +2547,12 @@ function readLaneId(args: string[]): string | null { } /** - * Parent chat-session lineage for spawned chat sessions. Defaults to the - * spawning agent's own session — ADE injects ADE_CHAT_SESSION_ID into every - * tracked agent shell (chat runtimes and tracked CLI/PTY sessions) — so a - * child created via `ade chat create` links back to the chat that spawned it - * instead of becoming an orphan. `--parent ` overrides the - * default; `--no-parent` opts out entirely. + * Parent chat-session lineage for spawned chat and agent-provider CLI + * sessions. Defaults to the spawning agent's own session — ADE injects + * ADE_CHAT_SESSION_ID into every tracked agent shell (chat runtimes and + * tracked CLI/PTY sessions). `--parent ` overrides the default; + * `--no-parent` opts out entirely. CLI callers keep chatSessionId separate + * because that field represents attached-terminal ownership, not lineage. */ function readParentSessionId(args: string[]): string | undefined { const override = readValue(args, ["--parent", "--parent-session", "--parent-session-id"]); @@ -4163,7 +4175,11 @@ function resolveNewChatLaneArgs(args: string[], prompt: string | null): { maybePut(createLaneArgs, "description", readValue(args, ["--description", "--desc"])); maybePut(createLaneArgs, "baseBranch", readValue(args, ["--base", "--base-branch"])); maybePut(createLaneArgs, "branchName", readValue(args, ["--branch-name"])); - maybePut(createLaneArgs, "parentLaneId", readValue(args, ["--parent", "--parent-lane", "--parent-lane-id"])); + // No bare `--parent` alias here: in `ade new chat` that flag is documented as + // the spawn-lineage parent SESSION (readParentSessionId), and this resolver + // runs first — consuming it would silently eat the lineage flag whenever + // --auto-create-lane is used. Lane parentage keeps the explicit aliases. + maybePut(createLaneArgs, "parentLaneId", readValue(args, ["--parent-lane", "--parent-lane-id"])); return { laneId: null, autoCreateLane: true, createLaneArgs }; } @@ -4184,7 +4200,7 @@ function buildNewChatPlan(args: string[], defaultMode: "chat" | "cli"): CliPlan const reasoningEffort = readValue(args, ["--reasoning-effort", "--effort", "--reasoning"]); const permissionMode = readValue(args, ["--permission-mode", "--permissions"]); const spawnTypeArg = readValue(args, ["--type", "--spawn-type"]); - const spawnKind = mode === "chat" ? spawnTypeArg?.trim().toLowerCase() : undefined; + const spawnKind = spawnTypeArg?.trim().toLowerCase(); const fastMode = readFastModeFlag(args); const title = readValue(args, ["--title"]); const printConfig = readFlag(args, ["--print-config", "--dry-run"]); @@ -4218,10 +4234,10 @@ function buildNewChatPlan(args: string[], defaultMode: "chat" | "cli"): CliPlan }; // Consume the flags in both modes so they never leak into the generic arg - // bag; lineage only applies to chat mode (a PTY session is not a chat - // record, so there is nothing to link). + // bag. Both chat and CLI modes record the same spawn lineage fields; CLI + // sessions keep chatSessionId free for true attached-terminal ownership. const parentSessionId = readParentSessionId(args); - const orchestrationParentSessionId = mode === "chat" ? parentSessionId : undefined; + const orchestrationParentSessionId = parentSessionId; const launchArgs = mode === "chat" ? collectGenericObjectArgs(args, { provider, @@ -4249,6 +4265,10 @@ function buildNewChatPlan(args: string[], defaultMode: "chat" | "cli"): CliPlan modelId: modelArg, reasoningEffort, ...(fastMode !== undefined ? { fastMode, codexFastMode: fastMode } : {}), + // Spawn lineage rides on resume metadata, which only agent providers + // have — plain shell terminals can't persist it, so don't pretend. + ...(provider !== "shell" && orchestrationParentSessionId ? { orchestrationParentSessionId } : {}), + ...(provider !== "shell" && spawnKind ? { spawnKind } : {}), cols: readIntOption(args, ["--cols"], 120), rows: readIntOption(args, ["--rows"], 36), cwd: readValue(args, ["--cwd"]), diff --git a/apps/ade-cli/src/tuiClient/__tests__/Drawer.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/Drawer.test.tsx index 8cb0b914b..c6d0c2037 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/Drawer.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/Drawer.test.tsx @@ -134,6 +134,25 @@ describe("Drawer closed CLI sessions", () => { }); }); + it("projects spawn lineage from tracked CLI resume metadata", () => { + const summary = terminalSessionToChatSummary(terminal({ + terminalId: "cli-subagent", + resumeMetadata: { + provider: "codex", + targetKind: "session", + targetId: "cli-subagent", + launch: {}, + orchestrationParentSessionId: "parent-chat", + spawnKind: "subagent", + }, + })); + + expect(summary).toMatchObject({ + orchestrationParentSessionId: "parent-chat", + spawnKind: "subagent", + }); + }); + it.each([ ["failed status", { status: "failed", exitCode: 1, runtimeState: "killed" }, "failed"], ["non-zero exit", { status: "completed", exitCode: 2, runtimeState: "exited" }, "failed"], @@ -172,6 +191,61 @@ describe("Drawer closed CLI sessions", () => { }); describe("Drawer lane and chat navigation layout", () => { + it("renders compact spawn-kind markers for chat and tracked CLI rows", () => { + const chat: AgentChatSessionSummary = { + sessionId: "chat-subagent", + laneId: "lane-1", + provider: "claude", + model: "claude-code", + title: "Chat child", + status: "idle", + startedAt: "2026-05-12T11:30:00.000Z", + endedAt: null, + lastActivityAt: "2026-05-12T11:31:00.000Z", + lastOutputPreview: null, + summary: null, + nextWakeAt: null, + orchestrationParentSessionId: "parent-chat", + spawnKind: "subagent", + }; + const cli = terminalSessionToChatSummary(terminal({ + terminalId: "cli-peer", + resumeMetadata: { + provider: "codex", + targetKind: "session", + targetId: "cli-peer", + launch: {}, + orchestrationParentSessionId: "parent-chat", + spawnKind: "peer", + }, + })); + + const baseProps = { + lanes: [lane("lane-1", "Feature", "feature/spawn-kind", "2026-05-12T11:55:00.000Z")], + activeLaneId: "lane-1", + activeSessionId: null, + browsingLaneId: "lane-1", + selectedLaneIndex: 0, + selectedChatIndex: -1, + panelHeight: 30, + }; + const chatFrame = stripAnsi(render( + , + ).lastFrame() ?? ""); + const cliFrame = stripAnsi(render( + , + ).lastFrame() ?? ""); + + expect(chatFrame).toContain("Chat child sub"); + expect(cliFrame).toContain("Codex CLI peer"); + }); + it("puts the primary lane first and removes old header/footer controls", () => { const frame = stripAnsi(render( {" "} : {dot.glyph} } {exec.glyph} {label} + {spawnLabel ? ( + {` ${spawnLabel}`} + ) : null} {tag ? {` ${tag}`} : null} {wake ? {` ⏰${wake}`} : null} {running ? : null} diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index a695cb64d..ba00a9b61 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -886,6 +886,63 @@ describe("ptyService", () => { expect(resolveBuiltInBrowserActorCapability(actorToken)).toBeNull(); }); + it("exports spawn lineage without replacing the tracked CLI session identity", async () => { + const { service, loadPty } = createHarness(); + + const result = await service.create({ + laneId: "lane-1", + title: "Codex child", + cols: 80, + rows: 24, + toolType: "codex", + command: "codex", + spawnLineage: { + parentChatSessionId: "parent-session-1", + spawnKind: null, + }, + }); + + const ptyLib = loadPty.mock.results.at(-1)?.value as { spawn: ReturnType }; + const spawnArgs = ptyLib.spawn.mock.calls.at(-1); + const opts = spawnArgs?.[2] as { env?: NodeJS.ProcessEnv } | undefined; + expect(opts?.env).toEqual(expect.objectContaining({ + ADE_CHAT_SESSION_ID: result.sessionId, + ADE_PARENT_CHAT_SESSION_ID: "parent-session-1", + ADE_SPAWN_KIND: "", + })); + expect(opts?.env?.ADE_CHAT_SESSION_ID).not.toBe("parent-session-1"); + }); + + it("strips inherited parent lineage env when the launch has no spawn lineage", async () => { + const previousParent = process.env.ADE_PARENT_CHAT_SESSION_ID; + const previousKind = process.env.ADE_SPAWN_KIND; + // The daemon can itself run inside a spawned agent shell; those inherited + // vars must not leak into terminals created without lineage. + process.env.ADE_PARENT_CHAT_SESSION_ID = "inherited-parent"; + process.env.ADE_SPAWN_KIND = "subagent"; + try { + const { service, loadPty } = createHarness(); + await service.create({ + laneId: "lane-1", + title: "Codex plain", + cols: 80, + rows: 24, + toolType: "codex", + command: "codex", + }); + const ptyLib = loadPty.mock.results.at(-1)?.value as { spawn: ReturnType }; + const opts = ptyLib.spawn.mock.calls.at(-1)?.[2] as { env?: NodeJS.ProcessEnv } | undefined; + expect(opts?.env).toBeDefined(); + expect(opts?.env).not.toHaveProperty("ADE_PARENT_CHAT_SESSION_ID"); + expect(opts?.env).not.toHaveProperty("ADE_SPAWN_KIND"); + } finally { + if (previousParent === undefined) delete process.env.ADE_PARENT_CHAT_SESSION_ID; + else process.env.ADE_PARENT_CHAT_SESSION_ID = previousParent; + if (previousKind === undefined) delete process.env.ADE_SPAWN_KIND; + else process.env.ADE_SPAWN_KIND = previousKind; + } + }); + it("refuses only new tracked CLI launches when storage is exhausted", async () => { let exhausted = false; const canPerform = vi.fn(() => exhausted @@ -2063,6 +2120,53 @@ describe("ptyService", () => { expect(sessionService.create).toHaveBeenCalledTimes(createCallsBeforeResume); }); + it("restores spawn-lineage env from persisted resume metadata on resume", async () => { + const { service, sessionService, loadPty } = createHarness(); + sessionService.create({ + sessionId: "session-spawned", + laneId: "lane-1", + ptyId: null, + tracked: true, + title: "Codex CLI", + startedAt: "2026-04-09T12:00:00.000Z", + transcriptPath: "/tmp/transcripts/session-spawned.log", + toolType: "codex", + resumeCommand: "codex --no-alt-screen resume thread-spawned", + resumeMetadata: { + provider: "codex", + targetKind: "thread", + targetId: "thread-spawned", + launch: { permissionMode: "config-toml" }, + orchestrationParentSessionId: "parent-session-1", + spawnKind: "subagent", + }, + }); + sessionService.end({ + sessionId: "session-spawned", + endedAt: "2026-04-09T12:30:00.000Z", + exitCode: 0, + status: "completed", + }); + + await service.create({ + sessionId: "session-spawned", + laneId: "lane-1", + title: "Codex CLI", + cols: 80, + rows: 24, + toolType: "codex", + startupCommand: "codex --no-alt-screen resume thread-spawned", + }); + + const ptyLib = loadPty.mock.results.at(-1)?.value as { spawn: ReturnType }; + const opts = ptyLib.spawn.mock.calls.at(-1)?.[2] as { env?: NodeJS.ProcessEnv } | undefined; + expect(opts?.env).toEqual(expect.objectContaining({ + ADE_PARENT_CHAT_SESSION_ID: "parent-session-1", + ADE_SPAWN_KIND: "subagent", + })); + expect(opts?.env?.ADE_CHAT_SESSION_ID).toBe("session-spawned"); + }); + it("backfills a targetless Claude resume command before launching the resumed PTY", async () => { (mocks.extractResumeCommandFromOutput as any).mockReturnValueOnce("claude --resume claude-session-123"); const { service, sessionService, mockPty } = createHarness(); diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index 576e7b1db..a686431e1 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -371,6 +371,7 @@ function withAdeTerminalContextEnv(env: NodeJS.ProcessEnv, args: { laneId: string; chatSessionId: string | null; ownerSessionId?: string | null; + spawnLineage?: PtyCreateArgs["spawnLineage"]; }): NodeJS.ProcessEnv { const next: NodeJS.ProcessEnv = { ...env, @@ -390,6 +391,15 @@ function withAdeTerminalContextEnv(env: NodeJS.ProcessEnv, args: { delete next.ADE_CHAT_SESSION_ID; delete next.ADE_BROWSER_ACTOR_TOKEN; } + if (args.spawnLineage) { + next.ADE_PARENT_CHAT_SESSION_ID = args.spawnLineage.parentChatSessionId; + next.ADE_SPAWN_KIND = args.spawnLineage.spawnKind ?? ""; + } else { + // The daemon itself may run inside a spawned agent shell that inherited + // these; without lineage they must not leak into unrelated terminals. + delete next.ADE_PARENT_CHAT_SESSION_ID; + delete next.ADE_SPAWN_KIND; + } return next; } @@ -3645,11 +3655,25 @@ export function createPtyService({ if (explicitNoColor && !explicitForceColor) { delete baseLaunchEnv.FORCE_COLOR; } + // Resume launches re-enter create() without args.spawnLineage — recover + // it from the persisted resume metadata so a resumed spawned CLI keeps + // its parent env (self-reporting + nested lineage). + const persistedParentSessionId = isTrackedAgentCliToolType(toolTypeHint) + ? existingSession?.resumeMetadata?.orchestrationParentSessionId?.trim() || null + : null; + const effectiveSpawnLineage = args.spawnLineage + ?? (persistedParentSessionId + ? { + parentChatSessionId: persistedParentSessionId, + spawnKind: existingSession?.resumeMetadata?.spawnKind ?? null, + } + : null); const contextLaunchEnv = withAdeTerminalContextEnv(baseLaunchEnv, { projectRoot, laneId, chatSessionId, ownerSessionId: isTrackedAgentCliToolType(toolTypeHint) ? sessionId : null, + spawnLineage: effectiveSpawnLineage, }); let launchEnv = withInteractiveTerminalColorEnv( getAdeCliAgentEnv?.(contextLaunchEnv) ?? contextLaunchEnv, diff --git a/apps/desktop/src/main/services/sessions/sessionService.test.ts b/apps/desktop/src/main/services/sessions/sessionService.test.ts index d62188b48..714753e49 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.test.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.test.ts @@ -150,6 +150,55 @@ describe("sessionService resume metadata", () => { activeDisposers.push(async () => db.close()); }); + it("projects tracked CLI spawn lineage from resume metadata without assigning a chat owner", async () => { + const projectRoot = makeProjectRoot("ade-session-service-lineage-"); + const dbPath = path.join(projectRoot, ".ade", "ade.db"); + const db = await openKvDb(dbPath, createLogger() as any); + activeDisposers.push(async () => db.close()); + insertProjectGraph(db); + const service = createSessionService({ db }); + + service.create({ + sessionId: "session-lineage", + laneId: "lane-1", + ptyId: null, + tracked: true, + title: "Codex CLI child", + startedAt: "2026-07-18T04:10:54.789Z", + transcriptPath: "/tmp/session-lineage.log", + toolType: "codex", + resumeMetadata: { + provider: "codex", + targetKind: "thread", + targetId: null, + launch: { permissionMode: "default" }, + orchestrationParentSessionId: "parent-session-1", + spawnKind: "subagent", + }, + }); + + expect(service.list({ laneId: "lane-1" })).toEqual([ + expect.objectContaining({ + id: "session-lineage", + chatSessionId: null, + orchestrationParentSessionId: "parent-session-1", + spawnKind: "subagent", + }), + ]); + + service.setResumeCommand("session-lineage", "codex resume thread-1"); + expect(service.get("session-lineage")).toEqual(expect.objectContaining({ + chatSessionId: null, + orchestrationParentSessionId: "parent-session-1", + spawnKind: "subagent", + resumeMetadata: expect.objectContaining({ + targetId: "thread-1", + orchestrationParentSessionId: "parent-session-1", + spawnKind: "subagent", + }), + })); + }); + it("preserves Codex approval and sandbox settings when rebuilding the resume command", 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 2b71bc7c9..f025cf5ea 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.ts @@ -11,8 +11,9 @@ import type { TerminalSessionSummary, TerminalToolType, ListSessionsArgs, - UpdateSessionMetaArgs + UpdateSessionMetaArgs, } from "../../../shared/types"; +import { isTrackedAgentCliToolType } from "../../../shared/types"; import { stripAnsi } from "../../utils/ansiStrip"; import { readHistoryFileSync } from "../storage/historyCompression"; import { @@ -152,6 +153,12 @@ function normalizeResumeMetadata(raw: unknown): TerminalResumeMetadata | null { const importedFromAt = typeof importedFromRecord?.importedAt === "string" && importedFromRecord.importedAt.trim().length ? importedFromRecord.importedAt.trim() : null; + const orchestrationParentSessionId = typeof record.orchestrationParentSessionId === "string" + ? record.orchestrationParentSessionId.trim() + : ""; + const spawnKind = record.spawnKind === "subagent" || record.spawnKind === "peer" || record.spawnKind === "none" + ? record.spawnKind + : null; if (!provider || !targetKind) return null; return { provider, @@ -178,6 +185,8 @@ function normalizeResumeMetadata(raw: unknown): TerminalResumeMetadata | null { : {}), ...(legacyTarget ? { target: legacyTarget } : {}), ...(permissionMode ? { permissionMode } : {}), + ...(orchestrationParentSessionId ? { orchestrationParentSessionId } : {}), + ...(spawnKind ? { spawnKind } : {}), }; } @@ -363,6 +372,12 @@ export function createSessionService({ db }: { db: AdeDb }) { chatSessionId: row.chatSessionId ?? null, ownerPid: normalizeOwnerPid(row.ownerPid), ownerProcessStartedAt: normalizeOwnerProcessStartedAt(row.ownerProcessStartedAt), + ...(isTrackedAgentCliToolType(toolType) && resumeMetadata?.orchestrationParentSessionId + ? { orchestrationParentSessionId: resumeMetadata.orchestrationParentSessionId } + : {}), + ...(isTrackedAgentCliToolType(toolType) && resumeMetadata?.spawnKind + ? { spawnKind: resumeMetadata.spawnKind } + : {}), }; }; @@ -1059,6 +1074,7 @@ export function createSessionService({ db }: { db: AdeDb }) { : null; const nextMetadata = parsed ? { + ...currentMetadata, provider: parsed.provider, targetKind: parsed.provider === "codex" ? "thread" : "session", targetId: parsed.targetId ?? currentMetadata?.targetId ?? null, diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 98f8fad7d..1083123b1 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -1244,6 +1244,7 @@ function mockAgentChatSummaryFromSession(session: any): any | null { orchestrationRunId: session.orchestrationRunId ?? undefined, orchestrationRole: session.orchestrationRole ?? undefined, orchestrationParentSessionId: session.orchestrationParentSessionId ?? undefined, + spawnKind: session.spawnKind ?? undefined, orchestrationTag: session.orchestrationTag ?? undefined, orchestrationStepId: session.orchestrationStepId ?? undefined, orchestrationBundlePath: session.orchestrationBundlePath ?? undefined, @@ -4517,6 +4518,7 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { updateAppearance: resolvedArg(undefined), archive: resolvedArg(undefined), delete: resolvedArg(undefined), + listDeleteProgress: resolved([]), cancelDelete: resolvedArg({ cancelled: false, reason: "no active delete", diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index bfa4d645d..9a1071374 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -2853,6 +2853,7 @@ export function AgentChatPane({ initialSessionId, initialSessionSummary, lockSessionId, + sessionTitleById, hideSessionTabs = false, hideNativeControls = false, hideModelControls = false, @@ -2899,6 +2900,8 @@ export function AgentChatPane({ initialSessionId?: string | null; initialSessionSummary?: AgentChatSessionSummary | null; lockSessionId?: string | null; + /** Full host-surface title index for locked single-session embeddings. */ + sessionTitleById?: ReadonlyMap; hideSessionTabs?: boolean; hideNativeControls?: boolean; /** Hide model/reasoning/fast controls when the embedding surface owns them. */ @@ -4858,6 +4861,15 @@ export function AgentChatPane({ return { parentId, parentTitle, spawnKind: selectedSession?.spawnKind ?? null }; }, [selectedSession?.orchestrationParentSessionId, selectedSession?.sessionId, selectedSession?.spawnKind, sessions]); + // Resolve a spawned child chat's live title so the Subagents pane's spawned-chat + // rows read as the chat they open, not the bare runtime name. + const resolveSpawnedChatTitle = useCallback( + (id: string): string | null => sessionTitleById?.get(id)?.trim() + || sessions.find((s) => s.sessionId === id)?.title?.trim() + || null, + [sessionTitleById, sessions], + ); + // Keep configured models selectable unless a caller explicitly constrains // this surface. Unconstrained sessions keep their active model visible even // when it has fallen out of the discovered catalog. @@ -9765,6 +9777,7 @@ export function AgentChatPane({ }} onClearSelectedSubagent={() => setSubagentView(null)} probeSubagentTranscript={probeSubagentTranscript} + resolveSpawnedChatTitle={resolveSpawnedChatTitle} capability={selectedSubagentCapability} selectedTaskId={subagentView?.taskId ?? null} goal={selectedSession?.provider === "codex" ? selectedCodexGoal : null} @@ -10266,10 +10279,10 @@ export function AgentChatPane({ ? "border-slate-400/18 bg-slate-400/[0.06] text-slate-300/75 hover:border-slate-300/30 hover:text-slate-100/90" : "border-violet-400/20 bg-violet-400/[0.06] text-violet-200/80 hover:border-violet-300/32 hover:text-violet-100", )} - title={spawnLineage.parentTitle ? `Open spawner: ${spawnLineage.parentTitle}` : "Open spawner"} + title={spawnLineage.parentTitle ? `Parent thread: "${spawnLineage.parentTitle}"` : "View parent thread"} > - from {spawnLineage.parentTitle ? `"${spawnLineage.parentTitle}"` : "spawner"} + View parent thread ) : null} {chatTerminalVisible && selectedSessionId ? ( diff --git a/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.test.tsx b/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.test.tsx index bcaa9fb8a..674ff5e9d 100644 --- a/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.test.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.test.tsx @@ -770,6 +770,117 @@ describe("ChatSubagentsPanel (pane variant)", () => { expect(screen.getByRole("button", { name: "Completed (2)" })).toBeTruthy(); }); + // A spawned ADE child chat row navigates to that chat instead of taking over + // the transcript / opening the inline drawer. The click is gated on either a + // `chat:`-prefixed taskId OR a `spawnKind` (the latter survives the dot-twin + // that can strip the `chat:` prefix — see chatExecutionSummary preservation). + describe("spawned-chat row navigation", () => { + const spawnedChatSnapshot = (overrides: Partial = {}): ChatSubagentSnapshot => ({ + taskId: "chat:child-1", + childSessionId: "child-1", + agentId: "child-1", + agentType: "codex", + description: "Codex Chat", + status: "running", + startedAt: "2026-07-18T04:10:54.789Z", + updatedAt: "2026-07-18T04:10:54.789Z", + summary: null, + spawnKind: "subagent", + ...overrides, + }); + + function withSelectSessionSpy(run: (selectSession: ReturnType) => void): void { + const selectSession = vi.fn(); + const listener = (event: Event) => selectSession((event as CustomEvent).detail); + window.addEventListener("ade:work:select-session", listener); + try { + run(selectSession); + } finally { + window.removeEventListener("ade:work:select-session", listener); + } + } + + it("navigates to the child chat (chat:-prefixed taskId) instead of drawer/takeover", () => { + withSelectSessionSpy((selectSession) => { + const onSelectSubagent = vi.fn<[SubagentSelection], void>(); + const probeSubagentTranscript = vi.fn().mockResolvedValue(true); + render( + , + ); + + fireEvent.click(screen.getByTitle("Open the spawned chat")); + + expect(selectSession).toHaveBeenCalledWith({ sessionId: "child-1", laneId: null }); + expect(onSelectSubagent).not.toHaveBeenCalled(); + expect(probeSubagentTranscript).not.toHaveBeenCalled(); + expect(screen.queryByText(/Transcript not ready yet/)).toBeNull(); + }); + }); + + it("navigates when the taskId is bare but spawnKind is set (post-dot-twin corruption shape)", () => { + withSelectSessionSpy((selectSession) => { + const onSelectSubagent = vi.fn<[SubagentSelection], void>(); + render( + , + ); + + fireEvent.click(screen.getByTitle("Open the spawned chat")); + + expect(selectSession).toHaveBeenCalledWith({ sessionId: "child-1", laneId: null }); + expect(onSelectSubagent).not.toHaveBeenCalled(); + expect(screen.queryByText(/Transcript not ready yet/)).toBeNull(); + }); + }); + + it("shows the resolved live chat title instead of the runtime agentType", () => { + render( + (id === "child-1" ? "Investigate flaky CI" : null)} + />, + ); + + // Resolved title is the row label; the runtime name stays as the small chip. + expect(screen.getByText("Investigate flaky CI")).toBeTruthy(); + expect(screen.getByText("codex")).toBeTruthy(); + }); + + it("falls back to the snapshot description when no live title resolves", () => { + render( + null} + />, + ); + + expect(screen.getByText("Codex Chat")).toBeTruthy(); + expect(screen.getByText("codex")).toBeTruthy(); + }); + }); + it("owns the pane scroller and uses sticky opaque section headers", () => { render(); diff --git a/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx b/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx index 9717fa5aa..2e44526ac 100644 --- a/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx @@ -781,6 +781,7 @@ function SubagentRow({ canViewFullTranscript, onClick, depth = 0, + spawnedChatTitle = null, }: { snapshot: ChatSubagentSnapshot; selected: boolean; @@ -793,10 +794,21 @@ function SubagentRow({ /** Whether this runtime can surface a full child transcript (drives the * drawer's "transcript not ready yet" vs "live details only" footer). */ canViewFullTranscript: boolean; + /** Live title of the spawned child chat this row navigates to, when resolved. + * Non-null marks this as a spawned-chat row (navigates on click). */ + spawnedChatTitle?: string | null; onClick: () => void; }) { - const name = chatSubagentDisplayName(snapshot); - const kindLabel = kindBadge(snapshot); + const isSpawnedChat = snapshot.childSessionId != null; + // Spawned-chat rows read as the chat they open: prefer the resolved live + // session title, then the snapshot description ("Codex Chat") — never the bare + // agentType ("codex"), which becomes the small chip instead. + const name = isSpawnedChat + ? (spawnedChatTitle?.trim() || snapshot.description?.trim() || chatSubagentDisplayName(snapshot)) + : chatSubagentDisplayName(snapshot); + // Spawned-chat rows surface the runtime as the uppercase chip (reusing the + // kindBadge chip idiom); other rows keep their taskType kind chip. + const kindLabel = isSpawnedChat ? (snapshot.agentType?.trim() || null) : kindBadge(snapshot); const color = chatSubagentColor(snapshot.agentId ?? snapshot.taskId); const isRunning = snapshot.status === "running"; const isCompleted = snapshot.status === "completed"; @@ -830,7 +842,7 @@ function SubagentRow({