From 5b5daae3a4e62f0eb8a6f21a435d948d2aa3af62 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:32:48 -0400 Subject: [PATCH 1/3] feat(chat): spawn types + completion reporting + spawned-chat navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a spawner-declared spawn type (subagent | peer | none) that is cosmetic to capabilities — a typed agent is a full ADE agent — and only sets a completion- report policy: a subagent wakes its spawner when it finishes, a peer drops a quiet notice, none (the default for an untyped spawn) stays silent. Delivery is the existing provider-agnostic messageSession(kind:"wake") path plus a subagent self-report guidance line; best-effort, non-durable. Spawned ADE chats are now navigable from their inline card, actions-pane row, and sidebar through one navigateToSpawnedChat helper, with a SUBAGENT/PEER type pill, a live-children badge, and a two-way lineage breadcrumb. spawnKind + orchestrationParentSessionId are projected onto TerminalSessionSummary. The orchestrator's spawnAgent sets the type too. Also adds CLI child-lane guidance: `ade lanes create` / `ade new chat --auto-create-lane` warn when the current lane has unmerged commits (suggesting `ade lanes child` to carry them) and when the resolved base is stale, with an ade-lanes-git decision table. New `ade new chat --type` flag. Docs, skills, and tests updated. Co-Authored-By: Claude Opus 4.8 --- apps/ade-cli/README.md | 2 + apps/ade-cli/src/adeRpcServer.test.ts | 51 +++++ apps/ade-cli/src/adeRpcServer.ts | 8 +- apps/ade-cli/src/cli.test.ts | 75 +++++++ apps/ade-cli/src/cli.ts | 80 ++++++- .../src/services/laneCreateRemoteBase.ts | 53 ++++- .../ade-cli-control-plane/SKILL.md | 13 ++ .../agent-skills/ade-lanes-git/SKILL.md | 12 +- .../ai/tools/orchestrationTools.test.ts | 21 ++ .../services/ai/tools/orchestrationTools.ts | 2 + .../services/chat/agentChatService.test.ts | 197 ++++++++++++++++++ .../main/services/chat/agentChatService.ts | 120 +++++++++-- .../src/main/services/ipc/registerIpc.ts | 7 + .../orchestration/orchestrationDomain.test.ts | 3 + .../orchestration/orchestrationDomain.ts | 1 + .../chat/AgentChatMessageList.test.tsx | 92 ++++++++ .../components/chat/AgentChatMessageList.tsx | 108 +++++++--- .../components/chat/AgentChatPane.tsx | 27 +++ .../components/chat/ChatSubagentsPanel.tsx | 11 + .../chat/SubagentActivityCards.test.tsx | 74 +++++++ .../components/chat/SubagentActivityCards.tsx | 158 ++++++++++---- .../chat/chatTranscriptRows.test.ts | 77 +++++++ .../components/chat/chatTranscriptRows.ts | 80 ++++++- .../components/chat/spawnNavigation.test.ts | 43 ++++ .../components/chat/spawnNavigation.ts | 21 ++ .../components/terminals/SessionCard.test.tsx | 77 +++++++ .../components/terminals/SessionCard.tsx | 59 +++++- .../components/terminals/SessionListPane.tsx | 14 ++ apps/desktop/src/shared/types/chat.ts | 30 +++ apps/desktop/src/shared/types/sessions.ts | 10 + docs/features/agents/README.md | 30 ++- docs/features/chat/README.md | 89 +++++++- docs/features/lanes/stacking.md | 41 ++++ 33 files changed, 1585 insertions(+), 101 deletions(-) create mode 100644 apps/desktop/src/renderer/components/chat/SubagentActivityCards.test.tsx create mode 100644 apps/desktop/src/renderer/components/chat/spawnNavigation.test.ts create mode 100644 apps/desktop/src/renderer/components/chat/spawnNavigation.ts diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index ba450ca5b..b1fc2df82 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -277,6 +277,7 @@ ade init ade lanes list --text 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 ade lanes create "lin-123" --linear-issue-json '{"id":"...","identifier":"LIN-123","title":"...","projectId":"...","projectSlug":"...","teamId":"...","teamKey":"...","stateId":"...","stateName":"Todo","stateType":"unstarted","priority":2,"priorityLabel":"high","labels":[],"assigneeId":null,"assigneeName":null,"createdAt":"...","updatedAt":"..."}' ade lanes reparent lane-child --parent lane-parent --stack-base-branch main ade lanes delete lane-id --force --delete-branch @@ -327,6 +328,7 @@ 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 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 d1207ff3b..e85e9ca7e 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -4170,6 +4170,57 @@ describe("adeRpcServer", () => { ); }); + it("returns a non-blocking stale-base warning when the remote fetch fails", async () => { + const fixture = createRuntime(); + fixture.runtime.laneService.list = vi.fn(async () => [ + { id: "lane-primary", laneType: "primary", baseRef: "main", branchRef: "main" }, + ]) as any; + fixture.runtime.gitService.fetch = vi.fn(async () => { throw new Error("offline"); }); + fixture.runtime.gitService.listBranches = vi.fn(async () => [ + { name: "main", isCurrent: true, isRemote: false, upstream: "origin/main" }, + { name: "origin/main", isCurrent: false, isRemote: true, upstream: null }, + ]) as any; + fixture.runtime.projectConfigService = { + getEffective: vi.fn(() => ({ git: { newLaneBaseSource: "remote" } })), + } as any; + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + + await initialize(handler, { callerId: "orchestrator", role: "orchestrator" }); + const response = await callTool(handler, "create_lane", { name: "new-feature" }); + + expect(response.structuredContent.warning).toBe( + "⚠ Base origin/main may be stale — fetch failed; using last-known ref.", + ); + expect(fixture.runtime.laneService.create).toHaveBeenCalledWith( + expect.objectContaining({ baseBranch: "origin/main" }), + ); + }); + + it("returns a non-blocking warning when local main is behind and no remote ref can be selected", async () => { + const fixture = createRuntime(); + fixture.runtime.laneService.list = vi.fn(async () => [ + { id: "lane-primary", laneType: "primary", baseRef: "main", branchRef: "main" }, + ]) as any; + fixture.runtime.gitService.listBranches = vi.fn(async () => [ + { name: "main", isCurrent: true, isRemote: false, upstream: "origin/main" }, + ]) as any; + fixture.runtime.gitService.getSyncStatus = vi.fn(async () => ({ behind: 4, ahead: 0 })); + fixture.runtime.projectConfigService = { + getEffective: vi.fn(() => ({ git: { newLaneBaseSource: "remote" } })), + } as any; + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + + await initialize(handler, { callerId: "orchestrator", role: "orchestrator" }); + const response = await callTool(handler, "create_lane", { name: "new-feature" }); + + expect(response.structuredContent.warning).toBe( + "⚠ local main is 4 behind origin — creating off possibly-stale base.", + ); + expect(fixture.runtime.laneService.create).toHaveBeenCalledWith( + expect.not.objectContaining({ baseBranch: expect.anything() }), + ); + }); + it("defaults a base-less run_ade_action lane.create to the remote-tracking ref", async () => { const fixture = createRuntime(); fixture.runtime.laneService.list = vi.fn(async () => [ diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index d59743faf..e5cc75a2d 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -3533,6 +3533,7 @@ async function runTool(args: { laneService: runtime.laneService, gitService: runtime.gitService, projectConfigService: runtime.projectConfigService, + onWarning: (warning) => console.warn(warning), }); if (remoteBase) scopedObjectArgs = { ...scopedObjectArgs, baseBranch: remoteBase }; } @@ -3764,6 +3765,7 @@ async function runTool(args: { const description = asOptionalTrimmedString(toolArgs.description); const parentLaneId = asOptionalTrimmedString(toolArgs.parentLaneId); let baseBranch = asOptionalTrimmedString(toolArgs.baseBranch); + let baseWarning: string | null = null; const branchName = asOptionalTrimmedString(toolArgs.branchName); if (!baseBranch && !parentLaneId) { // Base-less creates (`ade lanes create`, agent tool calls) must branch @@ -3774,6 +3776,9 @@ async function runTool(args: { laneService: runtime.laneService, gitService: runtime.gitService, projectConfigService: runtime.projectConfigService, + onWarning: (warning) => { + baseWarning = warning; + }, }); } let linearIssue: LaneLinearIssue | null = null; @@ -3797,7 +3802,8 @@ async function runTool(args: { }); return { - lane: mapLaneSummary(lane as unknown as Record) + lane: mapLaneSummary(lane as unknown as Record), + ...(baseWarning ? { warning: baseWarning } : {}), }; } diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 4bbeb495e..4b59e08d1 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -8,6 +8,7 @@ import { buildAdeCodeArgs, buildCliPlan, checkLinearReadiness, + detectUnmergedLaneCreateNudge, findProjectRoots, formatOutput, graphWaitState, @@ -1826,6 +1827,69 @@ describe("ADE CLI", () => { }); }); + it("adds a valid --type to new chat createSession args and rejects invalid values", () => { + const plan = buildCliPlan([ + "new", + "chat", + "--mode", + "chat", + "--lane", + "lane-1", + "--provider", + "codex", + "--model", + "openai/gpt-5.5", + "--type", + "peer", + ]); + const executePlan = expectExecutePlan(plan); + const createParams = (executePlan.steps[0]?.params as (v: Record) => Record)({}); + expect(createParams).toMatchObject({ + arguments: { + domain: "chat", + action: "createSession", + args: { spawnKind: "peer" }, + }, + }); + + expect(() => buildCliPlan([ + "new", + "chat", + "--mode", + "chat", + "--lane", + "lane-1", + "--type", + "manager", + ])).toThrow(/--type must be subagent, peer, or none/); + }); + + it("builds the unmerged-work child-lane nudge when the current lane is ahead", () => { + const notice = detectUnmergedLaneCreateNudge( + { newLaneName: "next-task", cwd: "/tmp/worktree", currentLaneId: "lane-current" }, + (gitArgs) => { + const command = gitArgs.join(" "); + if (command === "symbolic-ref --quiet --short refs/remotes/origin/HEAD") { + return { status: 0, stdout: "origin/main\n" }; + } + if (command === "rev-list --count origin/main..HEAD") { + return { status: 0, stdout: "3\n" }; + } + if (command === "branch --show-current") { + return { status: 0, stdout: "feature/current\n" }; + } + return { status: 1, stdout: "" }; + }, + ); + + expect(notice).toBe([ + '⚠ Lane "feature/current" has 3 commit(s) not on main.', + " To carry them into the new lane instead:", + " ade lanes child --lane lane-current --name next-task", + " Continuing off remote main (origin/main).", + ].join("\n")); + }); + it("rejects unknown providers for new chat before launching", () => { expect(() => buildCliPlan([ @@ -4325,6 +4389,17 @@ describe("ADE CLI", () => { expect(chatHelp.text).toContain("ade chat read "); expect(chatHelp.text).toContain("ade new chat --mode cli"); + const newChatHelp = buildCliPlan(["help", "new", "chat"]); + expect(newChatHelp.kind).toBe("help"); + if (newChatHelp.kind !== "help") return; + expect(newChatHelp.text).toContain("--type "); + + const laneCommandHelp = buildCliPlan(["help", "lanes"]); + expect(laneCommandHelp.kind).toBe("help"); + if (laneCommandHelp.kind !== "help") return; + expect(laneCommandHelp.text).toContain("lanes create --parent "); + expect(laneCommandHelp.text).toContain("carry the parent's unmerged work"); + const chatRecoveryHelp = buildCliPlan(["help", "chat", "recover"]); expect(chatRecoveryHelp.kind).toBe("help"); if (chatRecoveryHelp.kind !== "help") return; diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 6239223db..23b4656bf 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -250,6 +250,7 @@ type CliPlan = writeResultPath?: string; syncWebOpen?: boolean; syncWebNoClipboard?: boolean; + laneCreationNudge?: { newLaneName: string }; /** * Derive a nonzero exit code from the executed result (e.g. `ade search` * exits 1 when a query returns no results, so scripts can branch on it). @@ -1318,6 +1319,8 @@ const HELP_BY_COMMAND: Record = { --auto-create-lane Create a new lane first, then launch there. --lane-name Explicit name for an auto-created lane. --base Optional base branch for an auto-created lane. + --type + Cosmetic relationship + completion-report policy; a typed agent is a full agent. subagent = ADE wakes you when it finishes; peer = quiet note; none (default) = no report. --provider claude | codex | cursor | droid | opencode. CLI mode also accepts shell. --model Runtime model id. --reasoning-effort Reasoning tier. Alias: --effort. @@ -1344,6 +1347,7 @@ 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. `, lanes: `${ADE_BANNER} Lanes @@ -1364,8 +1368,11 @@ const HELP_BY_COMMAND: Record = { $ ade lanes batch-create-from-linear --linear-issues-json '[{...},{...}]' Create one lane per issue (partial success, no orphans) $ ade lanes create --base Override the base ref (omit to use the configured new-lane base, remote-first by default) + $ ade lanes create --parent --name + Create from a parent lane's HEAD $ 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 archive Archive a lane in ADE $ ade lanes unarchive Restore an archived lane @@ -2497,6 +2504,51 @@ function readParentSessionId(args: string[]): string | undefined { return env?.length ? env : undefined; } +type LaneNudgeGitResult = { + status: number | null; + stdout: string | Buffer; +}; + +type LaneNudgeGitRunner = (args: string[], cwd: string) => LaneNudgeGitResult; + +function detectUnmergedLaneCreateNudge( + args: { + newLaneName: string; + cwd?: string; + currentLaneId?: string | null; + }, + runGit: LaneNudgeGitRunner = (gitArgs, cwd) => spawnSync("git", gitArgs, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }), +): string | null { + const cwd = args.cwd ?? process.cwd(); + const readGit = (gitArgs: string[]): string | null => { + const result = runGit(gitArgs, cwd); + if (result.status !== 0) return null; + return Buffer.isBuffer(result.stdout) + ? result.stdout.toString("utf8").trim() + : result.stdout.trim(); + }; + const remoteHead = readGit(["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"]); + const defaultBranch = remoteHead?.replace(/^origin\//, "").trim() || "main"; + const countRaw = readGit(["rev-list", "--count", `origin/${defaultBranch}..HEAD`]); + const commitCount = Number.parseInt(countRaw ?? "", 10); + if (!Number.isFinite(commitCount) || commitCount <= 0) return null; + + const currentBranch = readGit(["branch", "--show-current"]); + const currentLaneId = args.currentLaneId?.trim() || process.env.ADE_LANE_ID?.trim() || ""; + const currentLabel = currentBranch || currentLaneId || "current"; + const currentLane = currentLaneId || currentBranch || "current"; + return [ + `⚠ Lane "${currentLabel}" has ${commitCount} commit(s) not on ${defaultBranch}.`, + " To carry them into the new lane instead:", + ` ade lanes child --lane ${currentLane} --name ${args.newLaneName}`, + ` Continuing off remote main (origin/${defaultBranch}).`, + ].join("\n"); +} + type ToolClaimArgs = { laneId?: string; chatSessionId?: string; @@ -3576,14 +3628,18 @@ function buildLanePlan(args: string[]): CliPlan { throw new CliUsageError( "parent lane is required. Use --lane or --parent .", ); + const createArgs = collectGenericObjectArgs(args, input); return { kind: "execute", label: "lane create", + ...(!createArgs.parentLaneId && typeof createArgs.name === "string" && createArgs.name.trim() + ? { laneCreationNudge: { newLaneName: createArgs.name.trim() } } + : {}), steps: [ actionCallStep( "result", "create_lane", - collectGenericObjectArgs(args, input), + createArgs, ), ], }; @@ -4072,6 +4128,8 @@ function buildNewChatPlan(args: string[], defaultMode: "chat" | "cli"): CliPlan const modelArg = readValue(args, ["--model", "--model-id"]); 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 fastMode = readFastModeFlag(args); const title = readValue(args, ["--title"]); const printConfig = readFlag(args, ["--print-config", "--dry-run"]); @@ -4082,6 +4140,9 @@ function buildNewChatPlan(args: string[], defaultMode: "chat" | "cli"): CliPlan if (mode === "chat" && provider === "shell") { throw new CliUsageError("Chat mode provider must be claude, codex, cursor, droid, or opencode."); } + if (spawnKind && spawnKind !== "subagent" && spawnKind !== "peer" && spawnKind !== "none") { + throw new CliUsageError("--type must be subagent, peer, or none."); + } if (mode === "cli") { const effectivePermissionMode = permissionMode ?? "default"; if (!isTrackedCliPermissionMode(effectivePermissionMode)) { @@ -4114,6 +4175,7 @@ function buildNewChatPlan(args: string[], defaultMode: "chat" | "cli"): CliPlan reasoningEffort, permissionMode, ...(orchestrationParentSessionId ? { orchestrationParentSessionId } : {}), + ...(spawnKind ? { spawnKind } : {}), droidPermissionMode: readValue(args, [ "--droid-permission-mode", "--droid-autonomy", @@ -4156,6 +4218,13 @@ function buildNewChatPlan(args: string[], defaultMode: "chat" | "cli"): CliPlan } const steps: InvocationStep[] = []; + const laneCreationNudge = lane.autoCreateLane + && lane.createLaneArgs + && !lane.createLaneArgs.parentLaneId + && typeof lane.createLaneArgs.name === "string" + && lane.createLaneArgs.name.trim() + ? { newLaneName: lane.createLaneArgs.name.trim() } + : null; if (lane.autoCreateLane) { steps.push(actionCallStep("lane", "create_lane", lane.createLaneArgs ?? {})); } @@ -4178,6 +4247,7 @@ function buildNewChatPlan(args: string[], defaultMode: "chat" | "cli"): CliPlan label: "new chat cli", formatter: "pty-create", steps, + ...(laneCreationNudge ? { laneCreationNudge } : {}), }; } @@ -4227,6 +4297,7 @@ function buildNewChatPlan(args: string[], defaultMode: "chat" | "cli"): CliPlan kind: "execute", label: "new chat", steps, + ...(laneCreationNudge ? { laneCreationNudge } : {}), }; } @@ -11222,6 +11293,7 @@ const VALUE_CARRIER_FLAGS: ReadonlySet = new Set([ "--since", "--source", "--source-lane", + "--spawn-type", "--stack", "--stack-base", "--stack-base-branch", @@ -11254,6 +11326,7 @@ const VALUE_CARRIER_FLAGS: ReadonlySet = new Set([ "--title", "--tool-type", "--title-query", + "--type", "--udid", "--url", "--until", @@ -18150,6 +18223,10 @@ async function runCli( output: formatOutput(plan.value, parsed.options, plan.formatter), exitCode: 0, }; + if (plan.kind === "execute" && plan.laneCreationNudge) { + const notice = detectUnmergedLaneCreateNudge(plan.laneCreationNudge); + if (notice) process.stderr.write(`${notice}\n`); + } if ( plan.kind === "execute" && plan.machineOnly @@ -18416,6 +18493,7 @@ export { buildCliPlan, buildAdeCodeArgs, checkLinearReadiness, + detectUnmergedLaneCreateNudge, findProjectRoots, formatOutput, graphWaitState, diff --git a/apps/ade-cli/src/services/laneCreateRemoteBase.ts b/apps/ade-cli/src/services/laneCreateRemoteBase.ts index ef5c6c8bf..46ad21e61 100644 --- a/apps/ade-cli/src/services/laneCreateRemoteBase.ts +++ b/apps/ade-cli/src/services/laneCreateRemoteBase.ts @@ -1,4 +1,8 @@ -import { resolveDefaultRemoteLaneBase } from "../../../desktop/src/shared/defaultRemoteLaneBase"; +import { + DEFAULT_LANE_BASE_REMOTE_FETCH_TIMEOUT_MS, + remoteLaneBaseCandidate, + selectRemoteLaneBaseRef, +} from "../../../desktop/src/shared/defaultRemoteLaneBase"; import type { NewLaneBaseSource } from "../../../desktop/src/shared/types"; import type { createProjectConfigService } from "../../../desktop/src/main/services/config/projectConfigService"; import type { createGitOperationsService } from "../../../desktop/src/main/services/git/gitOperationsService"; @@ -6,8 +10,13 @@ import type { createLaneService } from "../../../desktop/src/main/services/lanes export interface LaneCreateRemoteBaseDeps { laneService: Pick, "list">; - gitService?: Pick, "fetch" | "listBranches"> | null; + gitService?: ( + Pick, "fetch" | "listBranches"> + & Partial, "getSyncStatus">> + ) | null; projectConfigService?: Pick, "getEffective"> | null; + onWarning?: (warning: string) => void; + fetchTimeoutMs?: number; } /** @@ -37,12 +46,42 @@ export async function resolveLaneCreateRemoteBase(deps: LaneCreateRemoteBaseDeps const lanes = await deps.laneService.list({ includeStatus: false }); const primary = lanes.find((lane) => lane.laneType === "primary"); if (!primary) return null; - return await resolveDefaultRemoteLaneBase({ - newLaneBaseSource: source, - primaryBaseRef: primary.baseRef || primary.branchRef, - fetchRemote: () => gitService.fetch({ laneId: primary.id }), - listBranches: () => gitService.listBranches({ laneId: primary.id }), + const primaryBaseRef = primary.baseRef || primary.branchRef; + const remoteCandidate = remoteLaneBaseCandidate(primaryBaseRef); + const defaultBranch = remoteCandidate.replace(/^origin\//, "") || "main"; + let timeoutId: ReturnType | null = null; + const fetchSucceeded = await Promise.race([ + gitService.fetch({ laneId: primary.id }).then(() => true).catch(() => false), + new Promise((resolve) => { + timeoutId = setTimeout( + () => resolve(false), + deps.fetchTimeoutMs ?? DEFAULT_LANE_BASE_REMOTE_FETCH_TIMEOUT_MS, + ); + }), + ]).finally(() => { + if (timeoutId) clearTimeout(timeoutId); }); + if (!fetchSucceeded) { + deps.onWarning?.(`⚠ Base origin/${defaultBranch} may be stale — fetch failed; using last-known ref.`); + } + + const branches = await gitService.listBranches({ laneId: primary.id }); + const remoteBase = selectRemoteLaneBaseRef({ branches, primaryBaseRef }); + if (remoteBase) return remoteBase; + + if (fetchSucceeded && gitService.getSyncStatus) { + try { + const syncStatus = await gitService.getSyncStatus({ laneId: primary.id }); + if (syncStatus.behind > 0) { + deps.onWarning?.( + `⚠ local ${defaultBranch} is ${syncStatus.behind} behind origin — creating off possibly-stale base.`, + ); + } + } catch { + // Warning enrichment is best-effort; lane creation still falls back locally. + } + } + return null; } catch { return null; } diff --git a/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md b/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md index 4eac0b031..12755690c 100644 --- a/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md +++ b/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md @@ -108,6 +108,19 @@ provider CLI terminal. Both accept lane, provider, model, reasoning effort, permission mode, fast/no-fast, and prompt flags. Use `--lane auto` or `--auto-create-lane` when the desktop UI would use the auto-create lane row. +### Spawning agents + +`ade new chat --mode chat --provider

--prompt "..."` spawns a tracked ADE +agent and automatically links it back to the current chat through +`ADE_CHAT_SESSION_ID`. Add `--type subagent|peer|none` to choose the cosmetic +relationship and completion-report policy: `subagent` wakes the parent, `peer` +adds a quiet note, and `none` adds no report. A typed agent is still a full ADE +agent with the same runtime, permissions, and tools. + +When the new work must carry the current lane's unmerged commits, follow the +child-lane rule in the `ade-lanes-git` skill and use +`ade lanes child --lane --name ` instead of a fresh lane. + Use `ade chat read --text` to confirm recent transcript messages before and after steering another chat. diff --git a/apps/desktop/resources/agent-skills/ade-lanes-git/SKILL.md b/apps/desktop/resources/agent-skills/ade-lanes-git/SKILL.md index 49e2f8453..c0c5cd578 100644 --- a/apps/desktop/resources/agent-skills/ade-lanes-git/SKILL.md +++ b/apps/desktop/resources/agent-skills/ade-lanes-git/SKILL.md @@ -9,6 +9,17 @@ description: Use this skill when creating, inspecting, syncing, committing, push Lanes are ADE-managed git worktrees and branches. +New lanes default to remote main (`git.newLaneBaseSource: "remote"`). Pass +`--base` or set local mode to override that default. A child lane is based on +the parent lane's HEAD, so it carries the parent's commits that have not landed +on main yet. + +| situation | do | +|---|---| +| continue MY unmerged work in a new lane | `ade lanes child --lane --name ` (carries your commits) | +| fresh, unrelated feature | `ade lanes create --name ` (off remote main by default) | +| build on ANOTHER lane's unlanded work | `ade lanes child --lane --name ` | + ```bash ade lanes list --text ade lanes show --text @@ -46,4 +57,3 @@ For conflicts, inspect both sides and preserve intent from both branches. Do not - Use `--lane` for anything other than the active workspace. - Use `ade diff changes --lane --text` when you need ADE's view of file changes. - Do not archive or delete lanes unless the user asked for cleanup or the release workflow explicitly requires it. - diff --git a/apps/desktop/src/main/services/ai/tools/orchestrationTools.test.ts b/apps/desktop/src/main/services/ai/tools/orchestrationTools.test.ts index d2f106fee..5419769ae 100644 --- a/apps/desktop/src/main/services/ai/tools/orchestrationTools.test.ts +++ b/apps/desktop/src/main/services/ai/tools/orchestrationTools.test.ts @@ -351,6 +351,7 @@ describe("spawnAgent tool", () => { expect(createArgs.orchestrationRunId).toBe(setup.runId); expect(createArgs.orchestrationRole).toBe("worker"); expect(createArgs.orchestrationStepId).toBe("T-1"); + expect(createArgs.spawnKind).toBe("subagent"); expect(createArgs.provider).toBe("claude"); expect(createArgs.model).toBe("claude-sonnet-5"); expect(createArgs.claudePermissionMode).toBe("bypassPermissions"); @@ -365,6 +366,26 @@ describe("spawnAgent tool", () => { expect(setup.chat.sendMessage).toHaveBeenCalledTimes(1); }); + it("allows the lead to override the cosmetic spawn kind", async () => { + setup = await setupWithRun("lead"); + await approveRun(setup); + const tools = makeToolSet(setup, "lead", "S-lead"); + + const result: any = await tools.spawnAgent!.execute({ + role: "worker", + tag: "backend", + goalSummary: "Implement T-1", + stepId: "T-1", + initialMessage: VALID_BRIEF, + spawnKind: "peer", + }); + + expect(result.ok).toBe(true); + expect(setup.chat.createSession).toHaveBeenCalledWith( + expect.objectContaining({ spawnKind: "peer" }), + ); + }); + it("blocks spawning until the orchestration plan is approved", async () => { setup = await setupWithRun("lead"); const tools = makeToolSet(setup, "lead", "S-lead"); diff --git a/apps/desktop/src/main/services/ai/tools/orchestrationTools.ts b/apps/desktop/src/main/services/ai/tools/orchestrationTools.ts index 1ed22b5c7..d5099c440 100644 --- a/apps/desktop/src/main/services/ai/tools/orchestrationTools.ts +++ b/apps/desktop/src/main/services/ai/tools/orchestrationTools.ts @@ -190,6 +190,7 @@ function createSpawnAgentTool( goalSummary: z.string().min(1, "goalSummary is required"), stepId: z.string().min(1, "stepId is required"), initialMessage: z.string().min(1, "initialMessage is required"), + spawnKind: z.enum(["subagent", "peer", "none"]).optional(), modelOverride: z .object({ provider: z.string(), @@ -245,6 +246,7 @@ function createSpawnAgentTool( orchestrationRunId: ctx.runId, orchestrationRole: input.role, orchestrationParentSessionId: ctx.sessionId, + spawnKind: input.spawnKind ?? "subagent", orchestrationTag: input.tag, orchestrationStepId: input.stepId, orchestrationBundlePath: ctx.bundlePath, diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 9022f6cd8..44999df3c 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -9186,6 +9186,7 @@ describe("createAgentChatService", () => { model: "sonnet", title: "Fix flaky tests", orchestrationParentSessionId: parent.id, + spawnKind: "subagent", }); const parentEvents = events.filter((e) => e.sessionId === parent.id); @@ -9195,6 +9196,7 @@ describe("createAgentChatService", () => { expect(notice).toBeTruthy(); expect((notice!.event as any).message).toContain("Fix flaky tests"); expect((notice!.event as any).detail?.spawnedSession?.sessionId).toBe(child.id); + expect((notice!.event as any).detail?.spawnKind).toBe("subagent"); const row = parentEvents.find( (e) => e.event.type === "subagent_started" && (e.event as any).taskId === `chat:${child.id}`, @@ -9202,8 +9204,203 @@ describe("createAgentChatService", () => { expect(row).toBeTruthy(); expect((row!.event as any).agentId).toBe(child.id); expect((row!.event as any).description).toBe("Fix flaky tests"); + expect((row!.event as any).spawnKind).toBe("subagent"); expect(child.orchestrationParentSessionId).toBe(parent.id); + expect(child.spawnKind).toBe("subagent"); + expect(readPersistedChatState(child.id)).toMatchObject({ spawnKind: "subagent" }); + await expect(service.getSessionSummary(child.id)).resolves.toMatchObject({ spawnKind: "subagent" }); + await expect(createService().service.getSessionSummary(child.id)).resolves.toMatchObject({ spawnKind: "subagent" }); + }); + + it("wakes the parent with spawnCompletion metadata when a subagent finishes", async () => { + const events: AgentChatEventEnvelope[] = []; + const stream = vi.fn(() => (async function* () { + yield { type: "system", subtype: "init", session_id: "sdk-spawn-wake", slash_commands: [] }; + yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } }; + })()); + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send: vi.fn().mockResolvedValue(undefined), + stream, + close: vi.fn(), + sessionId: "sdk-spawn-wake", + setPermissionMode: vi.fn().mockResolvedValue(undefined), + } as any); + + const { service } = createService({ onEvent: (event: AgentChatEventEnvelope) => events.push(event) }); + const parent = await service.createSession({ laneId: "lane-1", provider: "claude", model: "sonnet" }); + const child = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + title: "Wake contract child", + orchestrationParentSessionId: parent.id, + spawnKind: "subagent", + }); + + await service.sendMessage({ sessionId: child.id, text: "Finish the task." }); + + await vi.waitFor(() => { + const wake = events.find((event) => + event.sessionId === parent.id + && event.event.type === "user_message" + && (event.event.metadata as any)?.spawnCompletion?.childSessionId === child.id + ); + expect(wake).toBeTruthy(); + expect((wake!.event as any).text).toContain("Wake contract child"); + expect((wake!.event as any).metadata.spawnCompletion).toMatchObject({ + childSessionId: child.id, + childTitle: "Wake contract child", + spawnKind: "subagent", + status: "completed", + summary: "Kickoff turn finished.", + }); + }); + }); + + it("emits a quiet completion notice without a wake when a peer finishes", async () => { + const events: AgentChatEventEnvelope[] = []; + const stream = vi.fn(() => (async function* () { + yield { type: "system", subtype: "init", session_id: "sdk-spawn-peer", slash_commands: [] }; + yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } }; + })()); + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send: vi.fn().mockResolvedValue(undefined), + stream, + close: vi.fn(), + sessionId: "sdk-spawn-peer", + setPermissionMode: vi.fn().mockResolvedValue(undefined), + } as any); + + const { service } = createService({ onEvent: (event: AgentChatEventEnvelope) => events.push(event) }); + const parent = await service.createSession({ laneId: "lane-1", provider: "claude", model: "sonnet" }); + const child = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + title: "Quiet peer", + orchestrationParentSessionId: parent.id, + spawnKind: "peer", + }); + + await service.sendMessage({ sessionId: child.id, text: "Finish the task." }); + + await vi.waitFor(() => { + const completion = events.find((event) => + event.sessionId === parent.id + && event.event.type === "system_notice" + && event.event.status === "spawn_completed" + ); + expect(completion).toBeTruthy(); + expect((completion!.event as any).detail.spawnCompletion).toMatchObject({ + childSessionId: child.id, + childTitle: "Quiet peer", + spawnKind: "peer", + status: "completed", + }); + }); + expect(events.some((event) => + event.sessionId === parent.id + && event.event.type === "user_message" + && (event.event.metadata as any)?.spawnCompletion + )).toBe(false); + }); + + it("emits no completion notice or wake when an untyped child finishes", async () => { + const events: AgentChatEventEnvelope[] = []; + const stream = vi.fn(() => (async function* () { + yield { type: "system", subtype: "init", session_id: "sdk-spawn-none", slash_commands: [] }; + yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } }; + })()); + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send: vi.fn().mockResolvedValue(undefined), + stream, + close: vi.fn(), + sessionId: "sdk-spawn-none", + setPermissionMode: vi.fn().mockResolvedValue(undefined), + } as any); + + const { service } = createService({ onEvent: (event: AgentChatEventEnvelope) => events.push(event) }); + const parent = await service.createSession({ laneId: "lane-1", provider: "claude", model: "sonnet" }); + const child = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + orchestrationParentSessionId: parent.id, + spawnKind: "none", + }); + + await service.sendMessage({ sessionId: child.id, text: "Finish the task." }); + + await vi.waitFor(() => { + expect(events.some((event) => + event.sessionId === parent.id + && event.event.type === "subagent_result" + && event.event.agentId === child.id + )).toBe(true); + }); + + expect(events.some((event) => + event.sessionId === parent.id + && event.event.type === "system_notice" + && event.event.status === "spawn_completed" + )).toBe(false); + expect(events.some((event) => + event.sessionId === parent.id + && event.event.type === "user_message" + && (event.event.metadata as any)?.spawnCompletion + )).toBe(false); + }); + + it("treats a parented spawn with no explicit type as silent (no wake, no notice)", async () => { + // Regression (quality A1): an untyped `ade new chat` spawn — spawnKind + // genuinely absent, not "none" — must NOT wake the parent. Before the fix + // the absent case resolved to "subagent" and fired a wake; the report is + // opt-in via --type subagent/peer. + const events: AgentChatEventEnvelope[] = []; + const stream = vi.fn(() => (async function* () { + yield { type: "system", subtype: "init", session_id: "sdk-spawn-absent", slash_commands: [] }; + yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } }; + })()); + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send: vi.fn().mockResolvedValue(undefined), + stream, + close: vi.fn(), + sessionId: "sdk-spawn-absent", + setPermissionMode: vi.fn().mockResolvedValue(undefined), + } as any); + + const { service } = createService({ onEvent: (event: AgentChatEventEnvelope) => events.push(event) }); + const parent = await service.createSession({ laneId: "lane-1", provider: "claude", model: "sonnet" }); + const child = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + orchestrationParentSessionId: parent.id, + // spawnKind intentionally omitted — the genuinely-untyped default. + }); + expect(child.spawnKind).toBeUndefined(); + + await service.sendMessage({ sessionId: child.id, text: "Finish the task." }); + + await vi.waitFor(() => { + expect(events.some((event) => + event.sessionId === parent.id + && event.event.type === "subagent_result" + && event.event.agentId === child.id + )).toBe(true); + }); + + expect(events.some((event) => + event.sessionId === parent.id + && event.event.type === "system_notice" + && event.event.status === "spawn_completed" + )).toBe(false); + expect(events.some((event) => + event.sessionId === parent.id + && event.event.type === "user_message" + && (event.event.metadata as any)?.spawnCompletion + )).toBe(false); }); }); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 7d6b46c6c..aad609dad 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -212,6 +212,7 @@ import type { AgentChatEvent, AgentChatEventEnvelope, AgentChatEventMetadata, + AgentChatSpawnCompletion, AgentChatEventHistoryPage, AgentChatEventHistorySnapshot, AgentChatContextAttachment, @@ -663,6 +664,7 @@ type PersistedChatState = { orchestrationRunId?: string; orchestrationRole?: "lead" | "worker" | "validator"; orchestrationParentSessionId?: string; + spawnKind?: AgentChatSession["spawnKind"]; orchestrationTag?: string; orchestrationStepId?: string; orchestrationBundlePath?: string; @@ -5347,7 +5349,7 @@ function enforceOrchestrationLockedPermissionMode( } // --------------------------------------------------------------------------- -// Orchestration field helpers — single source of truth for the 6-field bag +// Orchestration field helpers — single source of truth for the session lineage bag // that gets spread/read in persistence, hydration, session creation, summary, // and dead-session reconstruction. Every call-site that previously copy-pasted // these fields now delegates here. @@ -5357,6 +5359,7 @@ const ORCHESTRATION_SESSION_FIELD_NAMES = [ "orchestrationRunId", "orchestrationRole", "orchestrationParentSessionId", + "spawnKind", "orchestrationTag", "orchestrationStepId", "orchestrationBundlePath", @@ -5365,6 +5368,7 @@ const ORCHESTRATION_SESSION_FIELD_NAMES = [ type OrchestrationFieldSource = Partial>; const VALID_ORCHESTRATION_ROLES = new Set(["lead", "worker", "validator"]); +const VALID_AGENT_CHAT_SPAWN_KINDS = new Set(["subagent", "peer", "none"]); /** * Read orchestration fields from an untyped record (e.g. persisted JSON), @@ -5383,6 +5387,10 @@ function hydrateOrchestrationFields( } const parentId = record.orchestrationParentSessionId; if (typeof parentId === "string" && parentId.trim().length) out.orchestrationParentSessionId = parentId.trim(); + const spawnKind = record.spawnKind; + if (typeof spawnKind === "string" && VALID_AGENT_CHAT_SPAWN_KINDS.has(spawnKind)) { + out.spawnKind = spawnKind as "subagent" | "peer" | "none"; + } const tag = record.orchestrationTag; if (typeof tag === "string" && tag.trim().length) out.orchestrationTag = tag.trim(); const stepId = record.orchestrationStepId; @@ -5436,8 +5444,20 @@ function stringifyExecutableToolOutput(output: unknown): string { } } -function buildAdeGuidanceForLane(laneWorktreePath: string): string { - return buildAdeCliAgentGuidance(getAdeAgentSkillRootsForPrompt({ cwd: laneWorktreePath })); +function buildSpawnSelfReportGuidance( + session: Pick, +): string | null { + if (!session.orchestrationParentSessionId?.trim() || session.spawnKind !== "subagent") return null; + return "You were spawned as a subagent. When you finish, report a one-paragraph summary to your spawner with: `ade actions run chat.messageSession --input-json '{\"sessionId\":\"$ADE_PARENT_CHAT_SESSION_ID\",\"kind\":\"auto\",\"text\":\"

\"}'`. ADE will also notify them automatically, so this is enrichment, not required."; +} + +function buildAdeGuidanceForLane( + laneWorktreePath: string, + session?: Pick, +): string { + const base = buildAdeCliAgentGuidance(getAdeAgentSkillRootsForPrompt({ cwd: laneWorktreePath })); + const spawnGuidance = session ? buildSpawnSelfReportGuidance(session) : null; + return spawnGuidance ? `${base}\n${spawnGuidance}` : base; } function buildCodexDeveloperInstructions(args: { @@ -5451,6 +5471,7 @@ function buildCodexDeveloperInstructions(args: { | "orchestrationBundlePath" | "orchestrationTag" | "orchestrationParentSessionId" + | "spawnKind" | "orchestrationStepId" | "surface" >; @@ -5476,7 +5497,8 @@ function buildCodexDeveloperInstructions(args: { orchestrationParentSessionId: args.session.orchestrationParentSessionId, orchestrationStepId: args.session.orchestrationStepId, }); - return args.linearDirective ? `${base}\n\n${args.linearDirective}` : base; + const spawnGuidance = buildSpawnSelfReportGuidance(args.session); + return [base, args.linearDirective, spawnGuidance].filter(Boolean).join("\n\n"); } function resolveCodexInstructionCollaborationMode( @@ -6210,6 +6232,12 @@ export function createAgentChatService(args: { ADE_PROJECT_ROOT: projectRoot, ADE_WORKSPACE_ROOT: managed.laneWorktreePath, }), + ...(managed.session.orchestrationParentSessionId + ? { + ADE_PARENT_CHAT_SESSION_ID: managed.session.orchestrationParentSessionId, + ADE_SPAWN_KIND: managed.session.spawnKind ?? "", + } + : {}), }; if (personalSession) { delete env.ADE_LANE_ID; @@ -17872,7 +17900,7 @@ export function createAgentChatService(args: { "", ...slashCommandsSection, "", - buildAdeGuidanceForLane(managed.laneWorktreePath), + buildAdeGuidanceForLane(managed.laneWorktreePath, managed.session), ].join("\n"); }; @@ -23820,7 +23848,7 @@ export function createAgentChatService(args: { ...(linearDirective ? [linearDirective, ""] : []), ...slashCommandsSection, "", - buildAdeGuidanceForLane(managed.laneWorktreePath), + buildAdeGuidanceForLane(managed.laneWorktreePath, managed.session), ].join("\n"), }; opts.settingSources = ["user", "project", "local"]; @@ -25053,7 +25081,7 @@ export function createAgentChatService(args: { * restart no stale subagent_result can fire for a spawn event that * predates the process. */ - const childSpawnTracking = new Map(); + const childSpawnTracking = new Map(); const notifyParentSessionOfSpawn = (child: ManagedChatSession, label: string): void => { const parentSessionId = child.session.orchestrationParentSessionId?.trim(); @@ -25071,10 +25099,16 @@ export function createAgentChatService(args: { laneId: child.session.laneId ?? null, title: label, }, + spawnKind: child.session.spawnKind ?? "none", + // A plain spawn also emits an inline `subagent_started` card below; an + // orchestration-run child emits only this notice. The renderer keeps the + // quiet deep-link pill only when no card accompanies it. + hasInlineCard: !child.session.orchestrationRunId, }, }); - if (child.session.orchestrationRunId) return; - childSpawnTracking.set(child.session.id, { parentSessionId }); + const emitInlineEvents = !child.session.orchestrationRunId; + childSpawnTracking.set(child.session.id, { parentSessionId, emitInlineEvents }); + if (!emitInlineEvents) return; emitChatEvent(parent, { type: "subagent_started", taskId: `chat:${child.session.id}`, @@ -25084,6 +25118,7 @@ export function createAgentChatService(args: { description: label, background: false, taskType: "subagent", + spawnKind: child.session.spawnKind ?? "none", }); }; @@ -25105,17 +25140,61 @@ export function createAgentChatService(args: { : resultStatus === "stopped" ? "Stopped before finishing." : "Turn failed."; - emitChatEvent(parent, { - type: "subagent_result", - taskId: `chat:${childSessionId}`, - agentId: childSessionId, - ...(child?.session.provider ? { agentType: child.session.provider } : {}), - parentToolUseId: null, + if (tracked.emitInlineEvents) { + emitChatEvent(parent, { + type: "subagent_result", + taskId: `chat:${childSessionId}`, + agentId: childSessionId, + ...(child?.session.provider ? { agentType: child.session.provider } : {}), + parentToolUseId: null, + status: resultStatus, + summary, + finalSummary: summary, + taskType: "subagent", + }); + } + // Untyped spawns default to "none" (silent) — the completion report is opt-in + // via `--type subagent|peer`, matching the CLI docs and the self-report guidance. + const spawnKind = child?.session.spawnKind ?? "none"; + if (spawnKind === "none") return; + const childTitle = sessionService.get(childSessionId)?.title?.trim() + || defaultChatSessionTitle(child?.session.provider ?? "claude"); + const spawnCompletion: AgentChatSpawnCompletion = { + childSessionId, + childTitle, + spawnKind, status: resultStatus, summary, - finalSummary: summary, - taskType: "subagent", - }); + }; + // Delivery is best-effort: a failed wake/notice must never break child cleanup. + const onCompletionDeliveryFailed = (error: unknown) => { + logger.warn("agent_chat.spawn_completion_delivery_failed", { + childSessionId, + parentSessionId: parent.session.id, + spawnKind, + error: error instanceof Error ? error.message : String(error), + }); + }; + try { + if (spawnKind === "subagent") { + void messageSession({ + sessionId: parent.session.id, + kind: "wake", + text: `Your subagent "${childTitle}" finished — ${summary}`, + metadata: { spawnCompletion }, + }).catch(onCompletionDeliveryFailed); + } else { + emitChatEvent(parent, { + type: "system_notice", + noticeKind: "info", + status: "spawn_completed", + message: `Peer "${childTitle}" finished`, + detail: { spawnCompletion }, + }); + } + } catch (error) { + onCompletionDeliveryFailed(error); + } }; type AgentChatCreateInternalArgs = AgentChatCreateArgs & { @@ -25154,6 +25233,7 @@ export function createAgentChatService(args: { orchestrationRunId: requestedOrchestrationRunId, orchestrationRole: requestedOrchestrationRole, orchestrationParentSessionId: requestedOrchestrationParentSessionId, + spawnKind: requestedSpawnKind, orchestrationTag: requestedOrchestrationTag, orchestrationStepId: requestedOrchestrationStepId, orchestrationBundlePath: requestedOrchestrationBundlePath, @@ -25439,6 +25519,7 @@ export function createAgentChatService(args: { orchestrationRunId: requestedOrchestrationRunId, orchestrationRole: requestedOrchestrationRole, orchestrationParentSessionId: requestedOrchestrationParentSessionId, + spawnKind: requestedSpawnKind, orchestrationTag: requestedOrchestrationTag, orchestrationStepId: requestedOrchestrationStepId, orchestrationBundlePath: requestedOrchestrationBundlePath, @@ -27796,7 +27877,7 @@ export function createAgentChatService(args: { personalChatUserPromptFallback(managed.session), personalSession ? null : buildExecutionModeDirective(executionMode, managed.session.provider), personalSession ? null : buildClaudeInteractionModeDirective(managed.session.interactionMode, managed.session.provider), - shouldInjectGuidance ? buildAdeGuidanceForLane(managed.laneWorktreePath) : null, + shouldInjectGuidance ? buildAdeGuidanceForLane(managed.laneWorktreePath, managed.session) : null, personalSession ? null : buildComputerUseDirective( @@ -37465,6 +37546,7 @@ export function createAgentChatService(args: { orchestrationRunId?: string | null; orchestrationRole?: "lead" | "worker" | "validator" | null; orchestrationParentSessionId?: string | null; + spawnKind?: AgentChatSession["spawnKind"] | null; orchestrationTag?: string | null; orchestrationStepId?: string | null; orchestrationBundlePath?: string | null; diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 00f294370..ab1baceba 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -959,6 +959,13 @@ function projectChatOntoSession( orchestrationTag: chat.orchestrationTag, } : {}), + // Spawn lineage is independent of an orchestration run — a plain `ade new + // chat` spawn sets a parent + spawnKind with no run/role. Project both + // ungated so the sidebar can render the type pill and the live-children badge. + ...(chat.orchestrationParentSessionId + ? { orchestrationParentSessionId: chat.orchestrationParentSessionId } + : {}), + ...(chat.spawnKind ? { spawnKind: chat.spawnKind } : {}), }; if (chat.awaitingInput) { return { diff --git a/apps/desktop/src/main/services/orchestration/orchestrationDomain.test.ts b/apps/desktop/src/main/services/orchestration/orchestrationDomain.test.ts index fd199bd2f..e61a8a4e1 100644 --- a/apps/desktop/src/main/services/orchestration/orchestrationDomain.test.ts +++ b/apps/desktop/src/main/services/orchestration/orchestrationDomain.test.ts @@ -170,6 +170,9 @@ describe("orchestrationDomain", () => { expect(result).toEqual({ sessionId: "sess-1", etag: "etag-B" }); expect(h.agentChatService.createSession).toHaveBeenCalledTimes(1); + expect(h.agentChatService.createSession).toHaveBeenCalledWith( + expect.objectContaining({ spawnKind: "subagent" }), + ); expect(h.orchestrationService.manifestPatch).toHaveBeenCalledTimes(1); }); diff --git a/apps/desktop/src/main/services/orchestration/orchestrationDomain.ts b/apps/desktop/src/main/services/orchestration/orchestrationDomain.ts index c544b17b7..278b295f7 100644 --- a/apps/desktop/src/main/services/orchestration/orchestrationDomain.ts +++ b/apps/desktop/src/main/services/orchestration/orchestrationDomain.ts @@ -143,6 +143,7 @@ export function createOrchestrationDomainService(deps: OrchestrationDomainDeps) orchestrationRunId: arg.runId, orchestrationRole: arg.role, orchestrationParentSessionId: arg.leadSessionId, + spawnKind: "subagent", orchestrationTag: arg.tag, orchestrationStepId: arg.stepId, orchestrationBundlePath: bundlePath, diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx index decc31049..e53821539 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx @@ -927,6 +927,98 @@ describe("AgentChatMessageList transcript rendering", () => { expect(screen.queryByRole("button")).toBeNull(); }); + it("renders a spawn_completed peer notice as a quiet chip that navigates to the child", () => { + const dispatchSpy = vi.spyOn(window, "dispatchEvent"); + renderMessageList([ + { + sessionId: "parent-session", + timestamp: "2026-07-14T10:00:00.000Z", + event: { + type: "system_notice", + noticeKind: "info", + status: "spawn_completed", + message: 'Peer "Docs" finished', + detail: { + spawnCompletion: { + childSessionId: "child-peer-1", + childTitle: "Docs", + spawnKind: "peer", + status: "completed", + summary: "Wrote the docs.", + }, + }, + }, + }, + ], { sessionId: "parent-session" }); + + const chip = screen.getByRole("button", { name: /Docs.*finished/i }); + fireEvent.click(chip); + + const navEvent = dispatchSpy.mock.calls + .map(([evt]) => evt) + .find((evt): evt is CustomEvent => evt instanceof CustomEvent && evt.type === "ade:work:select-session"); + expect(navEvent).toBeTruthy(); + expect((navEvent!.detail as { sessionId?: string }).sessionId).toBe("child-peer-1"); + }); + + it("suppresses the legacy subagent_spawned pill for a plain spawn (the unified card replaces it)", () => { + renderMessageList([ + { + sessionId: "parent-session", + timestamp: "2026-07-14T10:00:00.000Z", + event: { + type: "system_notice", + noticeKind: "info", + status: "subagent_spawned", + message: "Subagent spawned: Wave 2 UI", + detail: { + spawnedSession: { sessionId: "child-1", laneId: null, title: "Wave 2 UI" }, + spawnKind: "subagent", + // Plain spawn: an inline SubagentSpawnCard accompanies the notice, so + // the quiet pill is suppressed. + hasInlineCard: true, + }, + }, + }, + ], { sessionId: "parent-session" }); + + expect(screen.queryByText("Wave 2 UI")).toBeNull(); + expect(screen.queryByRole("button")).toBeNull(); + }); + + it("keeps the subagent_spawned deep-link pill when there is no inline card (orchestration/continuity)", () => { + const dispatchSpy = vi.spyOn(window, "dispatchEvent"); + renderMessageList([ + { + sessionId: "parent-session", + timestamp: "2026-07-14T10:00:00.000Z", + event: { + type: "system_notice", + noticeKind: "info", + status: "subagent_spawned", + message: "Subagent spawned: Worker A", + detail: { + spawnedSession: { sessionId: "child-worker-1", laneId: null, title: "Worker A" }, + spawnKind: "subagent", + // No accompanying card (orchestration-run child / continuity spawn) → + // the quiet deep-link pill is retained. + hasInlineCard: false, + }, + }, + }, + ], { sessionId: "parent-session" }); + + const pill = screen.getByRole("button"); + expect(pill.textContent).toContain("Worker A"); + fireEvent.click(pill); + const navEvent = dispatchSpy.mock.calls + .map(([evt]) => evt) + .find((evt): evt is CustomEvent => evt instanceof CustomEvent && evt.type === "ade:work:select-session"); + expect(navEvent).toBeTruthy(); + expect((navEvent!.detail as { sessionId?: string }).sessionId).toBe("child-worker-1"); + dispatchSpy.mockRestore(); + }); + it("runs Codex stalled-turn recovery actions against the source chat", async () => { const onCodexRecovery = vi.fn().mockResolvedValue({ action: "wait", diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index 6a169d32d..05358d9c8 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -39,6 +39,7 @@ import type { AgentChatEvent, AgentChatEventEnvelope, AgentChatNoticeDetail, + AgentChatSpawnCompletion, AgentChatRecoverCodexTurnArgs, AgentChatRecoverCodexTurnResult, ChatSurfaceChipTone, @@ -86,6 +87,7 @@ import { type ChatActivityBundleItem, type CollapseTranscriptResult, type ScheduledWakeDividerRenderEvent, + type SpawnWakeDividerRenderEvent, type SubagentResultCardRenderEvent, type SubagentSpawnAnchorRenderEvent, type SubagentStoppedGroupEvent, @@ -93,6 +95,7 @@ import { type ChatTranscriptRenderEnvelope as TranscriptRenderEnvelope, } from "./chatTranscriptRows"; import { BackgroundFinishChip, SubagentResultCard, SubagentSpawnCard, SubagentStoppedGroupCard } from "./SubagentActivityCards"; +import { navigateToSpawnedChat } from "./spawnNavigation"; import { ChatUserMinimap } from "./ChatUserMinimap"; import { AgentCliAuthCard, type AgentCliAuthCardInfo } from "./AgentCliAuthCard"; import { ChatContinuityRecoveryCard } from "./ChatContinuityRecoveryCard"; @@ -689,7 +692,8 @@ type RenderEnvelope = { | SubagentResultCardRenderEvent | SubagentStoppedGroupEvent | BackgroundFinishChipRenderEvent - | ScheduledWakeDividerRenderEvent; + | ScheduledWakeDividerRenderEvent + | SpawnWakeDividerRenderEvent; }; function MessageCopyButton({ @@ -2725,6 +2729,32 @@ function renderEvent( ); } + /* ── Spawn-completion wake header (ADE woke this chat) ── */ + if (event.type === "spawn_wake_divider") { + const summary = event.summary?.trim(); + return ( +
+
+ + + + ADE woke this chat + + +
+ +
+ ); + } + /* ── User message ── */ if (event.type === "user_message") { const deliveryChip = describeUserDeliveryState(event); @@ -3070,6 +3100,7 @@ function renderEvent( return ( options!.onScrollToRowKey?.(`subagent-result:${event.agentKey}`) @@ -3306,38 +3337,66 @@ function renderEvent( /* ── System Notice ── */ if (event.type === "system_notice") { - // "Subagent spawned" chip — one quiet line deep-linking to the child - // chat session (spawnAgent + CLI-spawned children). SDK Task-tool - // subagents stay panel-only; the side panel remains the primary surface. + // Spawn notices. The "spawned" announcement is now carried by the unified, + // navigable spawn-anchor card (SubagentSpawnCard) — do NOT render a second + // quiet pill here. A `peer` child that finishes emits a `spawn_completed` + // notice; render a single quiet steel chip that navigates to the child. if (event.noticeKind === "info" && event.status === "subagent_spawned") { - const spawned = event.detail && typeof event.detail === "object" && "spawnedSession" in event.detail - ? (event.detail as { spawnedSession?: { sessionId?: string; laneId?: string | null; title?: string } }).spawnedSession - : undefined; - const childTitle = spawned?.title ?? event.message.replace(/^Subagent spawned:\s*/, ""); + // A plain spawn's announcement is carried by the unified, navigable + // SubagentSpawnCard, so suppress this quiet pill there (hasInlineCard). + // Orchestration-run children and continuity-recovery spawns emit only the + // notice (no inline card) — keep a compact deep-link chip for those. + const detail = (event.detail && typeof event.detail === "object" ? event.detail : {}) as { + hasInlineCard?: boolean; + spawnKind?: "subagent" | "peer" | "none"; + spawnedSession?: { sessionId?: string; laneId?: string | null; title?: string }; + }; + if (detail.hasInlineCard) return null; + const spawned = detail.spawnedSession; const childSessionId = typeof spawned?.sessionId === "string" && spawned.sessionId.length ? spawned.sessionId : null; + const childTitle = spawned?.title?.trim() || event.message.replace(/^Subagent spawned:\s*/, "") || "chat"; + const isPeer = detail.spawnKind === "peer"; return ( + ); + } + if (event.noticeKind === "info" && event.status === "spawn_completed") { + const completion: AgentChatSpawnCompletion | undefined = + event.detail && typeof event.detail === "object" ? event.detail.spawnCompletion : undefined; + const childTitle = completion?.childTitle?.trim() || event.message.replace(/^Peer\s+"?|"?\s+finished$/g, "").trim() || "Peer"; + const childSessionId = typeof completion?.childSessionId === "string" && completion.childSessionId.length + ? completion.childSessionId + : null; + return ( + ); } @@ -3365,7 +3424,6 @@ function renderEvent( // A chat whose provider thread couldn't be resumed after a disk-full incident // carries a persisted continuity-recovery detail — render the dedicated card // (retry / recover-from-history / start-new) instead of a plain notice chip. - // The `subagent_spawned` continuity notice keeps its deep-link chip above. if ( event.detail && typeof event.detail === "object" diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index c72a0eecd..41fb88b33 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -132,6 +132,7 @@ import { getLaneAccent } from "../lanes/laneColorPalette"; import { openLaneInLanesTabPath } from "../../lib/laneNavigation"; import { ChatTerminalDrawer } from "./ChatTerminalDrawer"; import { deriveChatSubagentSnapshots, deriveTodoItems, deriveTurnDiffSummaries, mergeManagedScheduledWorkSnapshots } from "./chatExecutionSummary"; +import { navigateToSpawnedChat } from "./spawnNavigation"; import { deriveMissionSnapshot } from "./chatMission"; import { MissionControlPanel } from "./MissionControlPanel"; import { derivePendingInputRequests, type DerivedPendingInput } from "./pendingInput"; @@ -4820,6 +4821,16 @@ export function AgentChatPane({ return chips; }, [resolvedChips, selectedSession?.claudeTag, selectedSessionImportedProvider]); + // Two-way lineage: a spawned child chat shows a "↳ from " breadcrumb + // in its header that navigates back to the parent. Parent title resolves from + // the loaded session list; falls back to a generic label with the link intact. + const spawnLineage = useMemo(() => { + const parentId = selectedSession?.orchestrationParentSessionId?.trim(); + if (!parentId || parentId === selectedSession?.sessionId) return null; + const parentTitle = sessions.find((s) => s.sessionId === parentId)?.title?.trim() || null; + return { parentId, parentTitle, spawnKind: selectedSession?.spawnKind ?? null }; + }, [selectedSession?.orchestrationParentSessionId, selectedSession?.sessionId, selectedSession?.spawnKind, 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. @@ -10189,6 +10200,22 @@ export function AgentChatPane({ ); const chatHeaderTrailingActions = ( <> + {spawnLineage ? ( + + ) : null} {chatTerminalVisible && selectedSessionId ? ( { + // A spawned ADE chat (peer/subagent) carries a `chat:` taskId — clicking + // navigates to that chat instead of a transcript takeover/drawer. Only + // runtime-native subagents (non-`chat:` taskIds) keep the takeover behavior. + if (snap.taskId.startsWith("chat:")) { + const childSessionId = snap.agentId?.trim() || snap.taskId.slice("chat:".length); + if (childSessionId) { + navigateToSpawnedChat(childSessionId, null); + return; + } + } // Clicking the row whose drawer is open closes it. if (expandedTaskId === snap.taskId) { setExpandedTaskId(null); diff --git a/apps/desktop/src/renderer/components/chat/SubagentActivityCards.test.tsx b/apps/desktop/src/renderer/components/chat/SubagentActivityCards.test.tsx new file mode 100644 index 000000000..289b18789 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/SubagentActivityCards.test.tsx @@ -0,0 +1,74 @@ +/* @vitest-environment jsdom */ + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { SubagentSpawnCard } from "./SubagentActivityCards"; +import type { SubagentSpawnAnchorRenderEvent } from "./chatTranscriptRows"; + +function spawnEvent(overrides: Partial = {}): SubagentSpawnAnchorRenderEvent { + return { + type: "subagent_spawn_anchor", + agentKey: "child-abc", + description: "Wave 2 UI", + agentType: "claude", + background: false, + status: "running", + statusLine: null, + lastToolName: null, + toolCount: null, + startedAt: "2026-07-14T10:00:00.000Z", + endedAt: null, + childSessionId: "child-abc", + spawnKind: "subagent", + resultSummary: null, + ...overrides, + }; +} + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe("SubagentSpawnCard", () => { + it("navigates to the spawned chat on click when a child session id is present", () => { + const dispatchSpy = vi.spyOn(window, "dispatchEvent"); + render(); + + fireEvent.click(screen.getByRole("button", { name: /Wave 2 UI/ })); + + const navEvent = dispatchSpy.mock.calls + .map(([evt]) => evt) + .find((evt): evt is CustomEvent => evt instanceof CustomEvent && evt.type === "ade:work:select-session"); + expect(navEvent).toBeTruthy(); + expect(navEvent!.detail).toEqual({ sessionId: "child-abc", laneId: "lane-1" }); + }); + + it("renders the type chip and result summary once finished", () => { + render( + , + ); + + expect(screen.getByText("SUBAGENT")).toBeTruthy(); + expect(screen.getByText("Kickoff turn finished.")).toBeTruthy(); + }); + + it("does not navigate for a runtime-native subagent (no child session id)", () => { + const dispatchSpy = vi.spyOn(window, "dispatchEvent"); + render( + , + ); + + // No navigable button wrapper — the card is a plain div. + expect(screen.queryByRole("button", { name: /Wave 2 UI/ })).toBeNull(); + const navEvent = dispatchSpy.mock.calls + .map(([evt]) => evt) + .find((evt): evt is CustomEvent => evt instanceof CustomEvent && evt.type === "ade:work:select-session"); + expect(navEvent).toBeUndefined(); + expect(screen.queryByText("SUBAGENT")).toBeNull(); + }); +}); diff --git a/apps/desktop/src/renderer/components/chat/SubagentActivityCards.tsx b/apps/desktop/src/renderer/components/chat/SubagentActivityCards.tsx index 526871995..d9f616e99 100644 --- a/apps/desktop/src/renderer/components/chat/SubagentActivityCards.tsx +++ b/apps/desktop/src/renderer/components/chat/SubagentActivityCards.tsx @@ -4,6 +4,8 @@ import { cn } from "../ui/cn"; import { formatSubagentDurationMs } from "../../lib/format"; import { ChatSubagentGlyph, chatSubagentColor } from "./chatSubagentIdentity"; import type { ChatSubagentSnapshot } from "./chatExecutionSummary"; +import type { AgentChatSpawnKind } from "../../../shared/types"; +import { navigateToSpawnedChat } from "./spawnNavigation"; import type { BackgroundFinishChipRenderEvent, SubagentResultCardRenderEvent, @@ -11,6 +13,34 @@ import type { SubagentStoppedGroupEvent, } from "./chatTranscriptRows"; +// Re-exported for existing importers that reach it through this module. +export { navigateToSpawnedChat }; + +/** + * Type-tinted accent for a spawned ADE chat card. subagent = violet (the chat's + * `--color-accent`); peer = steel/neutral slate. `none`/null spawns keep the + * default `--chat-accent` styling and show no type chip. + */ +export function spawnTypeAccent( + spawnKind: AgentChatSpawnKind | null | undefined, +): { label: string; cardClass: string; chipClass: string } | null { + if (spawnKind === "subagent") { + return { + label: "SUBAGENT", + cardClass: "border-violet-400/22 bg-violet-400/[0.06] hover:border-violet-300/32", + chipClass: "border-violet-300/25 bg-violet-400/10 text-violet-200/85", + }; + } + if (spawnKind === "peer") { + return { + label: "PEER", + cardClass: "border-slate-400/18 bg-slate-400/[0.06] hover:border-slate-300/28", + chipClass: "border-slate-300/20 bg-slate-400/10 text-slate-300/75", + }; + } + return null; +} + // Two rows per real subagent — a spawn card anchored where it started, and a // result card at the settle position. Both inherit the chat accent // (`--chat-accent`) and mirror the calm styling idiom of AgentCliAuthCard @@ -37,9 +67,12 @@ function glyphStatusFor(status: SubagentSpawnAnchorRenderEvent["status"]): ChatS export function SubagentSpawnCard({ event, onJumpToResult, + laneId, }: { event: SubagentSpawnAnchorRenderEvent; onJumpToResult?: () => void; + /** Lane of the spawner, forwarded to the navigation event when known. */ + laneId?: string | null; }) { const isRunning = event.status === "running"; const [nowMs, setNowMs] = useState(() => Date.now()); @@ -78,54 +111,95 @@ export function SubagentSpawnCard({ elapsed, ].filter((part): part is string => Boolean(part)); - return ( -
-
- - - - + // A spawned ADE chat (peer/subagent) carries a child session id → the whole + // card navigates. Runtime-native subagents (no child id) keep the passive + // card + nested "jump to result" affordance. + const childSessionId = event.childSessionId?.trim() || null; + const navigable = Boolean(childSessionId); + const typeAccent = spawnTypeAccent(event.spawnKind); + const resultSummary = !isRunning ? event.resultSummary?.trim() || null : null; + + const cardShell = cn( + "w-full max-w-[min(100%,70ch)] overflow-hidden rounded-[calc(var(--chat-radius-card)-6px)] border transition-colors", + typeAccent + ? typeAccent.cardClass + : "border-[color:color-mix(in_srgb,var(--chat-accent)_16%,transparent)] bg-[color:color-mix(in_srgb,var(--chat-accent)_6%,transparent)]", + ); + + const inner = ( + <> + + + -
-
- - {event.description || "Subagent task"} + +
+
+ + {event.description || "Subagent task"} + + {typeAccent ? ( + + {typeAccent.label} + + ) : null} + {event.agentType?.trim() && event.agentType.trim() !== "background" ? ( + + {event.agentType.trim()} + + ) : null} + {event.background ? ( + + background - {event.agentType?.trim() && event.agentType.trim() !== "background" ? ( - - {event.agentType.trim()} - - ) : null} - {event.background ? ( - - background - - ) : null} -
- {liveParts.length ? ( -
- {liveParts.join(" · ")} -
) : null}
- {!isRunning && onJumpToResult ? ( - + {liveParts.length ? ( +
+ {liveParts.join(" · ")} +
+ ) : null} + {resultSummary ? ( +
+ {resultSummary} +
) : null}
+ {navigable ? ( + + open + + + ) : !isRunning && onJumpToResult ? ( + + ) : null} + + ); + + if (navigable && childSessionId) { + return ( + + ); + } + + return ( +
+
{inner}
); } diff --git a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.test.ts b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.test.ts index 4203d2c28..ecbfe3c13 100644 --- a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.test.ts +++ b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.test.ts @@ -381,6 +381,83 @@ describe("chatTranscriptRows", () => { expect(rows[2]!.event.text).toBe(" world"); }); + it("threads childSessionId and spawnKind onto a spawned-ADE-chat anchor", () => { + const rows = collapseChatTranscriptEvents([ + { + sessionId: "parent-session", + timestamp: "2026-07-14T10:00:00.000Z", + event: { + type: "subagent_started", + taskId: "chat:child-123", + agentId: "child-123", + agentType: "claude", + description: "Wave 2 UI", + spawnKind: "peer", + taskType: "subagent", + }, + }, + ]); + + expect(rows).toHaveLength(1); + const anchor = rows[0]!.event; + if (anchor.type !== "subagent_spawn_anchor") throw new Error("Expected spawn anchor"); + expect(anchor.childSessionId).toBe("child-123"); + expect(anchor.spawnKind).toBe("peer"); + expect(anchor.agentType).toBe("claude"); + }); + + it("leaves childSessionId/spawnKind null for a runtime-native subagent", () => { + const rows = collapseChatTranscriptEvents([ + { + sessionId: "session-1", + timestamp: "2026-07-14T10:00:00.000Z", + event: { + type: "subagent_started", + taskId: "agent-native", + agentType: "Explore", + description: "Search the tree", + }, + }, + ]); + + const anchor = rows[0]!.event; + if (anchor.type !== "subagent_spawn_anchor") throw new Error("Expected spawn anchor"); + expect(anchor.childSessionId).toBeNull(); + expect(anchor.spawnKind).toBeNull(); + }); + + it("emits a spawn-wake divider above a subagent completion wake turn", () => { + const rows = collapseChatTranscriptEvents([ + { + sessionId: "parent-session", + timestamp: "2026-07-14T10:00:00.000Z", + event: { + type: "user_message", + text: 'Your subagent "Docs" finished — done.', + turnId: "turn-wake", + metadata: { + spawnCompletion: { + childSessionId: "child-9", + childTitle: "Docs", + spawnKind: "subagent", + status: "completed", + summary: "Wrote the docs.", + }, + }, + }, + }, + ]); + + const divider = rows.find((row) => row.event.type === "spawn_wake_divider"); + expect(divider).toBeTruthy(); + if (!divider || divider.event.type !== "spawn_wake_divider") throw new Error("Expected spawn_wake_divider"); + expect(divider.event.childSessionId).toBe("child-9"); + expect(divider.event.childTitle).toBe("Docs"); + expect(divider.event.summary).toBe("Wrote the docs."); + // The synthetic wake user turn still renders below the divider. + expect(rows.some((row) => row.event.type === "user_message")).toBe(true); + }); + it("updates streaming command and file-change entries in place instead of stacking", () => { const rows = collapseChatTranscriptEvents([ { diff --git a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts index 6e8fcd3a8..d9bc84792 100644 --- a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts +++ b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts @@ -3,7 +3,7 @@ import { mergeReasoningTextFragments, type ActivityPhaseMergeMeta, } from "../../../shared/chatActivityPhase"; -import type { AgentChatEvent, AgentChatEventEnvelope } from "../../../shared/types"; +import type { AgentChatEvent, AgentChatEventEnvelope, AgentChatSpawnKind } from "../../../shared/types"; import { isBackgroundShellCommand, longerSubagentText, @@ -138,6 +138,16 @@ export type SubagentSpawnAnchorRenderEvent = { toolCount: number | null; startedAt: string; endedAt: string | null; + /** + * Spawned-ADE-chat navigation. Set only when the source lifecycle event carried + * a `chat:` taskId (a spawned peer/subagent chat, not a runtime-native + * subagent). The whole card becomes a button that navigates to this session. + */ + childSessionId: string | null; + /** Cosmetic relationship + completion-report policy; null for runtime-native subagents. */ + spawnKind: AgentChatSpawnKind | null; + /** Final result summary, surfaced on the card once the agent settles. */ + resultSummary: string | null; }; /** @@ -204,6 +214,23 @@ export type ScheduledWakeDividerRenderEvent = { turnId?: string; }; +/** + * Header row rendered above a synthetic `wake` turn ADE delivered when a spawned + * `subagent` finished (`user_message.metadata.spawnCompletion`). Mirrors the + * scheduled-wake divider treatment; carries the child session id so the row's + * `[open ›]` affordance navigates to the finished child. + * Row key: `spawn-wake:${childSessionId}:${turnId}`. + */ +export type SpawnWakeDividerRenderEvent = { + type: "spawn_wake_divider"; + childSessionId: string; + childTitle: string; + spawnKind: AgentChatSpawnKind; + status: "completed" | "failed" | "stopped"; + summary: string | null; + turnId?: string; +}; + export type ChatTranscriptRenderEvent = | ChatTranscriptVisibleEvent | RenderReasoningEvent @@ -211,7 +238,8 @@ export type ChatTranscriptRenderEvent = | SubagentSpawnAnchorRenderEvent | SubagentResultCardRenderEvent | BackgroundFinishChipRenderEvent - | ScheduledWakeDividerRenderEvent; + | ScheduledWakeDividerRenderEvent + | SpawnWakeDividerRenderEvent; export type ChatTranscriptRenderEnvelope = { key: string; @@ -267,6 +295,10 @@ type SubagentAnchorState = { status: SubagentCardStatus; resultSummary: string | null; error: string | null; + /** Child session id for a spawned ADE chat (`chat:` taskId); null otherwise. */ + childSessionId: string | null; + /** Spawn-kind carried on the `subagent_started` event; null for runtime-native subagents. */ + spawnKind: AgentChatSpawnKind | null; }; type CollapseTranscriptContext = { @@ -887,6 +919,9 @@ function spawnAnchorEvent(state: SubagentAnchorState): SubagentSpawnAnchorRender toolCount: state.toolCount, startedAt: state.startedAt, endedAt: state.endedAt, + childSessionId: state.childSessionId, + spawnKind: state.spawnKind, + resultSummary: state.resultSummary, }; } @@ -907,12 +942,22 @@ function enrichSubagentStateFromEvent( background?: unknown; description?: unknown; command?: unknown; + spawnKind?: unknown; }; state.description = longerSubagentText(state.description, subagentText(record.description)); state.agentType = preferredSubagentAgentType(state.agentType, subagentText(event.agentType)); state.taskType = subagentText(event.taskType) ?? state.taskType; state.command = longerSubagentText(state.command, subagentText(record.command)); if (record.background === true) state.background = true; + // A spawned ADE chat carries a `chat:` taskId; the child session id (for + // navigation) is the agentId, equivalently taskId.slice("chat:".length). + const taskId = subagentText(event.taskId); + if (taskId && taskId.startsWith("chat:") && !state.childSessionId) { + state.childSessionId = subagentText(event.agentId) ?? (taskId.slice("chat:".length) || null); + } + if (typeof record.spawnKind === "string" && (record.spawnKind === "subagent" || record.spawnKind === "peer" || record.spawnKind === "none")) { + state.spawnKind = record.spawnKind; + } } function classificationInput(state: SubagentAnchorState) { @@ -979,6 +1024,8 @@ function handleSubagentLifecycleEvent( status: "running", resultSummary: null, error: null, + childSessionId: null, + spawnKind: null, }; anchors.set(agentKey, state); if (taskId) anchors.set(taskId, state); @@ -1123,6 +1170,35 @@ export function appendCollapsedChatTranscriptEvent( }, }); } + // A spawned `subagent` that finished delivers a `wake` turn carrying a + // `spawnCompletion` metadata payload — render a distinct "ADE woke this chat" + // header above the synthetic turn. Read the typed slot; keep runtime guards + // since transcript payloads arrive off the wire. + const completion = event.metadata?.spawnCompletion; + if (completion) { + const childSessionId = completion.childSessionId?.trim() ?? ""; + const spawnKind = completion.spawnKind === "subagent" || completion.spawnKind === "peer" || completion.spawnKind === "none" + ? completion.spawnKind + : null; + const status = completion.status === "completed" || completion.status === "failed" || completion.status === "stopped" + ? completion.status + : null; + if (childSessionId && spawnKind && status) { + rows.push({ + key: `spawn-wake:${childSessionId}:${event.turnId ?? sequence}`, + timestamp: envelope.timestamp, + event: { + type: "spawn_wake_divider", + childSessionId, + childTitle: completion.childTitle?.trim() || "spawned chat", + spawnKind, + status, + summary: completion.summary?.trim() || null, + ...(event.turnId ? { turnId: event.turnId } : {}), + }, + }); + } + } } if (event.type === "step_boundary" || event.type === "activity" || event.type === "pending_input_resolved") { diff --git a/apps/desktop/src/renderer/components/chat/spawnNavigation.test.ts b/apps/desktop/src/renderer/components/chat/spawnNavigation.test.ts new file mode 100644 index 000000000..7716375c2 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/spawnNavigation.test.ts @@ -0,0 +1,43 @@ +/* @vitest-environment jsdom */ + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { navigateToSpawnedChat } from "./spawnNavigation"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("navigateToSpawnedChat", () => { + it("dispatches ade:work:select-session with the session id and lane id", () => { + const spy = vi.spyOn(window, "dispatchEvent"); + navigateToSpawnedChat("child-1", "lane-9"); + + const evt = spy.mock.calls + .map(([e]) => e) + .find((e): e is CustomEvent => e instanceof CustomEvent && e.type === "ade:work:select-session"); + expect(evt).toBeTruthy(); + expect(evt!.detail).toEqual({ sessionId: "child-1", laneId: "lane-9" }); + }); + + it("defaults laneId to null when omitted", () => { + const spy = vi.spyOn(window, "dispatchEvent"); + navigateToSpawnedChat("child-2"); + + const evt = spy.mock.calls + .map(([e]) => e) + .find((e): e is CustomEvent => e instanceof CustomEvent && e.type === "ade:work:select-session"); + expect(evt!.detail).toEqual({ sessionId: "child-2", laneId: null }); + }); + + it("is a no-op when the session id is falsy", () => { + const spy = vi.spyOn(window, "dispatchEvent"); + navigateToSpawnedChat(null); + navigateToSpawnedChat(undefined, "lane-1"); + navigateToSpawnedChat(""); + + const nav = spy.mock.calls + .map(([e]) => e) + .find((e): e is CustomEvent => e instanceof CustomEvent && e.type === "ade:work:select-session"); + expect(nav).toBeUndefined(); + }); +}); diff --git a/apps/desktop/src/renderer/components/chat/spawnNavigation.ts b/apps/desktop/src/renderer/components/chat/spawnNavigation.ts new file mode 100644 index 000000000..97585e3cb --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/spawnNavigation.ts @@ -0,0 +1,21 @@ +/** + * Canonical navigation to a spawned child chat. Every spawn surface (inline + * cards, completion chips, the lineage breadcrumb, the actions-pane row) routes + * through this one helper so the `ade:work:select-session` contract — and its + * try/catch guard — stays consistent. A no-op when `sessionId` is falsy. + */ +export function navigateToSpawnedChat( + sessionId: string | null | undefined, + laneId?: string | null, +): void { + if (!sessionId) return; + try { + window.dispatchEvent( + new CustomEvent("ade:work:select-session", { + detail: { sessionId, laneId: laneId ?? null }, + }), + ); + } catch { + /* no-op */ + } +} diff --git a/apps/desktop/src/renderer/components/terminals/SessionCard.test.tsx b/apps/desktop/src/renderer/components/terminals/SessionCard.test.tsx index 84e752b3a..fb69d56e6 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionCard.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionCard.test.tsx @@ -122,6 +122,83 @@ describe("SessionCard orchestration identity", () => { }); }); +describe("SessionCard spawn type + live children", () => { + it("renders the SUBAGENT pill for a subagent-spawned session", () => { + render( + , + ); + + const pill = screen.getByText("SUBAGENT"); + expect(pill).toBeTruthy(); + expect(pill.getAttribute("data-spawn-kind")).toBe("subagent"); + }); + + it("renders the PEER pill for a peer-spawned session", () => { + render( + , + ); + + expect(screen.getByText("PEER")).toBeTruthy(); + }); + + it("hides the spawn-type pill for none/undefined spawnKind", () => { + render( + , + ); + + expect(screen.queryByText("SUBAGENT")).toBeNull(); + expect(screen.queryByText("PEER")).toBeNull(); + }); + + it("shows the live-children badge when children are running", () => { + render( + , + ); + + expect(screen.getByLabelText("3 live spawned chats")).toBeTruthy(); + }); + + it("hides the live-children badge when there are none", () => { + render( + , + ); + + expect(screen.queryByLabelText(/live spawned chat/)).toBeNull(); + }); +}); + describe("SessionCard auto-naming status", () => { it("shows the auto-naming status in place of the preview while the lane is being named", () => { setLaneNaming("lane-1", true); diff --git a/apps/desktop/src/renderer/components/terminals/SessionCard.tsx b/apps/desktop/src/renderer/components/terminals/SessionCard.tsx index c08ff967a..0cbc8d748 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionCard.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionCard.tsx @@ -1,6 +1,6 @@ import React from "react"; import { GridFour, WarningCircle, Question, Clock } from "@phosphor-icons/react"; -import type { LaneSummary, TerminalSessionSummary } from "../../../shared/types"; +import type { AgentChatSpawnKind, LaneSummary, TerminalSessionSummary } from "../../../shared/types"; import type { OrchestrationRole } from "../../../shared/types/orchestration"; import { sessionStatusDot, @@ -117,6 +117,44 @@ function orchestrationRoleA11yLabel(role: OrchestrationRole, tag?: string | null return role; } +/* ────────────────────────────────────────────────────────────────────────── + Spawn-type pill — cosmetic relationship + completion-report policy declared + by the spawner. Shares the orchestration-pill shape (uppercase mono capsule), + type-tinted: subagent = violet (the chat accent), peer = steel/neutral slate. + Hidden for `none`/undefined (the default, untyped spawn). + ────────────────────────────────────────────────────────────────────────── */ + +const SPAWN_TYPE_PILL_PALETTE: Record< + "subagent" | "peer", + { background: string; borderColor: string; color: string } +> = { + subagent: { + background: "rgba(167, 139, 250, 0.14)", + borderColor: "rgba(167, 139, 250, 0.42)", + color: "rgb(196, 181, 253)", + }, + peer: { + background: "rgba(148, 163, 184, 0.12)", + borderColor: "rgba(148, 163, 184, 0.34)", + color: "rgb(203, 213, 225)", + }, +}; + +function SpawnTypePill({ spawnKind }: { spawnKind: AgentChatSpawnKind }) { + if (spawnKind !== "subagent" && spawnKind !== "peer") return null; + const palette = SPAWN_TYPE_PILL_PALETTE[spawnKind]; + const label = spawnKind.toUpperCase(); + return ( + + {label} + + ); +} + /* ────────────────────────────────────────────────────────────────────────── Attention capsule — one-word status from the shared canonical state module. Only ever renders for the three attention states; calm sessions get nothing @@ -220,6 +258,7 @@ export const SessionCard = React.memo(function SessionCard({ onContextMenu, compact = false, gridBadge = null, + liveChildrenCount = 0, }: { session: TerminalSessionSummary; lane: LaneSummary | null; @@ -230,6 +269,8 @@ export const SessionCard = React.memo(function SessionCard({ compact?: boolean; /** Grid membership indicator: "active" = in the currently-viewed grid, "inactive" = in another grid, null = not gridded. */ gridBadge?: "active" | "inactive" | null; + /** Count of this session's still-running spawned children (drives the `▸N` badge). */ + liveChildrenCount?: number; }) { const dot = sessionStatusDot(session); // Blocked on a chat question/plan only the user can answer (distinct from a @@ -399,6 +440,22 @@ export const SessionCard = React.memo(function SessionCard({ /> ) : null} + {session.spawnKind && session.spawnKind !== "none" ? ( + + ) : null} + {liveChildrenCount > 0 ? ( + + + {liveChildrenCount} + + ) : null} {staleAgeHours != null ? ( { + const map = new Map(); + for (const session of allSessions) { + const parentId = session.orchestrationParentSessionId; + if (!parentId || parentId === session.id) continue; + if (session.status !== "running") continue; + map.set(parentId, (map.get(parentId) ?? 0) + 1); + } + return map; + }, [allSessions]); const isChildSectionCollapsed = useCallback( (parentId: string) => workCollapsedSectionIds.includes(`chat:${parentId}`), [workCollapsedSectionIds], @@ -621,6 +634,7 @@ export const SessionListPane = React.memo(function SessionListPane({ key={session.id} session={session} lane={laneById.get(session.laneId) ?? null} + liveChildrenCount={liveChildrenByParentId.get(session.id) ?? 0} isSelected={selectedSessionId === session.id} isMultiSelected={selectedSessionIds?.has(session.id) ?? false} onSelect={(id, event) => onSelectSession(id, event, renderedSessionIds)} diff --git a/apps/desktop/src/shared/types/chat.ts b/apps/desktop/src/shared/types/chat.ts index 8c7a5fb71..72390cedc 100644 --- a/apps/desktop/src/shared/types/chat.ts +++ b/apps/desktop/src/shared/types/chat.ts @@ -172,6 +172,20 @@ export type AgentChatOpenCodePermissionMode = "plan" | "edit" | "full-auto" | "c export type AgentChatDroidPermissionMode = "read-only" | "auto-low" | "auto-medium" | "auto-high" | "agi"; export type AgentChatResumeFailureKind = "thread_missing" | "provider_environment" | "transient" | "unknown"; +export type AgentChatSpawnKind = "subagent" | "peer" | "none"; + +/** + * Terminal outcome of a spawned child chat, delivered to the spawner. Rides the + * `subagent` wake message's metadata and the `peer` `spawn_completed` notice + * detail — one shape both producer and every renderer consumer narrow off. + */ +export type AgentChatSpawnCompletion = { + childSessionId: string; + childTitle: string; + spawnKind: AgentChatSpawnKind; + status: "completed" | "failed" | "stopped"; + summary?: string; +}; export type AgentChatContinuityRecovery = { state: "required" | "reconstructed"; @@ -218,6 +232,16 @@ export type AgentChatNoticeDetail = { laneId?: string | null; title?: string; }; + spawnKind?: AgentChatSpawnKind; + /** + * True when an inline spawned-chat card (`subagent_started`) accompanies this + * `subagent_spawned` notice — i.e. a plain (non-orchestration) spawn. The + * renderer suppresses the quiet pill in that case (the card is the surface) and + * keeps the pill only for orchestration-run children, which emit the notice but + * no inline card. Absent/false → render the pill. + */ + hasInlineCard?: boolean; + spawnCompletion?: AgentChatSpawnCompletion; crossMachineHandoff?: { handoffId: string; targetMachineName: string; @@ -426,6 +450,9 @@ export type AgentChatScheduledWakeMetadata = { export type AgentChatEventMetadata = Record & { /** Marks a synthetic unattended turn started by ADE's durable scheduler. */ scheduledWake?: AgentChatScheduledWakeMetadata; + /** Rides the `subagent` completion wake so the renderer can render the + * "ADE woke this chat" divider off a typed shape (mirrors `scheduledWake`). */ + spawnCompletion?: AgentChatSpawnCompletion; }; export type AgentChatScheduledWorkKind = @@ -690,6 +717,7 @@ export type AgentChatEvent = description: string; background?: boolean; taskType?: "subagent" | "background" | "local_workflow" | "cron" | "other"; + spawnKind?: AgentChatSpawnKind; workflowName?: string; turnId?: string; } @@ -1102,6 +1130,7 @@ export type OrchestrationSessionFields = { orchestrationRunId?: string; orchestrationRole?: OrchestrationRole; orchestrationParentSessionId?: string; + spawnKind?: AgentChatSpawnKind; orchestrationTag?: string; orchestrationStepId?: string; orchestrationBundlePath?: string; @@ -1649,6 +1678,7 @@ export type AgentChatCreateArgs = { orchestrationRunId?: string; orchestrationRole?: OrchestrationRole; orchestrationParentSessionId?: string; + spawnKind?: AgentChatSpawnKind; orchestrationTag?: string; orchestrationStepId?: string; orchestrationBundlePath?: string; diff --git a/apps/desktop/src/shared/types/sessions.ts b/apps/desktop/src/shared/types/sessions.ts index e04dc5e0d..0f8309ba9 100644 --- a/apps/desktop/src/shared/types/sessions.ts +++ b/apps/desktop/src/shared/types/sessions.ts @@ -8,6 +8,7 @@ import type { AgentChatCodexApprovalPolicy, AgentChatCodexConfigSource, AgentChatCodexSandbox, + AgentChatSpawnKind, } from "./chat"; import type { LaneLinearIssue } from "./lanes"; import type { OrchestrationRole } from "./orchestration"; @@ -123,6 +124,15 @@ export type TerminalSessionSummary = { orchestrationRunId?: string; orchestrationRole?: OrchestrationRole; orchestrationTag?: string; + /** + * Spawn lineage for chat-backed sessions, projected from the underlying chat + * session (independent of any orchestration run). `orchestrationParentSessionId` + * marks a chat spawned by another chat; `spawnKind` is the spawner-declared, + * cosmetic relationship (subagent/peer/none) the sidebar renders as a pill and + * uses to count a spawner's live children. Both optional for migration tolerance. + */ + orchestrationParentSessionId?: string; + spawnKind?: AgentChatSpawnKind; }; export type TerminalSessionDetail = TerminalSessionSummary & { diff --git a/docs/features/agents/README.md b/docs/features/agents/README.md index de31a8e3a..da9ea96e1 100644 --- a/docs/features/agents/README.md +++ b/docs/features/agents/README.md @@ -12,7 +12,7 @@ The former worker/hiring agents were removed. There is one persistent identity | `apps/desktop/src/main/services/cto/ctoMemoryService.ts` | The CTO's smart-memory file store (`MEMORY.md`, `thread-state.md`, daily logs, search, injection sections). | | `apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts` | CTO operator tools for chat spawning, lanes/PRs/git/tests, Linear reads/writes, and the `saveMemory` / `searchMemory` / `readMemory` memory tools. | | `apps/desktop/src/main/services/agentTools/agentToolsService.ts` | Detects external CLI tools on PATH. | -| `apps/ade-cli/src/cli.ts` | Agent-focused `ade` command surface and text/JSON output formatters. Includes the `ade ios-sim` (alias `ade ios`, `ade simulator`) family — see [iOS Simulator feature](../ios-simulator/README.md), the `ade --socket app-control ...` driver for live Electron apps, and the `ade --socket browser ...` driver for the in-app browser. `ade secrets list|get|set|delete` is the typed surface for encrypted project-scoped ADE secrets that agents may read when the user names a secret. `ade new chat --mode chat|cli --lane --provider codex --model --reasoning-effort --no-fast --permissions full-auto --prompt "..."` mirrors the desktop New Chat toggle. `ade chat create` / `ade new --mode chat` default `orchestrationParentSessionId` from `ADE_CHAT_SESSION_ID` so agent-spawned chats link back to the spawning chat (`--parent ` overrides, `--no-parent` opts out). `ade chat read --text` reads recent transcript messages. `ade chat scheduled-work list [session] [--all]` reads the runtime's durable management store, while `ade chat scheduled-work cancel ` uses the same provider-aware cancellation path as desktop Settings. `ade chat ... --personal` lists, creates, reads, sends to, interrupts, archives, and deletes machine-owned projectless chats through the running brain. `ade lanes link-linear-issue --linear-issue-json '{...}'` (aliases `link-linear`, `linear-link`) links Linear issues to an existing lane. | +| `apps/ade-cli/src/cli.ts` | Agent-focused `ade` command surface and text/JSON output formatters. Includes the `ade ios-sim` (alias `ade ios`, `ade simulator`) family — see [iOS Simulator feature](../ios-simulator/README.md), the `ade --socket app-control ...` driver for live Electron apps, and the `ade --socket browser ...` driver for the in-app browser. `ade secrets list|get|set|delete` is the typed surface for encrypted project-scoped ADE secrets that agents may read when the user names a secret. `ade new chat --mode chat|cli --lane --provider codex --model --reasoning-effort --no-fast --permissions full-auto --type --prompt "..."` mirrors the desktop New Chat toggle. `ade chat create` / `ade new --mode chat` default `orchestrationParentSessionId` from `ADE_CHAT_SESSION_ID` so agent-spawned chats link back to the spawning chat (`--parent ` overrides, `--no-parent` opts out). `--type` (chat mode only; alias `--spawn-type`) sets the child's `AgentChatSpawnKind` and thereby its completion-report policy — see [Chat › Spawn types and completion reporting](../chat/README.md#spawn-types-and-completion-reporting). `ade chat read --text` reads recent transcript messages. `ade chat scheduled-work list [session] [--all]` reads the runtime's durable management store, while `ade chat scheduled-work cancel ` uses the same provider-aware cancellation path as desktop Settings. `ade chat ... --personal` lists, creates, reads, sends to, interrupts, archives, and deletes machine-owned projectless chats through the running brain. `ade lanes link-linear-issue --linear-issue-json '{...}'` (aliases `link-linear`, `linear-link`) links Linear issues to an existing lane. | | `apps/ade-cli/src/adeRpcServer.ts` | Private ADE action RPC: registers actions, handles JSON-RPC, applies session-identity-based filtering, builds lane-scoped ADE guidance / `ADE_AGENT_SKILLS_DIRS` for CLI launches, injects an explicitly enabled and verified direct Computer Use MCP client into tracked Codex launches, and returns GitHub + ADE PR URLs from PR creation tools when available. | | `apps/desktop/src/main/services/builtInBrowser/builtInBrowserActorCapabilities.ts`, `desktopBridgeServer.ts`; `apps/ade-cli/src/services/builtInBrowser/desktopBridgeClient.ts` | Browser-automation security boundary. ADE issues an opaque in-memory capability for each chat-owned agent/terminal; the runtime strips caller routing and carries the token over a separately authenticated bridge, then Electron validates it in the issuing process and restores only its bound browser scope. | | `apps/desktop/src/main/utils/codexComputerUse.ts` | Security boundary for direct Codex Computer Use: explicit config opt-in, stable/cache candidate resolution, executable check, and strict OpenAI code-signature identity verification. | @@ -59,6 +59,34 @@ but receive a neutral general-assistant prompt and a scratch cwd. Project ADE guidance, project slash-command discovery, orchestration/Linear metadata, and project workflow tools are not injected. See [Personal chats](../personal-chats/README.md). +## Spawning agents and spawn types + +Any chat-bound agent can spawn another full ADE agent by running +`ade new chat` — the child inherits `orchestrationParentSessionId` from the +spawner's `ADE_CHAT_SESSION_ID` (see the CLI row above). The optional +`--type subagent|peer|none` flag sets the child's `AgentChatSpawnKind`, +which is **cosmetic to capabilities**: the child is a normal agent with the +same runtime, permissions, and tools regardless of type. The only thing the +type governs is how ADE reports the child's completion back to the spawner — +`subagent` wakes the spawner, `peer` leaves a quiet note, and `none` (the +default) reports nothing. + +The orchestrator's `spawnAgent` tool +(`services/ai/tools/orchestrationTools.ts`) and the orchestration domain +spawn path (`services/orchestration/orchestrationDomain.ts`) set the same +field, defaulting to `spawnKind: "subagent"` so orchestration workers wake +their lead. A `subagent` child is additionally handed +`ADE_PARENT_CHAT_SESSION_ID` / `ADE_SPAWN_KIND` and a self-report guidance +line so it can optionally post its own summary through +`chat.messageSession` on top of ADE's automatic report. + +The runtime mechanics — the completion-report policy, the wake/notice +delivery, and the navigation/pill/breadcrumb surfacing — live in +[Chat › Spawn types and completion reporting](../chat/README.md#spawn-types-and-completion-reporting). +When the spawned work must carry the current lane's unmerged commits, spawn +into a child lane instead of a fresh one — see +[Lanes › Child-lane guidance for spawned work](../lanes/stacking.md#child-lane-guidance-for-spawned-work). + ## Agent CLI install / auth from chat When a chat targets a provider whose CLI is missing or unauthenticated on the active runtime, the chat surfaces an inline `AgentCliAuthCard`. The card is built by `classifyAgentCliError` from `apps/ade-cli/src/services/agentRegistry.ts` and gives the user a tracked terminal action for install or login. diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index f72c41ecf..a50da72b3 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -22,7 +22,7 @@ for its separate RPC, sync, storage, and UI contracts. | `apps/desktop/src/renderer/components/chat/CrossMachineHandoffModal.tsx` | **Send to machine** workflow in the Handoff tab: source Git readiness, eligible connected-machine selection, brief or full-history fork selection, optional continuation note, destination project matching or confirmed clone, storage/auth/model/commit/lane checks, transport disclosure, route-pinned final send, and recoverable source-marker completion. Cross-machine fork transports provider-native history for Claude, Codex, and OpenCode; Cursor and Droid use brief mode because their histories are not portable between machines yet. A fork that can't be completed always degrades to a one-click brief rather than a dead end: an older destination that omits `forkHandoffSupport`, a history over the transport cap, or an unforkable provider file (e.g. a Codex `.zst` rollout) each surface a plain-language reason and a **send as brief** action that re-runs prepare + preflight in brief mode. The insecure-route consent line is fork-aware — a fork discloses that the full chat history is sent exactly as recorded, while a brief states only the summary is sent, never secrets. See [Cross-machine session handoff](../sync-and-multi-device/cross-machine-session-handoff.md). | | `apps/desktop/src/shared/crossMachineHandoff.ts` and `apps/desktop/src/shared/types/chat.ts` | Renderer-safe Git-origin normalization, portable remote sanitization, untrusted remote-response decoders, and the versioned capsule/preflight/accept DTOs shared across renderer, preload, Electron main, and the ADE runtime. `chat.ts` also owns the fork-handoff contract: `HANDOFF_FORK_PROVIDERS` (`claude`, `codex`, `opencode`, `droid`) + `providerSupportsHandoffFork()`, `AgentChatHandoffArgs.targetLaneId` (brief may retarget any lane in the project; fork must stay in the source lane), the cross-machine capsule's optional `mode: "brief" \| "fork"` with `forkTransport` (provider-native session files) and `transcriptEnvelopes` (gzipped ADE JSONL), and the preflight's optional `forkHandoffSupport` (absent = older destination the source must treat as fork-unsupported, so a fork never silently downgrades to a brief). `decodeCrossMachineDestinationPreflightResult` decodes `forkHandoffSupport` only when present. | | `apps/desktop/src/main/services/chat/crossMachineForkTransport.ts` | Node-only fork-transport plumbing shared by the source packaging and destination materialization paths. Owns the uncompressed limits (18 MiB provider main session file, 4 MiB total Claude sidecars, 3 MiB ADE transcript envelopes), the independent base64 bounds that reject oversized input before decoding, and `CROSS_MACHINE_FORK_ENCODED_BUDGET_BYTES` (20 MiB) — a whole-capsule encoded budget kept under the 25 MiB sync-envelope/WebSocket payload caps. `gzipToBase64` / `gunzipFromBase64` (the latter enforces a max output length) do the compression; `enforceCrossMachineForkEncodedBudget` drops the sidecar group first and only throws a "too large, send a brief" error when the main file plus transcript alone blow the budget; `crossMachineForkOversizeError` returns the typed `CROSS_MACHINE_FORK_OVERSIZE` failure; `runCliCapture` buffers `opencode export` / `import` stdout/stderr with a timeout; and `validateForkTransport` re-validates a received capsule's transport (provider match, kind allowlist, base64 shape, path-traversal-safe side-file paths, per-file and total size caps) before any decode. | -| `apps/desktop/src/main/services/chat/agentChatService.ts` | Main service: session lifecycle, external chat import orchestration (`importExternalChatSession` for Claude/Codex sessions discovered by the external-session service), turn dispatch, event emission, provider adapters, steer queue, handoff, auto-title, prompt-derived lane-name suggestions for auto-created / parallel lanes, event-history snapshots, durable chat transcript replay/storage compaction, slash-command discovery/merge (delegates to per-provider discovery modules and `slashCommandPromptExpansion` for unified prompt expansion), and active-workload detection used by project/window close guards. Codex non-retrying app-server failures are deduplicated by turn plus semantic error identity across the early `error` notification and terminal `turn/completed`; retrying notifications (`willRetry: true`) remain provider-health notices while the turn stays active. Lane naming runs through the session-intelligence prompt path, retries the configured/requested/default title models — the auto-title candidate order prefers the configured `titleModelId` before the session's `requestedModelId` — then falls back to a deterministic prompt slug; branch uniqueness is handled by the lane id suffix added by lane creation. Tracks Fast Mode with the legacy `codexFastMode: boolean` session field for every provider whose descriptor advertises `serviceTiers: ["fast"]`; Codex forwards it as `serviceTier: "fast" \| null` on every `thread/start` and `turn/start` JSON-RPC call, while Cursor SDK sessions resolve it through discovered model parameters (see [Agent Routing](agent-routing.md#provider-service-tiers-fast-mode)). Codex chat goals are managed through the app-server `thread/goal/get` / `set` / `clear` RPCs, persisted in session summaries, validated to the provider's 4,000-character objective limit, and normalized to ADE's unlimited-budget policy by sending `tokenBudget: null` and clearing provider-reported budgets. `applyCodexEffectiveThreadState` accepts a `requestedCodexPolicy` option and uses `shouldPreserveRequestedCodexPolicy` to keep ADE-controlled picker selections authoritative when the lifecycle response echoes an older thread policy (prevents a manual Plan→Edit switch from snapping back); it also syncs the abstract `permissionMode` via `syncLegacyPermissionMode` after every policy application. Whenever an `updateSession` touches any permission/interaction/mode field, the service also emits a transient `session_meta_updated` chat event carrying the recomputed mode fields (`permissionMode`, `interactionMode`, `claudePermissionMode`, `codexApprovalPolicy`/`codexSandbox`/`codexConfigSource`, `opencodePermissionMode`, `droidPermissionMode`, `cursorModeId`, and the `cursorModeSnapshot`) so any other client viewing the same session — a desktop refreshing a session an iOS device just re-moded, or vice versa — updates its composer controls live. It is a direct state patch, emitted after the Cursor policy sync so `cursorModeSnapshot` reflects the recomputed mode, and is kept off the session-list refresh path. Builds ADE guidance from the active lane worktree so Agent Skill roots are lane-scoped in persistent system/developer prompts and provider fallback injection. Spawns Claude/Codex agent runtimes with `buildAgentRuntimeEnv(managed)` so every agent process inherits `ADE_CHAT_SESSION_ID`, `ADE_LANE_ID`, `ADE_PROJECT_ROOT`, and `ADE_WORKSPACE_ROOT` (used by the agent guidance to call `ade --socket app-control logs` / `terminal read --chat-session "$ADE_CHAT_SESSION_ID"` without resolving the chat ID itself). When the session has Linear issues attached (`session_linear_issues`), `buildAgentRuntimeEnv` also materializes them into a per-session context file via `writeSessionLinearIssueContextFile` (`//linear-issues.json`, written atomically; stale files cleared when nothing is attached) and sets `ADE_LINEAR_ISSUE_IDS` (comma-joined identifiers) + `ADE_LINEAR_CONTEXT_FILE` so the agent reads its issue context without Linear credentials. Attaching a `linear_issue` context attachment at run time calls `laneService.attachLinearIssueToSession({ chatSessionId, issues, role: "worked", source: "chat_attach", includeInPr: true })` so the link is persisted even for standalone (laneless) chats; when the session has a lane it additionally runs `laneService.linkLinearIssues` for the lane/PR-card semantics. See [Linear integration](../linear-integration/README.md#session-scoped-issue-attachment-and-cli-context-injection). Claude SDK sessions also resolve the executable through `claudeCodeExecutable.ts` and pass `pathToClaudeCodeExecutable` so packaged builds can prefer the bundled native binary before PATH/auth fallbacks; interrupted Claude turns stop active subagents before emitting stopped `subagent_result`s, and every `subagent_result` is gated on a previously emitted `subagent_started` (tracked in `emittedSubagentStartIds`) so an interrupt can never emit a phantom stopped card for a subagent that never announced — terminal events clear both the taskId and agentId aliases. A plain Claude Code task run (`task_type` `other`, no agent metadata — e.g. "Re-run affected test files") is tracked for cleanup but never surfaces subagent rows. Claude resume paths run `claudeThinkingTranscriptRepair` before loading a transcript, and the runtime self-heals the same corruption after the Anthropic thinking-block 400 error. Full-auto plan acceptance emits the same plan-mode exit notice as the manual approval path so the renderer composer chip can update even when the session refresh races with compaction. Cursor SDK setup records interrupts that arrive while the worker is still being acquired, releases the acquired generation if setup loses the race, and suppresses false provider-health failures for user-initiated setup interrupts. Cursor provider slash commands use a dedicated discovery path (`cursorSlashCommandDiscovery`) instead of falling through to the generic filesystem-backed list. Claude query startup is single-flight: concurrent `ensureClaudeQuery` callers latch onto one in-flight `queryStartPromise`, and a per-runtime `queryGeneration` token aborts and reaps a start that a reset or interrupt superseded, so a resumed session never spawns twin subprocesses; both reset and interrupt reap the SDK subprocess through `claudeSubprocessReaper` because a closed `query()` still leaves a live `claude --resume` child. `run_in_background` shell tasks (SDK `task_type` `local_bash`/`background`) survive turn boundaries — the query stays alive across turns and delivers their real completion — so only interrupt, reset/dispose, or a host-restart rebind settle them as stopped; a reset that orphans still-open background tasks emits one `system_notice` that they were stopped without reporting completion, and background-task titles are sticky (the first spawn description is reused through the terminal row). A durable per-`(SDK message id, content index)` emitted-text record keeps a re-delivered assistant snapshot (after a stream-dedup reset from steer, message interleave, or idle handoff) from doubling the transcript. Claude `TaskCreate`/`TaskUpdate` tracking keys creates by tool-use id and remaps the harness's ordinal task id onto the Nth created task; an update for an id it cannot resolve or describe changes nothing rather than fabricating a todo row. `steer()` returns `AgentChatSteerResult` (`{ steerId, queued, reason?: "queue_full" }`); reasoning effort is normalized and applied at steer delivery, and an active Claude `interrupt-replace` uses SDK priority `now` without tearing down the query or its background work. Large service file. | +| `apps/desktop/src/main/services/chat/agentChatService.ts` | Main service: session lifecycle, external chat import orchestration (`importExternalChatSession` for Claude/Codex sessions discovered by the external-session service), turn dispatch, event emission, provider adapters, steer queue, handoff, auto-title, prompt-derived lane-name suggestions for auto-created / parallel lanes, event-history snapshots, durable chat transcript replay/storage compaction, slash-command discovery/merge (delegates to per-provider discovery modules and `slashCommandPromptExpansion` for unified prompt expansion), and active-workload detection used by project/window close guards. Codex non-retrying app-server failures are deduplicated by turn plus semantic error identity across the early `error` notification and terminal `turn/completed`; retrying notifications (`willRetry: true`) remain provider-health notices while the turn stays active. Lane naming runs through the session-intelligence prompt path, retries the configured/requested/default title models — the auto-title candidate order prefers the configured `titleModelId` before the session's `requestedModelId` — then falls back to a deterministic prompt slug; branch uniqueness is handled by the lane id suffix added by lane creation. Tracks Fast Mode with the legacy `codexFastMode: boolean` session field for every provider whose descriptor advertises `serviceTiers: ["fast"]`; Codex forwards it as `serviceTier: "fast" \| null` on every `thread/start` and `turn/start` JSON-RPC call, while Cursor SDK sessions resolve it through discovered model parameters (see [Agent Routing](agent-routing.md#provider-service-tiers-fast-mode)). Codex chat goals are managed through the app-server `thread/goal/get` / `set` / `clear` RPCs, persisted in session summaries, validated to the provider's 4,000-character objective limit, and normalized to ADE's unlimited-budget policy by sending `tokenBudget: null` and clearing provider-reported budgets. `applyCodexEffectiveThreadState` accepts a `requestedCodexPolicy` option and uses `shouldPreserveRequestedCodexPolicy` to keep ADE-controlled picker selections authoritative when the lifecycle response echoes an older thread policy (prevents a manual Plan→Edit switch from snapping back); it also syncs the abstract `permissionMode` via `syncLegacyPermissionMode` after every policy application. Whenever an `updateSession` touches any permission/interaction/mode field, the service also emits a transient `session_meta_updated` chat event carrying the recomputed mode fields (`permissionMode`, `interactionMode`, `claudePermissionMode`, `codexApprovalPolicy`/`codexSandbox`/`codexConfigSource`, `opencodePermissionMode`, `droidPermissionMode`, `cursorModeId`, and the `cursorModeSnapshot`) so any other client viewing the same session — a desktop refreshing a session an iOS device just re-moded, or vice versa — updates its composer controls live. It is a direct state patch, emitted after the Cursor policy sync so `cursorModeSnapshot` reflects the recomputed mode, and is kept off the session-list refresh path. Builds ADE guidance from the active lane worktree so Agent Skill roots are lane-scoped in persistent system/developer prompts and provider fallback injection. Spawns Claude/Codex agent runtimes with `buildAgentRuntimeEnv(managed)` so every agent process inherits `ADE_CHAT_SESSION_ID`, `ADE_LANE_ID`, `ADE_PROJECT_ROOT`, and `ADE_WORKSPACE_ROOT` (used by the agent guidance to call `ade --socket app-control logs` / `terminal read --chat-session "$ADE_CHAT_SESSION_ID"` without resolving the chat ID itself). When the session has Linear issues attached (`session_linear_issues`), `buildAgentRuntimeEnv` also materializes them into a per-session context file via `writeSessionLinearIssueContextFile` (`//linear-issues.json`, written atomically; stale files cleared when nothing is attached) and sets `ADE_LINEAR_ISSUE_IDS` (comma-joined identifiers) + `ADE_LINEAR_CONTEXT_FILE` so the agent reads its issue context without Linear credentials. Attaching a `linear_issue` context attachment at run time calls `laneService.attachLinearIssueToSession({ chatSessionId, issues, role: "worked", source: "chat_attach", includeInPr: true })` so the link is persisted even for standalone (laneless) chats; when the session has a lane it additionally runs `laneService.linkLinearIssues` for the lane/PR-card semantics. See [Linear integration](../linear-integration/README.md#session-scoped-issue-attachment-and-cli-context-injection). Claude SDK sessions also resolve the executable through `claudeCodeExecutable.ts` and pass `pathToClaudeCodeExecutable` so packaged builds can prefer the bundled native binary before PATH/auth fallbacks; interrupted Claude turns stop active subagents before emitting stopped `subagent_result`s, and every `subagent_result` is gated on a previously emitted `subagent_started` (tracked in `emittedSubagentStartIds`) so an interrupt can never emit a phantom stopped card for a subagent that never announced — terminal events clear both the taskId and agentId aliases. A plain Claude Code task run (`task_type` `other`, no agent metadata — e.g. "Re-run affected test files") is tracked for cleanup but never surfaces subagent rows. Claude resume paths run `claudeThinkingTranscriptRepair` before loading a transcript, and the runtime self-heals the same corruption after the Anthropic thinking-block 400 error. Full-auto plan acceptance emits the same plan-mode exit notice as the manual approval path so the renderer composer chip can update even when the session refresh races with compaction. Cursor SDK setup records interrupts that arrive while the worker is still being acquired, releases the acquired generation if setup loses the race, and suppresses false provider-health failures for user-initiated setup interrupts. Cursor provider slash commands use a dedicated discovery path (`cursorSlashCommandDiscovery`) instead of falling through to the generic filesystem-backed list. Claude query startup is single-flight: concurrent `ensureClaudeQuery` callers latch onto one in-flight `queryStartPromise`, and a per-runtime `queryGeneration` token aborts and reaps a start that a reset or interrupt superseded, so a resumed session never spawns twin subprocesses; both reset and interrupt reap the SDK subprocess through `claudeSubprocessReaper` because a closed `query()` still leaves a live `claude --resume` child. `run_in_background` shell tasks (SDK `task_type` `local_bash`/`background`) survive turn boundaries — the query stays alive across turns and delivers their real completion — so only interrupt, reset/dispose, or a host-restart rebind settle them as stopped; a reset that orphans still-open background tasks emits one `system_notice` that they were stopped without reporting completion, and background-task titles are sticky (the first spawn description is reused through the terminal row). A durable per-`(SDK message id, content index)` emitted-text record keeps a re-delivered assistant snapshot (after a stream-dedup reset from steer, message interleave, or idle handoff) from doubling the transcript. Claude `TaskCreate`/`TaskUpdate` tracking keys creates by tool-use id and remaps the harness's ordinal task id onto the Nth created task; an update for an id it cannot resolve or describe changes nothing rather than fabricating a todo row. `steer()` returns `AgentChatSteerResult` (`{ steerId, queued, reason?: "queue_full" }`); reasoning effort is normalized and applied at steer delivery, and an active Claude `interrupt-replace` uses SDK priority `now` without tearing down the query or its background work. When a spawned child chat ends, `reportChildSpawnEnded` reports its outcome to the spawner according to the child's `spawnKind` (see [Spawn types and completion reporting](#spawn-types-and-completion-reporting)); spawned agents also inherit `ADE_PARENT_CHAT_SESSION_ID` / `ADE_SPAWN_KIND` and a subagent self-report guidance line. Large service file. | | `apps/desktop/src/main/services/chat/providerResumeClassifier.ts` | Classifies Codex resume failures without conflating missing threads with MCP/provider-environment or transient transport failures; rollout-file evidence keeps a locally known thread from being declared missing. | | `apps/desktop/src/renderer/components/chat/ChatContinuityRecoveryCard.tsx` | Renders the explicit continuity-recovery choices from a `system_notice`: retry the preserved thread, reconstruct from durable ADE history, or start a separate chat. | | `apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.ts` | Runtime-owned durable mirror and wake coordinator for Claude `ScheduleWakeup`, durable `CronCreate`, and `/loop`. Persists versioned records, provider ids, expiry/terminal timestamps, and per-chat pause state in the project SQLite `kv` store; restores and re-arms them on service start; coalesces overdue work to one late fire; and reports transitions back to `agentChatService`. Startup migration drops the pre-1.2.27 `cron-tool:` intent placeholders that Claude could never cancel, quarantines older active provider rows in a paused state for operator review, and bounds terminal history to the newest 200 rows or seven days. Uses injected time/timer/persistence adapters so restart, pause, collision, migration, expiry, and catch-up behavior can be tested without Electron. | @@ -84,6 +84,7 @@ for its separate RPC, sync, storage, and UI contracts. | `apps/desktop/src/renderer/state/appStore.ts` | Shared renderer state store. Besides project/lane/work selection, it persists user preferences such as `launchPromptClipboardEnabled` and `launchPromptClipboardNoticeEnabled`, mirrors them into per-project stores, and owns `draftLaunchJobsByScope` (+ `setDraftLaunchJobs`) for Work draft launch status strips plus `handoffLaunchJobsByScope` (+ `setHandoffLaunchJobs`) for Work sidebar handoff placeholders. These live in the **root** store (not the per-project store) on purpose: in-flight launches must survive a remote project switch that destroys the originating per-project store; `AgentChatPane` reads them via `useRootAppStore` / `rootAppStoreApi.getState()`. | | `apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx` | Virtualized transcript renderer. Coalesces resize / measurement updates and, while sticky-to-bottom is active, follows height changes across multiple animation frames so streamed output and late row measurements do not leave the user above the newest message. Programmatic scroll writes are tracked by target scroll position, not a stale counter, so browser-coalesced scroll events do not swallow the next real user gesture. Activity bundles fold todo/scheduled updates and placeholder subagent updates into compact rows, dedupe placeholder subagent parents once the concrete child id is known, and open Chat Info / subagent detail instead of duplicating the drawer roster inline; real subagents instead render as inline `SubagentSpawnCard` / `SubagentResultCard` rows anchored where they started and settled (background shell commands collapse to a single `BackgroundFinishChip`), with jump-to-result / jump-to-start affordances that reuse the stable-row scroll machinery; a run of two or more interrupt-stopped subagents folds into one calm `SubagentStoppedGroupCard` instead of a wall of identical stopped cards. Synthetic scheduled turns render an amber `Woke on schedule` divider with fire time, reason, and late marker; the existing stable-row scroll machinery accepts jump requests from the while-you-were-away strip. Codex goal lifecycle events render as compact user-facing rows (`Goal set`, `Goal paused`, `Goal cleared`) instead of raw JSON-RPC/status wording. Codex runtime notices (`codex_safety_buffering`, `codex_moderation_metadata`, `codex_sleep`, `codex_thread_deleted`) render as small transcript chips. `codex_turn_stalled` renders a live recovery card: Wait re-arms the watchdog, Nudge sends a status steer, Retry interrupts and replays work in the same thread, and Resume restarts app-server, resumes the thread, and retries. Handoff brief user messages with `metadata.hideFullPrompt` show only their `displayText` breadcrumb and do not expose or copy the internal prompt body. History seeded into a forked chat (envelopes tagged `providerOrigin: "handoff_fork"`) renders under a single `Forked from the previous chat — full history above` divider pinned to the first live row after the seeded tail, rather than a per-row marker. Error events whose `errorInfo.agentCli.category` is `"unauthenticated"` render as the calm `AgentCliAuthCard` (raw 401 behind a `Details` disclosure) rather than the red error block, so a recoverable logout reads as a re-login prompt, not a crash. | | `apps/desktop/src/renderer/components/chat/SubagentActivityCards.tsx` | Inline subagent transcript cards mounted by `AgentChatMessageList` from the render events `chatTranscriptRows.ts` derives. `SubagentSpawnCard` anchors where the agent started (identicon/colour from `chatSubagentIdentity`, task title, agent-type/background chips, a single live `running · · tools · ` line that ticks each second, and a `jump to result` link once the agent ends); `SubagentResultCard` renders at the settle position (status + duration, ~2-line report preview, View transcript, `jump to start`, warm amber tones for stopped/failed instead of red error blocks); `BackgroundFinishChip` is the one-line finish chip for backgrounded shell commands; `SubagentStoppedGroupCard` collapses a run of interrupt-stopped subagents into one amber "N agents stopped when you interrupted" line that expands to a per-agent list with `jump to start` links. All inherit `--chat-accent`. | +| `apps/desktop/src/renderer/components/chat/spawnNavigation.ts` | One canonical `navigateToSpawnedChat(sessionId, laneId?)` helper that dispatches the `ade:work:select-session` window event (behind a try/catch, no-op on a falsy id). Every spawn surface routes through it: the inline `SubagentSpawnCard` (when it carries a `chat:` taskId), the `spawn_wake_divider` and `spawn_completed` completion rows, the subagent roster row click in `ChatSubagentsPanel`, and the header lineage breadcrumb in `AgentChatPane`, so the navigation contract stays in one place. | | `apps/desktop/src/renderer/components/chat/ChatActionsDrawerPanel.tsx`, `ChatSourcesPanel.tsx`, `chatSources.ts` | Codex Chat Actions source inventory. Sources is the first available tab and derives a deduplicated list of attachments/files, web searches/results, MCP apps/tools, and external resource URLs from the current transcript. HTTP(S) rows open in ADE's built-in browser; internal `node_repl` plumbing and unsafe protocols are excluded. | | `apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx` | Git / PR quick-action toolbar above the composer. If the lane already has a linked PR, the PR button opens or toggles that PR; otherwise it routes to the PR workspace with a create-PR handoff (`create=1&sourceLaneId=&target=primary`). When the chat PR pane or compact PR menu opens, it asks `prReadCache.refreshLinkedPrCoalesced` for a targeted `prs.refresh({ prIds })` so the badge picks up merged/closed/check transitions without broad GitHub polling. | | `apps/desktop/src/renderer/components/chat/ChatPrPane.tsx` | Left floating PR pane for Work chat. Renders cached lane PR details immediately, then performs the same cooldown-bound targeted PR refresh as the toolbar before settling the state. Terminal PRs hide stale running-check labels so merged/closed PRs do not keep showing in-progress CI from an old cache row. | @@ -770,6 +771,92 @@ cold-start. Project/window close probes still fail closed: if the chat workload probe throws, ADE keeps the project alive instead of closing over a possibly running agent. +## Spawn types and completion reporting + +A chat can spawn another chat. The relationship is captured by +`AgentChatSpawnKind = "subagent" | "peer" | "none"` on the session +(`apps/desktop/src/shared/types/chat.ts`), which rides the same session +lineage field bag as `orchestrationParentSessionId` (persisted, hydrated, +and projected onto summaries through `ORCHESTRATION_SESSION_FIELD_NAMES` in +`agentChatService.ts`, validated against a small allowlist on hydration). + +`spawnKind` is **cosmetic to capabilities** — a typed agent is a full ADE +agent with the same runtime, permissions, and tools as any other chat. The +only thing the type decides is the completion-report policy when the child +finishes. + +Where it is set: + +- `ade new chat --mode chat --type subagent|peer|none` (alias + `--spawn-type`). CLI-mode launches ignore the flag. Combined with the + existing `ADE_CHAT_SESSION_ID` → `orchestrationParentSessionId` default, + an agent that runs `ade new chat` links the child back to itself and can + choose how it wants to be told the child finished. +- The orchestrator `spawnAgent` tool + (`apps/desktop/src/main/services/ai/tools/orchestrationTools.ts`) and the + orchestration domain's `spawnAgent` + (`orchestration/orchestrationDomain.ts`) set it, defaulting to + `"subagent"`. + +### Type-decided completion reporting + +When any spawned child reaches a terminal turn, `reportChildSpawnEnded` in +`agentChatService.ts` reports to the spawner based on the child's +`spawnKind`: + +- **`subagent`** — ADE wakes the spawner with + `messageSession({ kind: "wake", metadata: { spawnCompletion } })`. The + wake starts a normal turn on the parent whose synthetic `user_message` + carries a typed `AgentChatSpawnCompletion` (`childSessionId`, + `childTitle`, `spawnKind`, `status`, `summary`). The renderer derives a + `spawn_wake_divider` ("ADE woke this chat") header above that turn with an + affordance to open the finished child (mirrors the scheduled-wake + divider). +- **`peer`** — a quiet `system_notice` with `status: "spawn_completed"` + carrying the same `spawnCompletion` in its detail. Rendered as a compact + navigable chip; the parent is not woken. +- **`none` / undefined (the default for an untyped spawn)** — silent. No + report is delivered. Completion reporting is opt-in. + +Delivery is best-effort: a failed wake or notice is logged +(`agent_chat.spawn_completion_delivery_failed`) and never breaks child +cleanup. + +Two enrichments accompany a `subagent` child: the agent runtime env gets +`ADE_PARENT_CHAT_SESSION_ID` (the parent session id) plus `ADE_SPAWN_KIND`, +and the injected ADE guidance appends a self-report line telling the +subagent it can optionally post a one-paragraph summary to its spawner via +`chat.messageSession` — enrichment, since ADE already reports automatically. + +### Inline card vs. quiet pill + +A plain (non-orchestration) spawn also emits an inline `subagent_started` / +`subagent_result` card pair anchored in the transcript, so the +`subagent_spawned` notice sets `hasInlineCard: true` and the renderer +suppresses the redundant deep-link pill (the card is the surface). An +orchestration-run child emits the notice but no inline card +(`hasInlineCard` absent/false), so the quiet navigable pill is kept. Either +way the completion report is chosen by `spawnKind`, independent of whether +an inline card was emitted. + +### Navigation and lineage surfacing + +Every spawn surface routes through `navigateToSpawnedChat` in +`spawnNavigation.ts` (see the source file map). `spawnKind` and +`orchestrationParentSessionId` are projected from the chat session onto +`TerminalSessionSummary` (`apps/desktop/src/shared/types/sessions.ts`) so +the Work sidebar can render, without any extra fetch: + +- a type pill on the spawner-declared child (`SessionCard` — violet for + `subagent`, slate for `peer`, hidden for `none`), and +- a live-children badge (`▸N`) counting a spawner's still-`running` + children, derived in `SessionListPane` from the already-loaded session + list. + +`AgentChatPane` renders a two-way lineage breadcrumb: a spawned child shows +a `↳ from ""` chip in its header that navigates back to the parent +(type-tinted, parent title resolved from the loaded session list). + ## IPC surface All channel constants live in `apps/desktop/src/shared/ipc.ts`; service diff --git a/docs/features/lanes/stacking.md b/docs/features/lanes/stacking.md index 7f1882b5a..f66bfa1d9 100644 --- a/docs/features/lanes/stacking.md +++ b/docs/features/lanes/stacking.md @@ -49,6 +49,47 @@ If a new consumer needs a lane's "upstream reference," it must use these helpers rather than reading `parent_lane_id` or `base_ref` directly. +## Child-lane guidance for spawned work + +The base-ref mechanics above decide what a new lane branches from, but a +caller still has to pick the right creation verb. A fresh lane +(`ade lanes create`) branches off the remote default branch by default +(`git.newLaneBaseSource: "remote"`, resolved host-side by +`resolveDefaultRemoteLaneBase` / `resolveLaneCreateRemoteBase` — see the +Lanes README lifecycle notes), so it does **not** carry the current lane's +uncommitted-to-main commits. A child lane (`ade lanes child`) branches off +the parent's HEAD, so it does. When an agent spins up new work that should +build on the commits already sitting in its lane, it wants a child, not a +fresh lane. + +Two CLI affordances make this reachable without the desktop UI: + +- **Unmerged-work nudge.** `ade lanes create` and + `ade new chat --auto-create-lane` (both go through + `detectUnmergedLaneCreateNudge` in `apps/ade-cli/src/cli.ts`) check + whether the current worktree has commits ahead of `origin/`. If + it does, the command prints a stderr nudge before continuing — + `⚠ Lane "" has N commit(s) not on ` — suggesting + `ade lanes child --lane --name ` to carry them, and noting + that it is otherwise continuing off remote main. The nudge is advisory + only; the requested create still runs. +- **Stale-base warning.** When the remote-first default base is resolved + (`resolveLaneCreateRemoteBase`, wired with an `onWarning` sink in + `adeRpcServer.ts`), a failed fetch surfaces + `⚠ Base origin/ may be stale — fetch failed; using last-known + ref.`, and a local default branch that is behind its upstream surfaces + `⚠ local is N behind origin — creating off possibly-stale + base.` Both are best-effort enrichment; lane creation still falls back to + the local base on any failure. + +The agent-facing decision table lives in the `ade-lanes-git` Agent Skill +(`apps/desktop/resources/agent-skills/ade-lanes-git/SKILL.md`): continue my +unmerged work → `ade lanes child --lane --name `; fresh +unrelated feature → `ade lanes create --name ` (off remote main); +build on another lane's unlanded work → `ade lanes child --lane --name `. +The `ade-cli-control-plane` skill cross-references the same rule from its +spawning-agents guidance. + ## Stack chain retrieval `laneService.getStackChain(laneId)`: From 50b8993c5770202b6bb4a758cdf57dddcf1bea25 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:04:41 -0400 Subject: [PATCH 2/3] fix(work): count live-children badge from unfiltered sessions Address CodeRabbit review: the sidebar live-children badge derived its count from the already search/lane-filtered session list, so a filter/search could hide a running child and undercount its visible parent's badge. Thread the unfiltered session list into SessionListPane and count from it. Co-Authored-By: Claude Opus 4.8 --- .../components/terminals/SessionListPane.test.tsx | 2 ++ .../components/terminals/SessionListPane.tsx | 13 +++++++++---- .../renderer/components/terminals/TerminalsPage.tsx | 1 + 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx b/apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx index cb79eb3f3..29350044d 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx @@ -74,6 +74,7 @@ function renderPane(props: Partial> = {}) runningFiltered={[session]} awaitingInputFiltered={[]} endedFiltered={[]} + allSessionsUnfiltered={[session]} loading={false} filterLaneId="all" setFilterLaneId={vi.fn()} @@ -160,6 +161,7 @@ describe("SessionListPane", () => { runningFiltered={[session]} awaitingInputFiltered={[]} endedFiltered={[]} + allSessionsUnfiltered={[session]} loading={false} filterLaneId="lane-known" setFilterLaneId={vi.fn()} diff --git a/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx b/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx index d5d6a6df0..a14f73d3f 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx @@ -361,6 +361,7 @@ export const SessionListPane = React.memo(function SessionListPane({ runningFiltered, awaitingInputFiltered, endedFiltered, + allSessionsUnfiltered, loading: _loading, filterLaneId, setFilterLaneId, @@ -392,6 +393,9 @@ export const SessionListPane = React.memo(function SessionListPane({ runningFiltered: TerminalSessionSummary[]; awaitingInputFiltered: TerminalSessionSummary[]; endedFiltered: TerminalSessionSummary[]; + /** All sessions before the search/lane filter — the live-children badge counts + * from this so a filtered-out running child doesn't undercount its parent. */ + allSessionsUnfiltered: TerminalSessionSummary[]; loading: boolean; filterLaneId: string; setFilterLaneId: (v: string) => void; @@ -477,18 +481,19 @@ export const SessionListPane = React.memo(function SessionListPane({ return set; }, [childrenByParentId]); // Live-children badge: count, per spawner id, its still-running spawned chats. - // Derived once from the already-loaded session list (no extra fetch); clears - // as children reach a terminal status. + // Counts from the UNFILTERED session list (not `allSessions`, which is already + // search/lane-filtered) so hiding a running child by filter does not undercount + // its visible parent's badge. No extra fetch; clears as children go terminal. const liveChildrenByParentId = useMemo(() => { const map = new Map(); - for (const session of allSessions) { + for (const session of allSessionsUnfiltered) { const parentId = session.orchestrationParentSessionId; if (!parentId || parentId === session.id) continue; if (session.status !== "running") continue; map.set(parentId, (map.get(parentId) ?? 0) + 1); } return map; - }, [allSessions]); + }, [allSessionsUnfiltered]); const isChildSectionCollapsed = useCallback( (parentId: string) => workCollapsedSectionIds.includes(`chat:${parentId}`), [workCollapsedSectionIds], diff --git a/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx b/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx index d35e2ca0a..2421ade73 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx @@ -1035,6 +1035,7 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { runningFiltered={work.runningFiltered} awaitingInputFiltered={work.awaitingInputFiltered} endedFiltered={work.endedFiltered} + allSessionsUnfiltered={work.sessions} loading={work.loading} filterLaneId={work.filterLaneId} setFilterLaneId={work.setFilterLaneId} From f7f21c54bfe8c46d930d80272bea6b3c24f081ff Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:19:14 -0400 Subject: [PATCH 3/3] fix(cli): suppress lane-creation nudge when an explicit --base is given Address Codex review (P3): the unmerged-work child-lane nudge fired even when `ade lanes create --base ` / `ade new chat --auto-create-lane --base ` passed an explicit base, wrongly claiming the new lane continues off remote main. Gate the nudge on the absence of an explicit baseBranch at both sites. Co-Authored-By: Claude Opus 4.8 --- apps/ade-cli/src/cli.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 23b4656bf..94874011e 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -3632,7 +3632,7 @@ function buildLanePlan(args: string[]): CliPlan { return { kind: "execute", label: "lane create", - ...(!createArgs.parentLaneId && typeof createArgs.name === "string" && createArgs.name.trim() + ...(!createArgs.parentLaneId && !createArgs.baseBranch && typeof createArgs.name === "string" && createArgs.name.trim() ? { laneCreationNudge: { newLaneName: createArgs.name.trim() } } : {}), steps: [ @@ -4221,6 +4221,7 @@ function buildNewChatPlan(args: string[], defaultMode: "chat" | "cli"): CliPlan const laneCreationNudge = lane.autoCreateLane && lane.createLaneArgs && !lane.createLaneArgs.parentLaneId + && !lane.createLaneArgs.baseBranch && typeof lane.createLaneArgs.name === "string" && lane.createLaneArgs.name.trim() ? { newLaneName: lane.createLaneArgs.name.trim() }