diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index a659af4b0..2a3f6a761 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -557,6 +557,9 @@ ade chat scheduled-work cancel session-id job-id # cancel one job; Claude-nat ade chat wait session-id --for idle --timeout-ms 600000 ade chat recover session-id --turn turn-id --action nudge # provider-neutral wait | nudge | retry | resume; falls back for older Codex brains ade chat resolve-unprocessed session-id --steer steer-id --action run-next # durable/idempotent; action is run-next | dismiss +ade chat demote [session-id] # take over a subagent: it becomes a peer and reports stop; defaults to $ADE_CHAT_SESSION_ID +ade chat promote [session-id] # restore a peer as a subagent so it reports to its parent again +ade chat keep-reporting [session-id] # dismiss the takeover prompt without changing the report channel ade chat handoff session-id --model openai/gpt-5.6-sol --note "focus on tests" # brief handoff; add --target-lane to hand off into another lane ade chat fork session-id --model openai/gpt-5.6-sol # fork provider history (claude/codex/opencode/droid); stays in source lane ade chat models --provider codex --json # model order + supported reasoning tiers diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index 77b032c94..f25054e09 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -2668,8 +2668,16 @@ const SCOPED_CHAT_ACTIONS = new Set([ "interrupt", "interruptWithQueueMode", "restoreCancelledQueue", + "setSpawnKind", + "dismissSubagentTakeoverPrompt", ]); +function chatUpdateSessionMutatesSpawnKind(chatArgs: Record): boolean { + return chatArgs.spawnKind === "subagent" + || chatArgs.spawnKind === "peer" + || chatArgs.subagentTakeoverPromptShown === true; +} + function scopeChatAdeActionArgs( session: SessionState, action: string, @@ -2677,7 +2685,8 @@ function scopeChatAdeActionArgs( domain: "chat" | "session" = "chat", ): Record { const method = `run_ade_action:${domain}.${action}`; - if (!SCOPED_CHAT_ACTIONS.has(action)) return chatArgs; + const spawnKindUpdate = action === "updateSession" && chatUpdateSessionMutatesSpawnKind(chatArgs); + if (!SCOPED_CHAT_ACTIONS.has(action) && !spawnKindUpdate) return chatArgs; if (isUnboundAdeCliCaller(session)) return chatArgs; const scopedArgs = { ...chatArgs }; @@ -3833,7 +3842,10 @@ async function runTool(args: { } else if ( !callerIsCto && domain === "chat" - && SCOPED_CHAT_ACTIONS.has(action) + && ( + SCOPED_CHAT_ACTIONS.has(action) + || (action === "updateSession" && chatUpdateSessionMutatesSpawnKind(rawObjectArgs)) + ) ) { const chatArgs = requireObjectArgsForScopedAdeAction( domain, diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index cbb7421cb..d5f1c860d 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -3496,6 +3496,8 @@ describe("ADE CLI", () => { if (help.kind === "help") { expect(help.text).toContain("ade chat note"); expect(help.text).toContain("ade chat ask"); + expect(help.text).toContain("ade chat demote"); + expect(help.text).toContain("ade chat promote"); // Settling is user-/PR-merge-driven only; the help must say so rather // than advertise a command that no longer exists. expect(help.text).toContain("'chat settle' / 'chat unsettle' were removed"); @@ -3912,6 +3914,48 @@ describe("ADE CLI", () => { ])).toThrow(/recoveryId/); }); + it("routes chat demote, promote, and keep-reporting to spawn-kind actions", () => { + const demote = expectExecutePlan(buildCliPlan(["chat", "demote", "chat-1"])); + expect(demote.label).toBe("chat demote"); + expect(demote.steps[0]?.params).toMatchObject({ + arguments: { + domain: "chat", + action: "setSpawnKind", + args: { sessionId: "chat-1", spawnKind: "peer" }, + }, + }); + + const promote = expectExecutePlan(buildCliPlan(["chat", "promote", "chat-1"])); + expect(promote.label).toBe("chat promote"); + expect(promote.steps[0]?.params).toMatchObject({ + arguments: { + domain: "chat", + action: "setSpawnKind", + args: { sessionId: "chat-1", spawnKind: "subagent" }, + }, + }); + + const keep = expectExecutePlan(buildCliPlan(["chat", "keep-reporting", "chat-1"])); + expect(keep.label).toBe("chat keep-reporting"); + expect(keep.steps[0]?.params).toMatchObject({ + arguments: { + domain: "chat", + action: "dismissSubagentTakeoverPrompt", + args: { sessionId: "chat-1" }, + }, + }); + + const envDemote = withEnv({ ADE_CHAT_SESSION_ID: "env-chat" }, () => + expectExecutePlan(buildCliPlan(["chat", "demote"]))); + expect(envDemote.steps[0]?.params).toMatchObject({ + arguments: { + domain: "chat", + action: "setSpawnKind", + args: { sessionId: "env-chat", spawnKind: "peer" }, + }, + }); + }); + it.each([ ["wait", "wait"], ["nudge", "nudge"], diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 6fda93886..e19984a64 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -1940,6 +1940,9 @@ const HELP_BY_COMMAND: Record = { Detach one issue (or all) from a session $ ade chat linear-issues --text List issues attached to a session $ ade chat interrupt Stop an active turn and clear its queued messages + $ ade chat demote Take over a subagent: it becomes a peer and reports stop + $ ade chat promote Restore a peer as a subagent so it reports to its parent again + $ ade chat keep-reporting Dismiss the takeover prompt without changing the report channel $ ade chat interrupt --keep-queue Stop the turn but preserve queued messages $ ade chat restore-queue Restore a recently cleared queue during its undo window $ ade chat slash --text List slash commands for a session @@ -1961,9 +1964,8 @@ const HELP_BY_COMMAND: Record = { --parent Link the new chat as a child of that session. Defaults to $ADE_CHAT_SESSION_ID in tracked agent shells. --no-parent Create the chat without a parent link. - --type Required with a parent. subagent wakes the parent - after every turn while the parent owns the mission; - peer leaves quiet notes. + --type Required with a parent. subagent always wakes the + parent after every turn; peer leaves quiet notes. Transcript read flags: --limit Messages per bounded window (default 50, max 100). @@ -2017,9 +2019,8 @@ const HELP_BY_COMMAND: Record = { Defaults to $ADE_CHAT_SESSION_ID when run from a tracked agent shell (the spawning chat). --no-parent Create the chat without a parent link. - --type Required with a parent. subagent wakes the parent - after every turn while the parent owns the mission; - peer leaves quiet notes. + --type Required with a parent. subagent always wakes the + parent after every turn; peer leaves quiet notes. Permission mapping highlights: codex full-auto -> codexSandbox=danger-full-access, codexApprovalPolicy=never. @@ -8065,6 +8066,65 @@ function buildChatPlan(args: string[]): CliPlan { ), ], }; + if (sub === "demote") { + return { + kind: "execute", + label: "chat demote", + steps: [ + actionStep( + "result", + "chat", + "setSpawnKind", + withSession({ + sessionId: requireValue( + sessionId ?? asString(process.env.ADE_CHAT_SESSION_ID), + "sessionId", + ), + spawnKind: "peer", + }), + ), + ], + }; + } + if (sub === "promote") { + return { + kind: "execute", + label: "chat promote", + steps: [ + actionStep( + "result", + "chat", + "setSpawnKind", + withSession({ + sessionId: requireValue( + sessionId ?? asString(process.env.ADE_CHAT_SESSION_ID), + "sessionId", + ), + spawnKind: "subagent", + }), + ), + ], + }; + } + if (sub === "keep-reporting" || sub === "dismiss-takeover") { + return { + kind: "execute", + label: "chat keep-reporting", + steps: [ + actionStep( + "result", + "chat", + "dismissSubagentTakeoverPrompt", + withSession({ + sessionId: requireValue( + sessionId ?? asString(process.env.ADE_CHAT_SESSION_ID), + "sessionId", + ), + }), + ), + ], + }; + } return { kind: "execute", label: `chat ${sub}`, diff --git a/apps/ade-cli/src/services/sync/syncHostService.test.ts b/apps/ade-cli/src/services/sync/syncHostService.test.ts index 89e6f6166..dc692a7d7 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.test.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.test.ts @@ -5945,6 +5945,8 @@ describe("CTO-gated Linear sync commands", () => { "session.snoozeSession", "session.wakeSession", "session.clearWokeMarker", + "chat.setSpawnKind", + "chat.dismissSubagentTakeoverPrompt", "prs.listGithubStacks", "prs.syncGithubStacks", "prs.createGithubStack", diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index b6777d47b..e336299f2 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -62,6 +62,8 @@ import type { AgentChatRecoverTurnArgs, AgentChatResolveUnprocessedMessageArgs, AgentChatUpdateSessionArgs, + AgentChatSetSpawnKindArgs, + AgentChatDismissSubagentTakeoverPromptArgs, AddGitHubPrStackPullRequestsArgs, AddPrCommentArgs, AiReviewSummaryArgs, @@ -2587,9 +2589,34 @@ function parseAgentChatUpdateSessionArgs(value: Record): AgentC parsed.cursorConfigValues = parseCursorConfigValues(value.cursorConfigValues); } if ("manuallyNamed" in value) parsed.manuallyNamed = value.manuallyNamed === true; + if (value.spawnKind === "subagent" || value.spawnKind === "peer") { + parsed.spawnKind = value.spawnKind; + } + if (value.subagentTakeoverPromptShown === true) { + parsed.subagentTakeoverPromptShown = true; + } return parsed; } +function parseAgentChatSetSpawnKindArgs(value: Record): AgentChatSetSpawnKindArgs { + const spawnKind = requireString(value.spawnKind, "chat.setSpawnKind requires spawnKind."); + if (spawnKind !== "subagent" && spawnKind !== "peer") { + throw new Error("chat.setSpawnKind requires spawnKind to be subagent or peer."); + } + return { + sessionId: requireString(value.sessionId, "chat.setSpawnKind requires sessionId."), + spawnKind, + }; +} + +function parseAgentChatDismissSubagentTakeoverPromptArgs( + value: Record, +): AgentChatDismissSubagentTakeoverPromptArgs { + return { + sessionId: requireString(value.sessionId, "chat.dismissSubagentTakeoverPrompt requires sessionId."), + }; +} + function parseAgentChatCodexGetGoalArgs(value: Record): AgentChatCodexGetGoalArgs { return { sessionId: requireString(value.sessionId, "chat.getCodexGoal requires sessionId."), @@ -4659,6 +4686,12 @@ function registerChatRemoteCommands({ args, register }: RemoteCommandRegistratio })); register("chat.updateSession", { viewerAllowed: true, queueable: true }, async (payload) => requireService(args.agentChatService, "Agent chat service not available.").updateSession(parseAgentChatUpdateSessionArgs(payload))); + register("chat.setSpawnKind", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.agentChatService, "Agent chat service not available.").setSpawnKind(parseAgentChatSetSpawnKindArgs(payload))); + register("chat.dismissSubagentTakeoverPrompt", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.agentChatService, "Agent chat service not available.").dismissSubagentTakeoverPrompt( + parseAgentChatDismissSubagentTakeoverPromptArgs(payload), + )); register("chat.getCodexGoal", { viewerAllowed: true, queueable: false }, async (payload) => requireService(args.agentChatService, "Agent chat service not available.").getCodexGoal(parseAgentChatCodexGetGoalArgs(payload))); register("chat.setCodexGoal", { viewerAllowed: true, queueable: false }, async (payload) => diff --git a/apps/ade-cli/src/tuiClient/__tests__/sessionLifecycle.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/sessionLifecycle.test.tsx index 8a25e3a88..5f0417596 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/sessionLifecycle.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/sessionLifecycle.test.tsx @@ -86,6 +86,8 @@ describe("/session slash commands", () => { "/session settle", "/session unsettle", "/session keep-active", + "/session demote", + "/session promote", ]) { const spec = BUILTIN_COMMANDS.find((command) => command.name === name); expect(spec, name).toBeDefined(); @@ -101,6 +103,9 @@ describe("/session slash commands", () => { expect(parseCommand("/session snooze abc 1h")?.args).toBe("abc 1h"); expect(parseCommand("/session keep-active")?.name).toBe("/session keep-active"); expect(parseCommand("/session unsettle sess-9")?.args).toBe("sess-9"); + expect(parseCommand("/session demote")?.name).toBe("/session demote"); + expect(parseCommand("/session promote chat-9")?.name).toBe("/session promote"); + expect(parseCommand("/session promote chat-9")?.args).toBe("chat-9"); expect(paletteCommands("/session sn")).toContainEqual(expect.objectContaining({ name: "/session snooze", diff --git a/apps/ade-cli/src/tuiClient/adeApi.ts b/apps/ade-cli/src/tuiClient/adeApi.ts index 4941335cc..b2d3378bd 100644 --- a/apps/ade-cli/src/tuiClient/adeApi.ts +++ b/apps/ade-cli/src/tuiClient/adeApi.ts @@ -1049,6 +1049,17 @@ export async function tagChat(connection: AdeCodeConnection, sessionId: string, }); } +export async function setChatSpawnKind( + connection: AdeCodeConnection, + sessionId: string, + spawnKind: "subagent" | "peer", +): Promise { + return await connection.action("chat", "setSpawnKind", { + sessionId, + spawnKind, + }); +} + export async function updateChatModel(args: { connection: AdeCodeConnection; sessionId: string; diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index f5a662de0..96e2f3c76 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -128,6 +128,7 @@ import { saveRuntimeTempAttachment, sendChatMessage, sendToTerminalSession, + setChatSpawnKind, signalTerminal, setClaudeOutputStyle, setSessionStatusNote, @@ -11071,6 +11072,8 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, " /session settle [id] [outcome] file the row as done", " /session unsettle [id] undo a settle", " /session keep-active [id] pin the row active against a later settle", + " /session demote [id] take over a subagent so reports stop", + " /session promote [id] restore a peer as a subagent", "", "Run /session snooze with no duration to pick one from the list.", ].join("\n"), @@ -11129,7 +11132,13 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, } else if (lifecycleVerb === "unsettle") { await unsettleSession(conn, target.sessionId); addNotice(`Removed the session's settled state.${scope}`, "success"); - } else { + } else if (lifecycleVerb === "demote") { + await setChatSpawnKind(conn, target.sessionId, "peer"); + addNotice(`Took over the chat. Reports to the parent stop.${scope}`, "success"); + } else if (lifecycleVerb === "promote") { + await setChatSpawnKind(conn, target.sessionId, "subagent"); + addNotice(`Restored the chat as a subagent. Reports resume.${scope}`, "success"); + } else if (lifecycleVerb === "keep-active") { // keep-active: the tri-state override's "active" pin. It suppresses // the settled tier for a row even if something later writes // settled_at (e.g. the PR-merge policy), so the user can hold a row @@ -11137,6 +11146,9 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, // exit is "ended", never "settled" (see sessionCanonicalState.ts). await setSessionSettleOverride(conn, target.sessionId, "active"); addNotice(`Pinned the session active.${scope}`, "success"); + } else { + const _exhaustive: never = lifecycleVerb; + return _exhaustive; } await refreshState(); } catch (err) { diff --git a/apps/ade-cli/src/tuiClient/commands.ts b/apps/ade-cli/src/tuiClient/commands.ts index 360d61aaf..4fb413551 100644 --- a/apps/ade-cli/src/tuiClient/commands.ts +++ b/apps/ade-cli/src/tuiClient/commands.ts @@ -72,12 +72,14 @@ export const BUILTIN_COMMANDS: BuiltinCommand[] = [ // visibility overlay, not a phase — see tuiClient/sessionLifecycle.ts. // The bare group name is registered so submitting it prints usage instead of // leaking "/session" into the chat as a message. - { name: "/session", description: "Run a session lifecycle command", placement: "right", argumentHint: "", category: "Chats" }, + { name: "/session", description: "Run a session lifecycle command", placement: "right", argumentHint: "", category: "Chats" }, { name: "/session snooze", description: "Snooze a session out of the Activity list until a deadline", placement: "right", argumentHint: "[session-id] [30m|1h|4h|1d]", category: "Chats" }, { name: "/session wake", description: "Wake a snoozed session back into the Activity list", placement: "right", argumentHint: "[session-id]", category: "Chats" }, { name: "/session settle", description: "Mark a session settled", placement: "right", argumentHint: "[session-id] [outcome]", category: "Chats" }, { name: "/session unsettle", description: "Remove a session's settled state", placement: "right", argumentHint: "[session-id]", category: "Chats" }, { name: "/session keep-active", description: "Pin a session active against a later settle", placement: "right", argumentHint: "[session-id]", category: "Chats" }, + { name: "/session demote", description: "Take over a subagent so it stops reporting to its parent", placement: "right", argumentHint: "[session-id]", category: "Chats" }, + { name: "/session promote", description: "Restore a peer as a subagent so it reports to its parent again", placement: "right", argumentHint: "[session-id]", category: "Chats" }, { name: "/tag", description: "Tag the active Claude chat", placement: "right", argumentHint: "", providers: ["claude"], category: "Model" }, { name: "/output-style", description: "List or select the active Claude output style", placement: "right", argumentHint: "[style]", providers: ["claude"], category: "Model" }, { name: "/plugin", description: "List, reload, or manage Claude plugins", placement: "right", argumentHint: "[reload|native args]", providers: ["claude"], category: "Model" }, diff --git a/apps/ade-cli/src/tuiClient/sessionLifecycle.ts b/apps/ade-cli/src/tuiClient/sessionLifecycle.ts index 1f4c59171..f25dd9317 100644 --- a/apps/ade-cli/src/tuiClient/sessionLifecycle.ts +++ b/apps/ade-cli/src/tuiClient/sessionLifecycle.ts @@ -36,7 +36,9 @@ export type SessionLifecycleCommand = | "wake" | "settle" | "unsettle" - | "keep-active"; + | "keep-active" + | "demote" + | "promote"; /** Slash names this module owns, mapped to their verb. `/chat settle` and * `/chat unsettle` keep their own (active-only) dispatch in app.tsx. */ @@ -46,6 +48,8 @@ export const SESSION_LIFECYCLE_COMMAND_BY_NAME: Readonly { }); }); - it("leaves a quiet completion note when a human dispatches the child's first turn", async () => { + it("wakes the parent when a human messages a subagent, and names that human message in the report", async () => { const events: AgentChatEventEnvelope[] = []; const stream = vi.fn(() => (async function* () { yield { type: "system", subtype: "init", session_id: "sdk-spawn-human", slash_commands: [] }; + yield { + type: "assistant", + message: { + id: "msg-human-summary", + content: [{ type: "text", text: "Adjusted the retry." }], + usage: { input_tokens: 1, output_tokens: 4 }, + }, + }; yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } }; })()); vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ @@ -10519,15 +10527,24 @@ describe("createAgentChatService", () => { await vi.waitFor(() => { expect(events.some((event) => event.sessionId === parent.id - && event.event.type === "system_notice" - && event.event.status === "spawn_completed" - && (event.event.detail as any)?.spawnCompletion?.childSessionId === child.id + && event.event.type === "user_message" + && event.event.metadata?.spawnCompletion?.childSessionId === child.id )).toBe(true); }); - expect(events.some((event) => + const wake = events.find((event) => event.sessionId === parent.id && event.event.type === "user_message" && event.event.metadata?.spawnCompletion?.childSessionId === child.id + ); + expect(wake?.event.type === "user_message" && wake.event.metadata?.spawnCompletion?.humanMessageCount).toBe(1); + expect(wake?.event.type === "user_message" && wake.event.metadata?.spawnCompletion?.summary).toContain( + "The user also sent 1 message to this chat.", + ); + expect(events.some((event) => + event.sessionId === parent.id + && event.event.type === "system_notice" + && event.event.status === "spawn_completed" + && (event.event.detail as any)?.spawnCompletion?.childSessionId === child.id )).toBe(false); }); @@ -10610,7 +10627,7 @@ describe("createAgentChatService", () => { .not.toBe((dispatchedWake!.event as any).metadata.spawnCompletion.childTurnId); }); - it("leaves a quiet note for a scheduled turn after a human takes over the mission", async () => { + it("keeps waking the parent after a human messages a subagent, including later scheduled turns", async () => { const events: AgentChatEventEnvelope[] = []; const stream = vi.fn(() => (async function* () { yield { type: "system", subtype: "init", session_id: "sdk-spawn-handover", slash_commands: [] }; @@ -10656,7 +10673,10 @@ describe("createAgentChatService", () => { await vi.waitFor(() => expect(parentWakes()).toHaveLength(1)); await service.sendMessage({ sessionId: child.id, text: "Actually, hold on — do this instead." }); - await vi.waitFor(() => expect(quietNotices()).toHaveLength(1)); + await vi.waitFor(() => expect(parentWakes()).toHaveLength(2)); + const secondWake = parentWakes()[1]; + expect(secondWake?.event.type === "user_message" + && secondWake.event.metadata?.spawnCompletion?.humanMessageCount).toBe(1); await service.messageSession({ sessionId: child.id, @@ -10672,8 +10692,8 @@ describe("createAgentChatService", () => { }, }); - await vi.waitFor(() => expect(quietNotices()).toHaveLength(2)); - expect(parentWakes()).toHaveLength(1); + await vi.waitFor(() => expect(parentWakes()).toHaveLength(3)); + expect(quietNotices()).toHaveLength(0); }); it("emits a quiet completion notice without a wake when a peer finishes", async () => { @@ -10724,6 +10744,185 @@ describe("createAgentChatService", () => { )).toBe(false); }); + it("demotes a subagent to a peer, notes the parent, and keeps later turns quiet", async () => { + const events: AgentChatEventEnvelope[] = []; + const stream = vi.fn(() => (async function* () { + yield { type: "system", subtype: "init", session_id: "sdk-spawn-demote", 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-demote", + 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: "Review child", + orchestrationParentSessionId: parent.id, + spawnKind: "subagent", + }); + + const demoted = service.setSpawnKind({ sessionId: child.id, spawnKind: "peer" }); + expect(demoted.spawnKind).toBe("peer"); + expect(demoted.subagentTakeoverPromptShownAt).toBeTruthy(); + await vi.waitFor(() => { + expect(events.some((event) => + event.sessionId === parent.id + && event.event.type === "system_notice" + && event.event.status === "spawn_takeover" + && (event.event.detail as any)?.spawnTakeover?.childSessionId === child.id + )).toBe(true); + }); + const takeover = events.find((event) => + event.sessionId === parent.id + && event.event.type === "system_notice" + && event.event.status === "spawn_takeover" + ); + expect(takeover?.event.type === "system_notice" && takeover.event.message).toBe( + 'The user took over "Review child" — reports stop here.', + ); + + await service.sendMessage({ sessionId: child.id, text: "Keep going without the parent." }); + await vi.waitFor(() => { + expect(events.some((event) => + event.sessionId === parent.id + && event.event.type === "system_notice" + && event.event.status === "spawn_completed" + && (event.event.detail as any)?.spawnCompletion?.childSessionId === child.id + )).toBe(true); + }); + expect(events.some((event) => + event.sessionId === parent.id + && event.event.type === "user_message" + && event.event.metadata?.spawnCompletion?.childSessionId === child.id + )).toBe(false); + }); + + it("promotes a peer back to a subagent when the parent still exists", async () => { + const events: AgentChatEventEnvelope[] = []; + const stream = vi.fn(() => (async function* () { + yield { type: "system", subtype: "init", session_id: "sdk-spawn-promote", 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-promote", + 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: "Promoted child", + orchestrationParentSessionId: parent.id, + spawnKind: "peer", + }); + + expect(service.setSpawnKind({ sessionId: child.id, spawnKind: "subagent" }).spawnKind).toBe("subagent"); + await service.sendMessage({ sessionId: child.id, text: "Report back." }); + await vi.waitFor(() => { + expect(events.some((event) => + event.sessionId === parent.id + && event.event.type === "user_message" + && event.event.metadata?.spawnCompletion?.childSessionId === child.id + )).toBe(true); + }); + }); + + it("refuses to promote when the parent chat is gone", async () => { + const { service } = createService(); + 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: "Orphaned child", + orchestrationParentSessionId: parent.id, + spawnKind: "peer", + }); + await service.deleteSession({ sessionId: parent.id }); + expect(() => service.setSpawnKind({ sessionId: child.id, spawnKind: "subagent" })).toThrow( + /parent chat is gone/i, + ); + }); + + it("auto-promotes a peer back to a subagent when the parent dispatches again", async () => { + const events: AgentChatEventEnvelope[] = []; + const stream = vi.fn(() => (async function* () { + yield { type: "system", subtype: "init", session_id: "sdk-spawn-autoped", 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-autoped", + 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: "Reclaimed child", + orchestrationParentSessionId: parent.id, + spawnKind: "peer", + }); + + await service.messageSession({ + sessionId: child.id, + text: "Do this next.", + metadata: { + spawnDispatch: { parentSessionId: parent.id, dispatchedAt: "2026-08-12T00:00:00.000Z" }, + }, + }); + await vi.waitFor(() => { + expect(events.some((event) => + event.sessionId === parent.id + && event.event.type === "user_message" + && event.event.metadata?.spawnCompletion?.childSessionId === child.id + )).toBe(true); + }); + expect((await service.getSessionSummary(child.id))?.spawnKind).toBe("subagent"); + expect(events.some((event) => + event.sessionId === parent.id + && event.event.type === "system_notice" + && event.event.status === "spawn_takeover" + )).toBe(false); + }); + + it("persists the takeover prompt as shown without changing spawn kind", async () => { + const { service } = createService(); + 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: "subagent", + }); + const dismissed = service.dismissSubagentTakeoverPrompt({ sessionId: child.id }); + expect(dismissed.spawnKind).toBe("subagent"); + expect(dismissed.subagentTakeoverPromptShownAt).toBeTruthy(); + expect((await service.getSessionSummary(child.id))?.subagentTakeoverPromptShownAt).toBe( + dismissed.subagentTakeoverPromptShownAt, + ); + }); + it("reports a stopped completion before deleting an unfinished child", async () => { const events: AgentChatEventEnvelope[] = []; const { service } = createService({ onEvent: (event: AgentChatEventEnvelope) => events.push(event) }); @@ -10742,12 +10941,11 @@ describe("createAgentChatService", () => { await vi.waitFor(() => { const completion = events.find((event) => event.sessionId === parent.id - && event.event.type === "system_notice" - && event.event.status === "spawn_completed" - && (event.event.detail as any)?.spawnCompletion?.childSessionId === child.id + && event.event.type === "user_message" + && event.event.metadata?.spawnCompletion?.childSessionId === child.id ); expect(completion).toBeTruthy(); - expect((completion!.event as any).detail.spawnCompletion).toMatchObject({ + expect(completion!.event.type === "user_message" && completion!.event.metadata?.spawnCompletion).toMatchObject({ childSessionId: child.id, childTitle: "Deleted child", spawnKind: "subagent", diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 05333fe7f..e5a2ae149 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -92,7 +92,10 @@ import { } from "./claudeWorkflowProgress"; import { discoverClaudeSlashCommands } from "./claudeSlashCommandDiscovery"; import { discoverCodexSlashCommands } from "./codexSlashCommandDiscovery"; -import { parentShouldWakeForChildTurn } from "./spawnMissionOwnership"; +import { + countHumanChildMessagesForTurn, + formatHumanChildMessageAnnotation, +} from "./spawnMissionOwnership"; import { classifyCodexResumeFailure, type ResumeFailureClassification, @@ -239,6 +242,9 @@ import type { AgentChatEventEnvelope, AgentChatEventMetadata, AgentChatSpawnCompletion, + AgentChatSpawnKind, + AgentChatSetSpawnKindArgs, + AgentChatDismissSubagentTakeoverPromptArgs, AgentChatEventHistoryPage, AgentChatEventHistorySnapshot, AgentChatContextAttachment, @@ -1039,6 +1045,7 @@ type PersistedChatState = { orchestrationRole?: "lead" | "worker" | "validator"; orchestrationParentSessionId?: string; spawnKind?: AgentChatSession["spawnKind"]; + subagentTakeoverPromptShownAt?: string | null; orchestrationTag?: string; orchestrationStepId?: string; orchestrationBundlePath?: string; @@ -6214,6 +6221,7 @@ const ORCHESTRATION_SESSION_FIELD_NAMES = [ "orchestrationRole", "orchestrationParentSessionId", "spawnKind", + "subagentTakeoverPromptShownAt", "orchestrationTag", "orchestrationStepId", "orchestrationBundlePath", @@ -6245,6 +6253,10 @@ function hydrateOrchestrationFields( if (typeof spawnKind === "string" && VALID_AGENT_CHAT_SPAWN_KINDS.has(spawnKind)) { out.spawnKind = spawnKind as "subagent" | "peer"; } + const shownAt = record.subagentTakeoverPromptShownAt; + if (typeof shownAt === "string" && shownAt.trim().length) { + out.subagentTakeoverPromptShownAt = shownAt.trim(); + } const tag = record.orchestrationTag; if (typeof tag === "string" && tag.trim().length) out.orchestrationTag = tag.trim(); const stepId = record.orchestrationStepId; @@ -6311,7 +6323,7 @@ function buildSpawnSelfReportGuidance( ): string | null { if (!session.orchestrationParentSessionId?.trim()) return null; if (session.spawnKind === "subagent") { - return "You were spawned as a subagent. While your parent owns your current mission, ADE automatically wakes it after every turn you finish — including turns your own scheduled wakeups start — and includes your latest assistant summary. If a human messages you directly, completions become quiet notes until your parent dispatches again. You may send extra context or recover from a delivery failure with: `ade actions run chat.messageSession --input-json '{\"sessionId\":\"$ADE_PARENT_CHAT_SESSION_ID\",\"kind\":\"auto\",\"text\":\"\"}'`. Do not poll the parent transcript for coordination."; + return "You were spawned as a subagent. ADE automatically wakes your parent after every turn you finish — including turns your own scheduled wakeups start — and includes your latest assistant summary. A human message does not close that report channel. If a human takes this chat over, ADE converts you to a peer and completions become quiet notes. You may send extra context or recover from a delivery failure with: `ade actions run chat.messageSession --input-json '{\"sessionId\":\"$ADE_PARENT_CHAT_SESSION_ID\",\"kind\":\"auto\",\"text\":\"\"}'`. Do not poll the parent transcript for coordination."; } if (session.spawnKind === "peer") { return "You were spawned as a peer for fire-and-forget work. ADE records quiet completion notes but does not wake your parent. If the parent unexpectedly needs your result, report it directly with: `ade actions run chat.messageSession --input-json '{\"sessionId\":\"$ADE_PARENT_CHAT_SESSION_ID\",\"kind\":\"auto\",\"text\":\"\"}'`."; @@ -29623,11 +29635,9 @@ export function createAgentChatService(args: { /** * Child chat sessions spawned with a parent lineage. The relationship lives - * on the persisted child session, while parent-dispatch causality lives on - * the child's persisted user-message metadata — per turn for the turn that - * was dispatched, and across turns as mission ownership. Completion deliveries - * carry the child turn id in the parent transcript, providing durable dedupe - * without a process-local spawn tracker. + * on the persisted child session. Wake vs quiet is the child's `spawnKind`. + * Completion deliveries carry the child turn id in the parent transcript, + * providing durable dedupe without a process-local spawn tracker. */ const spawnCompletionDeliveriesInFlight = new Set(); @@ -29672,6 +29682,110 @@ export function createAgentChatService(args: { }); }; + const parentChatStillExists = (parentSessionId: string): boolean => { + const live = managedSessions.get(parentSessionId); + if (live && !live.deleted) return true; + const row = sessionService.get(parentSessionId); + return Boolean(row && isChatToolType(row.toolType)); + }; + + const emitSpawnKindMeta = (managed: ManagedChatSession): void => { + emitTransientChatEnvelope(managed.session.id, { + type: "session_meta_updated", + ...(managed.session.spawnKind ? { spawnKind: managed.session.spawnKind } : {}), + subagentTakeoverPromptShownAt: managed.session.subagentTakeoverPromptShownAt ?? null, + }); + }; + + const postSpawnTakeoverNote = (child: ManagedChatSession, parentSessionId: string): void => { + const childTitle = sessionService.get(child.session.id)?.title?.trim() + || defaultChatSessionTitle(child.session.provider); + try { + const parent = ensureManagedSession(parentSessionId); + if (parent.deleted) return; + emitChatEvent(parent, { + type: "system_notice", + noticeKind: "info", + status: "spawn_takeover", + message: `The user took over "${childTitle}" — reports stop here.`, + detail: { + spawnTakeover: { + childSessionId: child.session.id, + childTitle, + }, + }, + }); + } catch (error) { + logger.warn("agent_chat.spawn_takeover_note_failed", { + childSessionId: child.session.id, + parentSessionId, + error: error instanceof Error ? error.message : String(error), + }); + } + }; + + const applySpawnKindChange = ({ + sessionId, + spawnKind, + source, + }: { + sessionId: string; + spawnKind: AgentChatSpawnKind; + source: "takeover" | "promote" | "parent_dispatch"; + }): AgentChatSession => { + const managed = ensureManagedSession(sessionId); + const parentSessionId = managed.session.orchestrationParentSessionId?.trim() || ""; + if (!parentSessionId || parentSessionId === sessionId) { + throw new Error("This chat is not a child of another chat."); + } + if (spawnKind === "subagent" && source !== "parent_dispatch" && !parentChatStillExists(parentSessionId)) { + throw new Error("The parent chat is gone, so this chat cannot report back."); + } + const previous = managed.session.spawnKind; + if (source === "takeover") { + managed.session.subagentTakeoverPromptShownAt = nowIso(); + } + managed.session.spawnKind = spawnKind; + persistChatState(managed); + emitSpawnKindMeta(managed); + logger.info("agent_chat.spawn_kind_changed", { + sessionId, + parentSessionId, + previousSpawnKind: previous ?? null, + spawnKind, + source, + }); + if (spawnKind === "peer" && previous !== "peer" && source === "takeover") { + postSpawnTakeoverNote(managed, parentSessionId); + } + return managed.session; + }; + + const setSpawnKind = ({ sessionId, spawnKind }: AgentChatSetSpawnKindArgs): AgentChatSession => { + if (spawnKind !== "subagent" && spawnKind !== "peer") { + throw new Error("Spawn type must be subagent or peer."); + } + return applySpawnKindChange({ + sessionId, + spawnKind, + source: spawnKind === "peer" ? "takeover" : "promote", + }); + }; + + const dismissSubagentTakeoverPrompt = ( + { sessionId }: AgentChatDismissSubagentTakeoverPromptArgs, + ): AgentChatSession => { + const managed = ensureManagedSession(sessionId); + if (!managed.session.orchestrationParentSessionId?.trim()) { + return managed.session; + } + if (managed.session.subagentTakeoverPromptShownAt) return managed.session; + managed.session.subagentTakeoverPromptShownAt = nowIso(); + persistChatState(managed); + emitSpawnKindMeta(managed); + return managed.session; + }; + const reportChildSpawnEnded = ( childSessionId: string, status: "completed" | "interrupted" | "failed", @@ -29691,18 +29805,20 @@ export function createAgentChatService(args: { const deliveryKey = `${parentSessionId}:${childSessionId}:${resolvedTurnId}`; if (spawnCompletionDeliveriesInFlight.has(deliveryKey)) return; - // Wake decision lives in `spawnMissionOwnership` so the policy — which - // inputs count as a directive — is stated and tested in one place. Peers - // never wake, so they never pay for the transcript read. Read once, before - // the delivery retries: a retry must not re-decide ownership. - const parentShouldWake = spawnKind === "subagent" && parentShouldWakeForChildTurn({ - history: mergeEnvelopeStreams( - readTranscriptEnvelopes(child), - eventHistoryBySession.get(childSessionId) ?? [], - ), - parentSessionId, - turnId: resolvedTurnId, - }); + // Subagent completions always wake the parent. Human messages no longer + // steal that channel — only an explicit demote to peer does. Peers skip + // the transcript read because they never wake. + const parentShouldWake = spawnKind === "subagent"; + const humanMessageCount = parentShouldWake + ? countHumanChildMessagesForTurn( + mergeEnvelopeStreams( + readTranscriptEnvelopes(child), + eventHistoryBySession.get(childSessionId) ?? [], + ), + resolvedTurnId, + ) + : 0; + const humanAnnotation = formatHumanChildMessageAnnotation(humanMessageCount); const resultStatus = status === "interrupted" ? "stopped" : status === "failed" ? "failed" : "completed"; const assistantSummary = [...child.recentConversationEntries] .reverse() @@ -29710,15 +29826,19 @@ export function createAgentChatService(args: { ?.text .replace(/\s+/g, " ") .trim(); - const summary = resultStatus === "completed" && assistantSummary - ? assistantSummary.length > 1_200 + let baseSummary: string; + if (resultStatus === "completed" && assistantSummary) { + baseSummary = assistantSummary.length > 1_200 ? `${assistantSummary.slice(0, 1_197).trimEnd()}...` - : assistantSummary - : resultStatus === "completed" - ? spawnKind === "subagent" ? "Subagent turn finished." : "Peer turn finished." - : resultStatus === "stopped" - ? "Stopped before finishing." - : "Turn failed."; + : assistantSummary; + } else if (resultStatus === "completed") { + baseSummary = spawnKind === "subagent" ? "Subagent turn finished." : "Peer turn finished."; + } else if (resultStatus === "stopped") { + baseSummary = "Stopped before finishing."; + } else { + baseSummary = "Turn failed."; + } + const summary = humanAnnotation ? `${baseSummary}\n${humanAnnotation}` : baseSummary; const childTitle = sessionService.get(childSessionId)?.title?.trim() || defaultChatSessionTitle(child.session.provider); const spawnCompletion: AgentChatSpawnCompletion = { @@ -29728,6 +29848,7 @@ export function createAgentChatService(args: { childTurnId: resolvedTurnId, status: resultStatus, summary, + ...(humanMessageCount > 0 ? { humanMessageCount } : {}), }; const parentAlreadyHasCompletion = (parent: ManagedChatSession): boolean => @@ -29780,7 +29901,7 @@ export function createAgentChatService(args: { type: "system_notice", noticeKind: "info", status: "spawn_completed", - message: `${spawnKind === "subagent" ? "Subagent" : "Peer"} "${childTitle}" turn finished`, + message: `Peer "${childTitle}" turn finished`, detail: { spawnCompletion }, }); } @@ -32686,6 +32807,18 @@ export function createAgentChatService(args: { allowPendingInput?: boolean; }): PreparedSendMessage | null => { const managed = ensureManagedSession(sessionId); + const dispatchParentId = metadata?.spawnDispatch?.parentSessionId?.trim(); + if ( + dispatchParentId + && managed.session.spawnKind === "peer" + && managed.session.orchestrationParentSessionId?.trim() === dispatchParentId + ) { + applySpawnKindChange({ + sessionId, + spawnKind: "subagent", + source: "parent_dispatch", + }); + } const publicContextAttachments = normalizeChatContextAttachments(contextAttachments); const trimmedText = text.trim(); const trimmed = trimmedText.length @@ -41402,6 +41535,8 @@ export function createAgentChatService(args: { cursorModeId, cursorConfigValues, permissionMode, + spawnKind: requestedSpawnKind, + subagentTakeoverPromptShown, }: AgentChatUpdateSessionArgs): Promise => { const fastMode = requestedFastModeArg ?? requestedLegacyFastModeArg; const managed = ensureManagedSession(sessionId); @@ -41865,6 +42000,17 @@ export function createAgentChatService(args: { if (manuallyNamed) managed.runtimeTitleAdopted = false; } + if (requestedSpawnKind === "subagent" || requestedSpawnKind === "peer") { + applySpawnKindChange({ + sessionId, + spawnKind: requestedSpawnKind, + source: requestedSpawnKind === "peer" ? "takeover" : "promote", + }); + } + if (subagentTakeoverPromptShown === true) { + dismissSubagentTakeoverPrompt({ sessionId }); + } + persistChatState(managed); return managed.session; }; @@ -43833,6 +43979,7 @@ export function createAgentChatService(args: { orchestrationRole?: "lead" | "worker" | "validator" | null; orchestrationParentSessionId?: string | null; spawnKind?: AgentChatSession["spawnKind"] | null; + subagentTakeoverPromptShownAt?: string | null; orchestrationTag?: string | null; orchestrationStepId?: string | null; orchestrationBundlePath?: string | null; @@ -44437,6 +44584,8 @@ export function createAgentChatService(args: { disposeAll, forceDisposeAll, updateSession, + setSpawnKind, + dismissSubagentTakeoverPrompt, reconcileThreadPointerFromRedundantSources, isTranscriptPathActive, warmupModel, diff --git a/apps/desktop/src/main/services/chat/spawnMissionOwnership.test.ts b/apps/desktop/src/main/services/chat/spawnMissionOwnership.test.ts index 775a0f7e7..86987aa41 100644 --- a/apps/desktop/src/main/services/chat/spawnMissionOwnership.test.ts +++ b/apps/desktop/src/main/services/chat/spawnMissionOwnership.test.ts @@ -2,8 +2,9 @@ import { describe, expect, it } from "vitest"; import type { AgentChatEvent, AgentChatEventEnvelope } from "../../../shared/types/chat"; import { - isMissionDirective, - parentShouldWakeForChildTurn, + countHumanChildMessagesForTurn, + formatHumanChildMessageAnnotation, + isHumanChildMessage, stripHostAuthoredMessageProvenance, } from "./spawnMissionOwnership"; @@ -33,141 +34,66 @@ const scheduledWake = (turnId: string) => userMessage({ metadata: { scheduledWake: { scheduleId: "wake-1", kind: "wakeup", firedAt: "2026-08-11T00:10:00.000Z" } }, }); -describe("isMissionDirective", () => { - it("counts a plain message as a directive", () => { - expect(isMissionDirective({ type: "user_message", text: "do it" })).toBe(true); +describe("isHumanChildMessage", () => { + it("counts a plain human message", () => { + expect(isHumanChildMessage({ type: "user_message", text: "hold on" })).toBe(true); }); - it.each([ - ["a scheduler delivery", { scheduledWake: { scheduleId: "s", kind: "wakeup" as const, firedAt: "x" } }], - ["a grandchild completion", { spawnCompletion: { childSessionId: "g", childTitle: "g", spawnKind: "subagent" as const, status: "completed" as const } }], - ["another agent relaying", { agentRelay: { fromSessionId: "grandchild" } }], - ["a host continuation", { hostContinuation: { reason: "interrupted_turn_recovery" as const } }], - ["a legacy continuity recovery", { kind: "continuity_recovery" }], - ["an orchestration worker status ping", { orchestrationOrigin: { runId: "r", fromSessionId: "worker", kind: "queue", intent: "status" } }], - ["an orchestration question", { orchestrationOrigin: { runId: "r", fromSessionId: "worker", kind: "queue", intent: "question" } }], - ])("does not count %s as a directive", (_label, metadata) => { - expect(isMissionDirective({ type: "user_message", text: "…", metadata })).toBe(false); + it("counts a handoff prompt as a human continuation", () => { + expect(isHumanChildMessage({ type: "user_message", text: "…", metadata: { kind: "handoff" } })).toBe(true); }); - it.each([["handoff"], ["cross_machine_handoff"]])( - "counts a %s prompt as a directive — it carries a human's continuation intent", - (kind) => { - expect(isMissionDirective({ type: "user_message", text: "…", metadata: { kind } })).toBe(true); - }, - ); - - it("counts an explicit orchestration directive as a directive", () => { - expect(isMissionDirective({ + it("does not count a parent dispatch", () => { + expect(isHumanChildMessage({ type: "user_message", - text: "…", - metadata: { orchestrationOrigin: { runId: "r", fromSessionId: "lead", kind: "queue", intent: "directive" } }, - })).toBe(true); - }); - - it("does not count a queued message, whose delivered twin carries the real metadata", () => { - expect(isMissionDirective({ type: "user_message", text: "…", deliveryState: "queued" })).toBe(false); - }); -}); - -describe("parentShouldWakeForChildTurn", () => { - const shouldWake = (history: AgentChatEventEnvelope[], turnId: string) => - parentShouldWakeForChildTurn({ history, parentSessionId: PARENT, turnId }); - - it("wakes for the turn the parent dispatched", () => { - expect(shouldWake([parentDispatch("t1")], "t1")).toBe(true); + text: "Ship it.", + metadata: { spawnDispatch: { parentSessionId: PARENT, dispatchedAt: "x" } }, + })).toBe(false); }); - it("wakes for a scheduler-started turn while the parent owns the mission", () => { - expect(shouldWake([parentDispatch("t1"), scheduledWake("t2")], "t2")).toBe(true); - }); - - it("stays quiet once a human takes the mission over", () => { - expect(shouldWake([parentDispatch("t1"), humanMessage("t2")], "t2")).toBe(false); - expect(shouldWake([parentDispatch("t1"), humanMessage("t2"), scheduledWake("t3")], "t3")).toBe(false); - }); - - it("returns to the parent when it dispatches again", () => { - const history = [parentDispatch("t1"), humanMessage("t2"), parentDispatch("t3"), scheduledWake("t4")]; - expect(shouldWake(history, "t4")).toBe(true); + it("does not count a scheduled wake", () => { + expect(isHumanChildMessage({ + type: "user_message", + text: "…", + metadata: { scheduledWake: { scheduleId: "s", kind: "wakeup", firedAt: "x" } }, + })).toBe(false); }); - it("keeps ownership through a scheduled wake that was queued behind a busy turn", () => { - // The queue path persists the queued copy without `scheduledWake`; the - // delivered copy carries it. Counting the queued copy would read as a - // directive and silently hand the mission away from the parent. - const history = [ - parentDispatch("t1"), - userMessage({ turnId: "t1", steerId: "s1", deliveryState: "queued" }), - userMessage({ - turnId: "t2", - steerId: "s1", - deliveryState: "delivered", - metadata: { scheduledWake: { scheduleId: "wake-1", kind: "wakeup", firedAt: "2026-08-11T00:10:00.000Z" } }, - }), - ]; - expect(shouldWake(history, "t2")).toBe(true); + it("does not count a queued copy", () => { + expect(isHumanChildMessage({ type: "user_message", text: "…", deliveryState: "queued" })).toBe(false); }); - it("keeps ownership when an orchestration worker reports status to a lead that is itself a subagent", () => { - const history = [ - parentDispatch("t1"), - userMessage({ - turnId: "t2", - metadata: { orchestrationOrigin: { runId: "r", fromSessionId: "worker", kind: "queue", intent: "status" } }, - }), - ]; - expect(shouldWake(history, "t2")).toBe(true); + it("does not count an orchestration directive as a human message", () => { + expect(isHumanChildMessage({ + type: "user_message", + text: "Ship the worker task.", + metadata: { orchestrationOrigin: { runId: "r", fromSessionId: "lead", kind: "queue", intent: "directive" } }, + })).toBe(false); }); +}); - it("keeps ownership when the child's own grandchild reports in", () => { +describe("countHumanChildMessagesForTurn", () => { + it("counts human messages on the finished turn and ignores parent dispatches and other turns", () => { const history = [ parentDispatch("t1"), - userMessage({ turnId: "t2", metadata: { agentRelay: { fromSessionId: "grandchild" } } }), - ]; - expect(shouldWake(history, "t2")).toBe(true); - }); - - it("still wakes for a parent-dispatched turn a human interrupted mid-flight", () => { - // Ownership moved to the human, but the parent is still owed the result of - // the turn it started. - const history = [parentDispatch("t1"), humanMessage("t2")]; - expect(shouldWake(history, "t1")).toBe(true); - }); - - it("still wakes when a human steers inline into the parent-dispatched turn", () => { - // An inline steer joins the running turn and reuses its id, so the turn's - // last user message is the human's — the parent dispatch is still in there. - const history = [parentDispatch("t1"), humanMessage("t1")]; - expect(shouldWake(history, "t1")).toBe(true); - }); - - it("does not wake for a parent message still queued behind someone else's turn", () => { - // The queued row carries the running turn's id. Counting it would wake the - // parent for a turn a human started, before the parent's own message ran. - const history = [ humanMessage("t1"), - userMessage({ - turnId: "t1", - steerId: "s1", - deliveryState: "queued", - metadata: { spawnDispatch: { parentSessionId: PARENT, dispatchedAt: "2026-08-11T00:05:00.000Z" } }, - }), + humanMessage("t1"), + scheduledWake("t2"), + humanMessage("t2"), ]; - expect(shouldWake(history, "t1")).toBe(false); + expect(countHumanChildMessagesForTurn(history, "t1")).toBe(2); + expect(countHumanChildMessagesForTurn(history, "t2")).toBe(1); }); +}); - it("stays quiet for a child with no parent-dispatched history at all", () => { - expect(shouldWake([humanMessage("t1")], "t1")).toBe(false); - expect(shouldWake([], "t1")).toBe(false); +describe("formatHumanChildMessageAnnotation", () => { + it("returns null when the user sent nothing", () => { + expect(formatHumanChildMessageAnnotation(0)).toBeNull(); }); - it("ignores a stamp naming a different parent", () => { - const history = [userMessage({ - turnId: "t1", - metadata: { spawnDispatch: { parentSessionId: "someone-else", dispatchedAt: "x" } }, - })]; - expect(shouldWake(history, "t1")).toBe(false); + it("uses singular and plural copy", () => { + expect(formatHumanChildMessageAnnotation(1)).toBe("The user also sent 1 message to this chat."); + expect(formatHumanChildMessageAnnotation(3)).toBe("The user also sent 3 messages to this chat."); }); }); diff --git a/apps/desktop/src/main/services/chat/spawnMissionOwnership.ts b/apps/desktop/src/main/services/chat/spawnMissionOwnership.ts index 7818ca9f9..5225a3b7f 100644 --- a/apps/desktop/src/main/services/chat/spawnMissionOwnership.ts +++ b/apps/desktop/src/main/services/chat/spawnMissionOwnership.ts @@ -3,60 +3,58 @@ import type { AgentChatEvent, AgentChatEventEnvelope, AgentChatEventMetadata } f /** * Who a spawned child chat is currently working for. * - * A subagent completion wakes its parent when the parent still owns the child's - * mission, not only when the parent started the turn that happened to finish. - * Long missions are mostly driven by the child's own scheduled wakeups — an ADE - * ship loop polling CI, say — so strict per-turn attribution left the parent - * unnotified on the turn that actually delivered the result. + * Wake vs quiet is decided by the child's persisted `spawnKind`, not by the + * latest human message. A subagent always wakes its parent; a peer never does. + * Taking over (demote to peer) is an explicit user action. A human message + * while the child stays a subagent does not close the report channel — the + * next wake names how many human messages landed in that turn so the parent + * can read the transcript before following up. * - * Ownership is the most recent *directive* the child received. A directive is - * someone assigning work: a human message, or a message the host stamped as - * parent-dispatched. Everything else continues whatever mission is already in - * flight and must not reassign it: + * `isHumanChildMessage` counts those human messages. Parent dispatches, + * scheduled wakes, relays, host continuations, and any orchestration origin + * are not human messages. * - * - `scheduledWake` — the child's own durable scheduler firing. - * - `spawnCompletion` — a result returning from the child's own grandchild. - * - `agentRelay` — another bound agent talking to the child: its own grandchild - * reporting in (which ADE's spawn guidance tells children to do), a sibling - * coordinating, or the child messaging itself. None of them own the mission, - * so none of them may take it from the parent. - * - `orchestrationOrigin` with any `intent` other than `"directive"` — status, - * diff notices, questions and cancellations inside an orchestration run. - * - `hostContinuation` — ADE prompting the child to resume/repair its own work. - * - `kind: "continuity_recovery"` — the same thing on transcripts written - * before `hostContinuation` existed. Current writers set both. - * - `deliveryState: "queued"` — not yet delivered, and the queued copy is - * lossy (the queue path strips `scheduledWake`). The delivered twin carries - * the authoritative metadata; a queued message that is never delivered was - * never acted on and should not take ownership. - * - * A handoff prompt (`kind: "handoff"` / `"cross_machine_handoff"`) *is* a - * directive: it carries a human's continuation intent into the session. - * - * Every input here is persisted host-authored state. `spawnDispatch` is stamped - * at the ADE RPC edge from the caller's bound session and the target's - * persisted parent, with caller-supplied values deleted first, so a child - * cannot manufacture ownership of itself. + * Every host-authored marker is persisted host state. `spawnDispatch` is + * stamped at the ADE RPC edge from the caller's bound session and the + * target's persisted parent, with caller-supplied values deleted first, so a + * child cannot manufacture ownership of itself. */ -export const isMissionDirective = ( +export const isHumanChildMessage = ( event: Extract, ): boolean => { if (event.deliveryState === "queued") return false; const metadata: AgentChatEventMetadata | null | undefined = event.metadata; if (!metadata) return true; + if (metadata.spawnDispatch) return false; if (NON_DIRECTIVE_METADATA_KEYS.some((key) => metadata[key])) return false; if (metadata.kind === "continuity_recovery") return false; - // Orchestration pings: a worker reporting status, a diff notice, a question, - // or a cancellation is coordination inside a run. Only an explicit directive - // assigns work. - const orchestrationIntent = (metadata.orchestrationOrigin as { intent?: unknown } | undefined)?.intent; - if (orchestrationIntent != null && orchestrationIntent !== "directive") return false; + if (metadata.orchestrationOrigin) return false; return true; }; -/** Host-authored markers that say "this message continues the mission" rather - * than "this message assigns one". The single source for both the predicate - * above and the untrusted-caller strip below. */ +export const countHumanChildMessagesForTurn = ( + history: readonly AgentChatEventEnvelope[], + turnId: string, +): number => { + let count = 0; + for (const envelope of history) { + const event = envelope.event; + if (event?.type !== "user_message") continue; + if (event.turnId !== turnId) continue; + if (!isHumanChildMessage(event)) continue; + count += 1; + } + return count; +}; + +export const formatHumanChildMessageAnnotation = (count: number): string | null => { + if (count <= 0) return null; + if (count === 1) return "The user also sent 1 message to this chat."; + return `The user also sent ${count} messages to this chat.`; +}; + +/** Host-authored markers that are not human messages. Shared with the + * untrusted-caller strip below. */ const NON_DIRECTIVE_METADATA_KEYS = [ "scheduledWake", "spawnCompletion", @@ -73,7 +71,8 @@ const NON_DIRECTIVE_METADATA_KEYS = [ export const HOST_AUTHORED_MESSAGE_PROVENANCE_KEYS = [ "spawnDispatch", // Written in-process by the orchestration service, never accepted from a - // chat caller — its `intent` also feeds the directive test above. + // chat caller. Any orchestration origin is excluded from the human-message + // count above. "orchestrationOrigin", ...NON_DIRECTIVE_METADATA_KEYS, ] as const; @@ -81,55 +80,3 @@ export const HOST_AUTHORED_MESSAGE_PROVENANCE_KEYS = [ export const stripHostAuthoredMessageProvenance = (metadata: Record): void => { for (const key of HOST_AUTHORED_MESSAGE_PROVENANCE_KEYS) delete metadata[key]; }; - -const lastDirective = ( - history: readonly AgentChatEventEnvelope[], -): Extract | null => { - for (let index = history.length - 1; index >= 0; index -= 1) { - const event = history[index]?.event; - if (event?.type !== "user_message" || !isMissionDirective(event)) continue; - return event; - } - return null; -}; - -const stampedByParent = ( - event: Extract | null, - parentSessionId: string, -): boolean => event?.metadata?.spawnDispatch?.parentSessionId === parentSessionId; - -/** - * Whether a finished child turn should wake `parentSessionId`. - * - * Two independent reasons, either of which is enough: - * - the parent dispatched *this* turn (the original contract — kept so a turn - * the parent started still reports back even if a human messaged the child - * while it ran; an inline steer joins the running turn and reuses its id, so - * this asks whether the turn contains a parent dispatch, not whether the - * parent wrote its last message), or - * - the parent owns the mission at completion time. - * - * Ownership is read at completion time; ADE keeps no per-schedule provenance. - * "Who is waiting for this result now" is what the wake answers, so work the - * child scheduled while a human owned the mission still wakes the parent if - * the parent re-dispatched before it fired. - */ -export const parentShouldWakeForChildTurn = (args: { - history: readonly AgentChatEventEnvelope[]; - parentSessionId: string; - turnId: string; -}): boolean => { - const { history, parentSessionId, turnId } = args; - const dispatchedThisTurn = history.some((envelope) => { - const event = envelope.event; - return event.type === "user_message" - // A queued row carries the *running* turn's id, so a parent message that - // arrived mid-turn and has not been delivered yet would otherwise look - // like the dispatch of a turn it never started. - && event.deliveryState !== "queued" - && event.turnId === turnId - && stampedByParent(event, parentSessionId); - }); - if (dispatchedThisTurn) return true; - return stampedByParent(lastDirective(history), parentSessionId); -}; diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts index c5ace8957..e5abfd22c 100644 --- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts +++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts @@ -241,6 +241,8 @@ function createMockAgentChatService() { respondToInput: vi.fn().mockResolvedValue(undefined), resumeSession: vi.fn().mockResolvedValue(undefined), updateSession: vi.fn().mockResolvedValue(undefined), + setSpawnKind: vi.fn().mockResolvedValue({ id: "sess-1", spawnKind: "peer" }), + dismissSubagentTakeoverPrompt: vi.fn().mockResolvedValue({ id: "sess-1" }), getCodexGoal: vi.fn().mockResolvedValue({ objective: "Ship it", status: "active", tokenBudget: null }), setCodexGoal: vi.fn().mockResolvedValue({ objective: "Ship it", status: "active", tokenBudget: null }), setCodexGoalStatus: vi.fn().mockResolvedValue({ objective: "Ship it", status: "paused", tokenBudget: null }), @@ -1587,6 +1589,42 @@ describe("createSyncRemoteCommandService", () => { }); }); + it("chat.updateSession forwards spawnKind and takeover-banner dismissal", async () => { + await service.execute(makePayload("chat.updateSession", { + sessionId: "sess-1", + spawnKind: "peer", + subagentTakeoverPromptShown: true, + })); + + expect(agentChatService.updateSession).toHaveBeenCalledWith({ + sessionId: "sess-1", + spawnKind: "peer", + subagentTakeoverPromptShown: true, + }); + }); + + it("chat.setSpawnKind routes to agentChatService.setSpawnKind", async () => { + await service.execute(makePayload("chat.setSpawnKind", { + sessionId: "sess-1", + spawnKind: "peer", + })); + + expect(agentChatService.setSpawnKind).toHaveBeenCalledWith({ + sessionId: "sess-1", + spawnKind: "peer", + }); + }); + + it("chat.dismissSubagentTakeoverPrompt routes to the chat service", async () => { + await service.execute(makePayload("chat.dismissSubagentTakeoverPrompt", { + sessionId: "sess-1", + })); + + expect(agentChatService.dismissSubagentTakeoverPrompt).toHaveBeenCalledWith({ + sessionId: "sess-1", + }); + }); + it("chat.getCodexGoal routes to agentChatService.getCodexGoal", async () => { const result = await service.execute(makePayload("chat.getCodexGoal", { sessionId: "sess-1", diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index 923692201..7cd1a2cd5 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -3380,6 +3380,26 @@ function renderEvent( ); } + if (event.noticeKind === "info" && event.status === "spawn_takeover") { + const takeover = event.detail && typeof event.detail === "object" ? event.detail.spawnTakeover : undefined; + const childTitle = takeover?.childTitle?.trim() || "chat"; + const childSessionId = typeof takeover?.childSessionId === "string" && takeover.childSessionId.length + ? takeover.childSessionId + : null; + return ( + + ); + } if (event.noticeKind === "info" && event.status === "spawn_completed") { const completion: AgentChatSpawnCompletion | undefined = event.detail && typeof event.detail === "object" ? event.detail.spawnCompletion : undefined; diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 6152ede29..86b15a3e3 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -113,6 +113,7 @@ import { cn } from "../ui/cn"; import { AgentChatComposer, type ParallelComposerControlSlot } from "./AgentChatComposer"; import { collectAgentChatPromptHistory, type AgentChatPromptHistoryEntry } from "./chatPromptHistory"; import { ChatLifecycleBanner } from "./ChatLifecycleBanner"; +import { ChatSubagentTakeoverBanner } from "./ChatSubagentTakeoverBanner"; import { resolveModelDescriptorWithRuntimeCatalog, descriptorsFromAgentChatModelCatalog } from "../shared/ModelPicker/modelCatalog"; import { latestContextUsageInput, toUsageViewModel, type ContextUsageViewModel } from "./usage/contextUsageModel"; import { getSharedRuntimeCatalog } from "../shared/ModelPicker/runtimeCatalogCache"; @@ -7419,6 +7420,10 @@ export function AgentChatPane({ if (meta.cursorModeId !== undefined) summaryPatch.cursorModeId = meta.cursorModeId; if (meta.cursorModeSnapshot !== undefined) summaryPatch.cursorModeSnapshot = meta.cursorModeSnapshot; if (meta.cursorConfigValues !== undefined) summaryPatch.cursorConfigValues = meta.cursorConfigValues; + if (meta.spawnKind !== undefined) summaryPatch.spawnKind = meta.spawnKind; + if (meta.subagentTakeoverPromptShownAt !== undefined) { + summaryPatch.subagentTakeoverPromptShownAt = meta.subagentTakeoverPromptShownAt; + } if (Object.keys(summaryPatch).length > 0) { patchSessionSummary(envelope.sessionId, summaryPatch); } @@ -7428,7 +7433,9 @@ export function AgentChatPane({ // (mirrors the plan-mode transition special-case below). summaryPatch's // keys are exactly `title` plus the mode fields (each gated on the same // `meta.X !== undefined` check), so any non-title key means a mode changed. - const modeChanged = Object.keys(summaryPatch).some((key) => key !== "title"); + const modeChanged = Object.keys(summaryPatch).some((key) => + key !== "title" && key !== "spawnKind" && key !== "subagentTakeoverPromptShownAt" + ); if (modeChanged && envelope.sessionId === selectedSessionIdRef.current) { if (meta.interactionMode !== undefined) { setInteractionMode(meta.interactionMode ?? initialNativeControls.interactionMode); @@ -12046,6 +12053,55 @@ export function AgentChatPane({ const lifecycleBanner = composerSessionId ? ( ) : null; + const takeoverBanner = composerSessionId + && selectedSession?.spawnKind === "subagent" + && selectedSession.orchestrationParentSessionId + && !selectedSession.subagentTakeoverPromptShownAt + ? ( + { + const sessionId = composerSessionId; + const previousShownAt = selectedSession.subagentTakeoverPromptShownAt ?? null; + patchSessionSummary(sessionId, { + spawnKind: "peer", + subagentTakeoverPromptShownAt: new Date().toISOString(), + }); + void window.ade.agentChat.updateSession({ + sessionId, + spawnKind: "peer", + }, ...chatPinArgsFor(chatRuntimePinRef)).then((updated) => { + patchSessionSummary(sessionId, { + spawnKind: updated.spawnKind, + subagentTakeoverPromptShownAt: updated.subagentTakeoverPromptShownAt, + }); + }).catch((err) => { + patchSessionSummary(sessionId, { + spawnKind: "subagent", + subagentTakeoverPromptShownAt: previousShownAt, + }); + setError(err instanceof Error ? err.message : String(err)); + }); + }} + onKeepReporting={() => { + const sessionId = composerSessionId; + const shownAt = new Date().toISOString(); + patchSessionSummary(sessionId, { subagentTakeoverPromptShownAt: shownAt }); + void window.ade.agentChat.updateSession({ + sessionId, + subagentTakeoverPromptShown: true, + }, ...chatPinArgsFor(chatRuntimePinRef)).then((updated) => { + patchSessionSummary(sessionId, { + subagentTakeoverPromptShownAt: updated.subagentTakeoverPromptShownAt ?? shownAt, + }); + }).catch((err) => { + patchSessionSummary(sessionId, { subagentTakeoverPromptShownAt: null }); + setError(err instanceof Error ? err.message : String(err)); + }); + }} + /> + ) + : null; const composerMachineBinding = activeComposerRuntimeBinding; @@ -12695,6 +12751,7 @@ export function AgentChatPane({ {awayDigestStrip} {lifecycleBanner} + {takeoverBanner} {composerElement} ); @@ -13137,6 +13194,7 @@ export function AgentChatPane({ {awayDigestStrip} {lifecycleBanner} + {takeoverBanner} {composerElement} ) : null} diff --git a/apps/desktop/src/renderer/components/chat/ChatSubagentTakeoverBanner.tsx b/apps/desktop/src/renderer/components/chat/ChatSubagentTakeoverBanner.tsx new file mode 100644 index 000000000..5f60af102 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/ChatSubagentTakeoverBanner.tsx @@ -0,0 +1,89 @@ +import { ArrowsLeftRight, X } from "@phosphor-icons/react"; + +import { cn } from "../ui/cn"; + +/** + * Non-blocking notice above the composer on a subagent chat: this thread still + * reports to its parent. Take over converts it to a peer; Keep reporting (or + * dismiss) leaves the channel open. Sending does not answer the prompt. + */ + +const CARD_BASE_CLASS = + "mb-1.5 flex items-start gap-2.5 rounded-[calc(var(--chat-radius-card)-8px)] border px-3 py-2.5"; +const ICON_TILE_CLASS = + "flex h-6 w-6 shrink-0 items-center justify-center rounded-lg border border-violet-400/18 bg-violet-400/[0.08] text-violet-200"; +const BUTTON_BASE_CLASS = + "inline-flex shrink-0 items-center rounded-md border px-2.5 py-1 font-mono text-[length:calc(var(--chat-font-size)*9/14)] font-semibold transition-colors disabled:pointer-events-none disabled:opacity-40"; + +export function ChatSubagentTakeoverBanner({ + parentTitle, + onTakeOver, + onKeepReporting, + className, +}: { + parentTitle: string | null; + onTakeOver: () => void; + onKeepReporting: () => void; + className?: string; +}) { + const namedParent = parentTitle?.trim() || null; + const line = namedParent + ? `This chat reports back to "${namedParent}". Take it over?` + : "This chat reports back to its parent. Take it over?"; + + return ( +
+
+ +
+
+
+ Take over this chat? +
+
+ {line} +
+
+ + +
+
+ +
+ ); +} diff --git a/apps/desktop/src/renderer/components/terminals/SessionContextMenu.test.tsx b/apps/desktop/src/renderer/components/terminals/SessionContextMenu.test.tsx index 2d538ea63..c08a417ff 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionContextMenu.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionContextMenu.test.tsx @@ -509,3 +509,59 @@ describe("SessionContextMenu snooze and explicit-settle lifecycle", () => { }); }); }); + +describe("SessionContextMenu spawn kind", () => { + let updateSession: ReturnType; + + beforeEach(() => { + updateSession = vi.fn().mockResolvedValue({ spawnKind: "peer" }); + (window as unknown as { ade: unknown }).ade = { + agentChat: { updateSession }, + sessions: {}, + }; + }); + + afterEach(() => { + delete (window as unknown as { ade?: unknown }).ade; + vi.clearAllMocks(); + }); + + it("offers Demote to peer on a subagent child and writes spawnKind peer", async () => { + const session = makeSession({ + orchestrationParentSessionId: "parent-1", + spawnKind: "subagent", + }); + const { onClose } = renderMenu(session); + + fireEvent.click(screen.getByRole("button", { name: "Demote to peer" })); + await waitFor(() => { + expect(updateSession).toHaveBeenCalledWith( + { sessionId: "chat-1", spawnKind: "peer" }, + ); + }); + expect(onClose).toHaveBeenCalled(); + expect(screen.queryByRole("button", { name: "Promote to subagent" })).toBeNull(); + }); + + it("offers Promote to subagent on a peer child", async () => { + const session = makeSession({ + orchestrationParentSessionId: "parent-1", + spawnKind: "peer", + }); + renderMenu(session); + + fireEvent.click(screen.getByRole("button", { name: "Promote to subagent" })); + await waitFor(() => { + expect(updateSession).toHaveBeenCalledWith( + { sessionId: "chat-1", spawnKind: "subagent" }, + ); + }); + expect(screen.queryByRole("button", { name: "Demote to peer" })).toBeNull(); + }); + + it("hides demote and promote on a chat with no parent", () => { + renderMenu(makeSession({ spawnKind: "subagent" })); + expect(screen.queryByRole("button", { name: "Demote to peer" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Promote to subagent" })).toBeNull(); + }); +}); diff --git a/apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx b/apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx index 3e490a4ae..da9c53eb7 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx @@ -14,6 +14,7 @@ import { LaneActionsSubmenu } from "./LaneActionsSubmenu"; import { WorkManageLaneDialogHost } from "./WorkManageLaneDialogHost"; import { setSessionSettleOverride, + setChatSpawnKind, snoozeSessionForDuration, unsettleSession, wakeSessionNow, @@ -436,6 +437,25 @@ function SessionContextMenuPanel({ {settleRow} + {isChat && session.orchestrationParentSessionId && session.spawnKind === "subagent" ? ( + + ) : null} + {isChat && session.orchestrationParentSessionId && session.spawnKind === "peer" ? ( + + ) : null} + {/* ── Go to: the surfaces outside this menu that show the same session. ── */} Go to diff --git a/apps/desktop/src/renderer/components/terminals/sessionLifecycleActions.ts b/apps/desktop/src/renderer/components/terminals/sessionLifecycleActions.ts index 1af0d2813..51922e7c4 100644 --- a/apps/desktop/src/renderer/components/terminals/sessionLifecycleActions.ts +++ b/apps/desktop/src/renderer/components/terminals/sessionLifecycleActions.ts @@ -151,3 +151,18 @@ export function clearSessionWokeMarker( console.error("[sessionLifecycle] clearWokeMarker failed", { sessionId, error }); }); } + +export async function setChatSpawnKind( + session: Pick, + spawnKind: "subagent" | "peer", + pin?: OpenProjectBinding | null, +): Promise { + const action = spawnKind === "peer" ? "Take over" : "Promote to subagent"; + try { + await (pin + ? window.ade.agentChat.updateSession({ sessionId: session.id, spawnKind }, pin) + : window.ade.agentChat.updateSession({ sessionId: session.id, spawnKind })); + } catch (error) { + reportFailure(action, session.id, error); + } +} diff --git a/apps/desktop/src/shared/syncMobileCompatibility.ts b/apps/desktop/src/shared/syncMobileCompatibility.ts index 825fefd4a..1cd32fba2 100644 --- a/apps/desktop/src/shared/syncMobileCompatibility.ts +++ b/apps/desktop/src/shared/syncMobileCompatibility.ts @@ -25,6 +25,12 @@ export const MOBILE_SYNC_OPTIONAL_REMOTE_COMMAND_ACTIONS = [ "session.snoozeSession", "session.wakeSession", "session.clearWokeMarker", + // Subagent takeover. The phone hides Take over / Keep reporting / Demote / + // Promote unless `chat.setSpawnKind` is advertised. Optional so an older + // phone against a newer host does not go limited, and an older host simply + // omits the actions. + "chat.setSpawnKind", + "chat.dismissSubagentTakeoverPrompt", // GitHub Stacked PRs are in public preview. Mobile clients can expose these // actions as they adopt stack management without limiting older builds. "prs.listGithubStacks", diff --git a/apps/desktop/src/shared/types/chat.ts b/apps/desktop/src/shared/types/chat.ts index c337e49ae..ffee576eb 100644 --- a/apps/desktop/src/shared/types/chat.ts +++ b/apps/desktop/src/shared/types/chat.ts @@ -189,6 +189,12 @@ export type AgentChatSpawnCompletion = { childTurnId?: string; status: "completed" | "failed" | "stopped"; summary?: string; + /** + * Human messages the user sent to the child during this turn. Present on + * subagent wakes so the parent can see the two-drivers overlap before it + * follows up. Omitted when the count is zero. + */ + humanMessageCount?: number; }; export type AgentChatSpawnDispatchMetadata = { @@ -273,6 +279,10 @@ export type AgentChatNoticeDetail = { */ hasInlineCard?: boolean; spawnCompletion?: AgentChatSpawnCompletion; + spawnTakeover?: { + childSessionId: string; + childTitle: string; + }; spawnCompletionDeliveryFailure?: { childTurnId: string; parentSessionId: string; @@ -1313,6 +1323,8 @@ export type AgentChatEvent = cursorModeId?: string | null; cursorModeSnapshot?: AgentChatCursorModeSnapshot; cursorConfigValues?: Record | null; + spawnKind?: AgentChatSpawnKind; + subagentTakeoverPromptShownAt?: string | null; // Accept turnId for uniformity with other variants — ignored by handlers. turnId?: string; }; @@ -1413,6 +1425,11 @@ export type OrchestrationSessionFields = { orchestrationRole?: OrchestrationRole; orchestrationParentSessionId?: string; spawnKind?: AgentChatSpawnKind; + /** + * When the takeover banner was dismissed or Take over was chosen. Brain-side + * so desktop, iOS, and ADE Code do not re-show it. Absent means not shown yet. + */ + subagentTakeoverPromptShownAt?: string | null; orchestrationTag?: string; orchestrationStepId?: string; orchestrationBundlePath?: string; @@ -2781,11 +2798,23 @@ export type AgentChatArchiveArgs = { sessionId: string; }; +export type AgentChatSetSpawnKindArgs = { + sessionId: string; + spawnKind: AgentChatSpawnKind; +}; + +export type AgentChatDismissSubagentTakeoverPromptArgs = { + sessionId: string; +}; + export type AgentChatUpdateSessionArgs = { sessionId: string; title?: string | null; tag?: string | null; manuallyNamed?: boolean; + spawnKind?: AgentChatSpawnKind; + /** Persist that the takeover banner was shown and answered or dismissed. */ + subagentTakeoverPromptShown?: boolean; modelId?: ModelId; reasoningEffort?: string | null; fastMode?: boolean; diff --git a/apps/desktop/src/shared/types/sync.ts b/apps/desktop/src/shared/types/sync.ts index d43b7d5bd..97d967984 100644 --- a/apps/desktop/src/shared/types/sync.ts +++ b/apps/desktop/src/shared/types/sync.ts @@ -1808,6 +1808,8 @@ export type SyncRemoteCommandAction = | "chat.respondToInput" | "chat.restart" | "chat.updateSession" + | "chat.setSpawnKind" + | "chat.dismissSubagentTakeoverPrompt" | "chat.getCodexGoal" | "chat.setCodexGoal" | "chat.setCodexGoalStatus" diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index 4cda04e6d..fa888c618 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -893,6 +893,8 @@ struct AgentChatSessionSummary: Codable, Identifiable, Equatable { var orchestrationRole: String? = nil var orchestrationParentSessionId: String? = nil var spawnKind: AgentChatSpawnKind? = nil + /// When the takeover banner was dismissed or Take over was chosen. Absent means not shown yet. + var subagentTakeoverPromptShownAt: String? = nil var orchestrationTag: String? = nil var orchestrationStepId: String? = nil var orchestrationBundlePath: String? = nil @@ -951,6 +953,7 @@ struct AgentChatSessionSummary: Codable, Identifiable, Equatable { && lhs.orchestrationRole == rhs.orchestrationRole && lhs.orchestrationParentSessionId == rhs.orchestrationParentSessionId && lhs.spawnKind == rhs.spawnKind + && lhs.subagentTakeoverPromptShownAt == rhs.subagentTakeoverPromptShownAt && lhs.orchestrationTag == rhs.orchestrationTag && lhs.orchestrationStepId == rhs.orchestrationStepId && lhs.orchestrationBundlePath == rhs.orchestrationBundlePath @@ -986,6 +989,9 @@ struct AgentChatSessionMetaModeUpdate: Decodable, Equatable { /// key. Symmetric with `cursorModeIdWasCleared`: absent-key still means "no /// change"; only an explicit null sets this so `applyModeUpdate` assigns nil. var cursorConfigValuesWasCleared: Bool = false + var spawnKind: AgentChatSpawnKind? + var subagentTakeoverPromptShownAt: String? + var subagentTakeoverPromptShownAtWasCleared: Bool = false private enum CodingKeys: String, CodingKey { case permissionMode @@ -1000,6 +1006,8 @@ struct AgentChatSessionMetaModeUpdate: Decodable, Equatable { case cursorModeId case cursorModeSnapshot case cursorConfigValues + case spawnKind + case subagentTakeoverPromptShownAt } init(from decoder: Decoder) throws { @@ -1040,6 +1048,14 @@ struct AgentChatSessionMetaModeUpdate: Decodable, Equatable { cursorConfigValues = nil cursorConfigValuesWasCleared = false } + spawnKind = try c.decodeIfPresent(AgentChatSpawnKind.self, forKey: .spawnKind) + if c.contains(.subagentTakeoverPromptShownAt) { + subagentTakeoverPromptShownAt = try c.decodeIfPresent(String.self, forKey: .subagentTakeoverPromptShownAt) + subagentTakeoverPromptShownAtWasCleared = subagentTakeoverPromptShownAt == nil + } else { + subagentTakeoverPromptShownAt = nil + subagentTakeoverPromptShownAtWasCleared = false + } } /// True when the event carries at least one mode field. A bare @@ -1058,6 +1074,9 @@ struct AgentChatSessionMetaModeUpdate: Decodable, Equatable { || cursorModeSnapshot != nil || cursorConfigValues != nil || cursorConfigValuesWasCleared + || spawnKind != nil + || subagentTakeoverPromptShownAt != nil + || subagentTakeoverPromptShownAtWasCleared } } @@ -1089,6 +1108,12 @@ extension AgentChatSessionSummary { // rather than leaving the stale values in place. cursorConfigValues = nil } + if let v = update.spawnKind { spawnKind = v } + if let v = update.subagentTakeoverPromptShownAt { + subagentTakeoverPromptShownAt = v + } else if update.subagentTakeoverPromptShownAtWasCleared { + subagentTakeoverPromptShownAt = nil + } } /// Overlay the mode fields from another summary (used to fold a cache-side @@ -1119,6 +1144,8 @@ extension AgentChatSessionSummary { cursorModeId = other.cursorModeId if let v = other.cursorModeSnapshot { cursorModeSnapshot = v } cursorConfigValues = other.cursorConfigValues + if let v = other.spawnKind { spawnKind = v } + if let v = other.subagentTakeoverPromptShownAt { subagentTakeoverPromptShownAt = v } } } @@ -1591,6 +1618,7 @@ struct AgentChatSession: Codable, Identifiable, Equatable { var orchestrationRole: String? = nil var orchestrationParentSessionId: String? = nil var spawnKind: AgentChatSpawnKind? = nil + var subagentTakeoverPromptShownAt: String? = nil var orchestrationTag: String? = nil var orchestrationStepId: String? = nil var orchestrationBundlePath: String? = nil @@ -1637,6 +1665,7 @@ struct AgentChatSession: Codable, Identifiable, Equatable { case orchestrationRole case orchestrationParentSessionId case spawnKind + case subagentTakeoverPromptShownAt case orchestrationTag case orchestrationStepId case orchestrationBundlePath @@ -1685,6 +1714,7 @@ struct AgentChatSession: Codable, Identifiable, Equatable { orchestrationRole = try container.decodeIfPresent(String.self, forKey: .orchestrationRole) orchestrationParentSessionId = try container.decodeIfPresent(String.self, forKey: .orchestrationParentSessionId) spawnKind = try container.decodeIfPresent(AgentChatSpawnKind.self, forKey: .spawnKind) + subagentTakeoverPromptShownAt = try container.decodeIfPresent(String.self, forKey: .subagentTakeoverPromptShownAt) orchestrationTag = try container.decodeIfPresent(String.self, forKey: .orchestrationTag) orchestrationStepId = try container.decodeIfPresent(String.self, forKey: .orchestrationStepId) orchestrationBundlePath = try container.decodeIfPresent(String.self, forKey: .orchestrationBundlePath) @@ -1731,6 +1761,7 @@ struct AgentChatSession: Codable, Identifiable, Equatable { try container.encodeIfPresent(orchestrationRole, forKey: .orchestrationRole) try container.encodeIfPresent(orchestrationParentSessionId, forKey: .orchestrationParentSessionId) try container.encodeIfPresent(spawnKind, forKey: .spawnKind) + try container.encodeIfPresent(subagentTakeoverPromptShownAt, forKey: .subagentTakeoverPromptShownAt) try container.encodeIfPresent(orchestrationTag, forKey: .orchestrationTag) try container.encodeIfPresent(orchestrationStepId, forKey: .orchestrationStepId) try container.encodeIfPresent(orchestrationBundlePath, forKey: .orchestrationBundlePath) @@ -3395,6 +3426,8 @@ struct AgentChatUpdateSessionRequest: Codable, Equatable { var unifiedPermissionMode: String? var computerUse: RemoteJSONValue? var manuallyNamed: Bool? + var spawnKind: String? + var subagentTakeoverPromptShown: Bool? } struct AgentChatTranscriptEntry: Codable, Identifiable, Equatable { diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 493429a0c..ca8cf7ccd 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -9397,6 +9397,14 @@ final class SyncService: ObservableObject { supportsRemoteAction("session.snoozeSession") } + /// Whether this host can demote, promote, or dismiss the subagent takeover + /// banner. Older brains advertise `chat.updateSession` but ignore or reject + /// `spawnKind`; `chat.setSpawnKind` is the advertise check so the phone hides + /// the controls instead of offering a write that cannot apply. + var supportsSpawnKindUpdate: Bool { + supportsRemoteAction("chat.setSpawnKind") + } + private func sessionLifecycleUnsupportedError(_ action: String) -> NSError { NSError(domain: "ADE", code: 27, userInfo: [ NSLocalizedDescriptionKey: @@ -13089,7 +13097,9 @@ final class SyncService: ObservableObject { cursorConfigValues: [String: RemoteJSONValue]? = nil, unifiedPermissionMode: String? = nil, computerUse: RemoteJSONValue? = nil, - manuallyNamed: Bool? = nil + manuallyNamed: Bool? = nil, + spawnKind: String? = nil, + subagentTakeoverPromptShown: Bool? = nil ) async throws -> AgentChatSession { let scope = chatCommandScope(for: sessionId) return try await sendDecodableChatCommand( @@ -13112,7 +13122,9 @@ final class SyncService: ObservableObject { cursorConfigValues: cursorConfigValues, unifiedPermissionMode: unifiedPermissionMode, computerUse: computerUse, - manuallyNamed: manuallyNamed + manuallyNamed: manuallyNamed, + spawnKind: spawnKind, + subagentTakeoverPromptShown: subagentTakeoverPromptShown ), targetProjectId: scope.projectId, targetProjectRootPath: scope.rootPath, diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift index c10292363..937fdd925 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift @@ -169,8 +169,12 @@ struct WorkChatSummaryRenderContext: Equatable { let modelLabel: String let contextWindowFallback: Int? let claudeGoal: AgentChatClaudeGoal? + let spawnKind: AgentChatSpawnKind? + let orchestrationParentSessionId: String? + let subagentTakeoverPromptShownAt: String? + let parentTitle: String? - init(_ summary: AgentChatSessionSummary?) { + init(_ summary: AgentChatSessionSummary?, parentTitle: String? = nil) { guard let summary else { self.isAvailable = false self.provider = "" @@ -187,6 +191,10 @@ struct WorkChatSummaryRenderContext: Equatable { self.modelLabel = "Model" self.contextWindowFallback = nil self.claudeGoal = nil + self.spawnKind = nil + self.orchestrationParentSessionId = nil + self.subagentTakeoverPromptShownAt = nil + self.parentTitle = nil return } @@ -205,6 +213,10 @@ struct WorkChatSummaryRenderContext: Equatable { self.modelLabel = prettyWorkChatModelName(summary.model) self.contextWindowFallback = workContextWindowFallback(modelId: summary.modelId, model: summary.model) self.claudeGoal = summary.claudeGoal + self.spawnKind = summary.spawnKind + self.orchestrationParentSessionId = summary.orchestrationParentSessionId + self.subagentTakeoverPromptShownAt = summary.subagentTakeoverPromptShownAt + self.parentTitle = parentTitle } var currentModelId: String { @@ -392,6 +404,8 @@ struct WorkChatSessionView: View { /// Re-requests the transcript after a failed load. When nil the failure state /// renders without a Retry button rather than offering a dead control. var onRetryTranscript: (() -> Void)? = nil + var onTakeOverSubagent: (@MainActor () async -> Void)? = nil + var onKeepReportingSubagent: (@MainActor () async -> Void)? = nil @State var steerEditDrafts: [String: String] = [:] @State var modelPickerPresented = false @@ -1160,6 +1174,26 @@ struct WorkChatSessionView: View { ) } + if chatSummaryContext.spawnKind == .subagent, + let parentId = chatSummaryContext.orchestrationParentSessionId, + !parentId.isEmpty, + chatSummaryContext.subagentTakeoverPromptShownAt == nil, + onTakeOverSubagent != nil || onKeepReportingSubagent != nil { + WorkSubagentTakeoverBanner( + parentTitle: chatSummaryContext.parentTitle, + takeOverEnabled: onTakeOverSubagent != nil && !actionInFlight && !hostUnreachable, + keepReportingEnabled: onKeepReportingSubagent != nil && !actionInFlight && !hostUnreachable, + onTakeOver: { + guard let onTakeOverSubagent else { return } + await runSessionAction { await onTakeOverSubagent() } + }, + onKeepReporting: { + guard let onKeepReportingSubagent else { return } + await runSessionAction { await onKeepReportingSubagent() } + } + ) + } + WorkChatComposerCard( chatSummary: chatSummaryContext, usageViewModel: contextUsageViewModelCache.value( @@ -2825,6 +2859,63 @@ private struct WorkChatComposerDraftInput: View { } } +private struct WorkSubagentTakeoverBanner: View { + let parentTitle: String? + let takeOverEnabled: Bool + let keepReportingEnabled: Bool + let onTakeOver: @MainActor () async -> Void + let onKeepReporting: @MainActor () async -> Void + + private var line: String { + if let named = parentTitle?.trimmingCharacters(in: .whitespacesAndNewlines), !named.isEmpty { + return "This chat reports back to \"\(named)\". Take it over?" + } + return "This chat reports back to its parent. Take it over?" + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Take over this chat?") + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + Text(line) + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + .fixedSize(horizontal: false, vertical: true) + HStack(spacing: 8) { + Button { + Task { await onTakeOver() } + } label: { + Text("Take over") + .font(.caption.weight(.semibold)) + .padding(.horizontal, 10) + .padding(.vertical, 6) + } + .buttonStyle(.borderedProminent) + .disabled(!takeOverEnabled) + Button { + Task { await onKeepReporting() } + } label: { + Text("Keep reporting") + .font(.caption.weight(.semibold)) + .padding(.horizontal, 10) + .padding(.vertical, 6) + } + .buttonStyle(.bordered) + .disabled(!keepReportingEnabled) + Spacer(minLength: 0) + } + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(ADEColor.accent.opacity(0.08)) + ) + .accessibilityElement(children: .contain) + } +} + private struct WorkQueueRecoveryBanner: View { let recovery: WorkQueueRecoveryModel let restoring: Bool diff --git a/apps/ios/ADE/Views/Work/WorkModels.swift b/apps/ios/ADE/Views/Work/WorkModels.swift index 716ac71dd..1da85077a 100644 --- a/apps/ios/ADE/Views/Work/WorkModels.swift +++ b/apps/ios/ADE/Views/Work/WorkModels.swift @@ -878,6 +878,48 @@ struct WorkSubagentSelection: Identifiable, Equatable { var id: String { taskId } } +/// Demote/promote and the composer takeover banner write `spawnKind`. Older +/// hosts advertise `chat.updateSession` but do not apply that field, so the +/// dedicated `chat.setSpawnKind` advertise check is the gate. +func workCanDemoteChatToPeer( + isChat: Bool, + spawnKind: AgentChatSpawnKind?, + parentSessionId: String?, + hostSupportsSpawnKindUpdate: Bool +) -> Bool { + guard hostSupportsSpawnKindUpdate, isChat, spawnKind == .subagent else { return false } + let parent = parentSessionId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return !parent.isEmpty +} + +func workCanPromoteChatToSubagent( + isChat: Bool, + spawnKind: AgentChatSpawnKind?, + parentSessionId: String?, + hostSupportsSpawnKindUpdate: Bool +) -> Bool { + guard hostSupportsSpawnKindUpdate, isChat, spawnKind == .peer else { return false } + let parent = parentSessionId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return !parent.isEmpty +} + +/// Apply a successful spawn-kind or takeover-banner write onto the live +/// summary, falling back to the composer latch when `chatSummary` is nil. +func workApplyingSpawnKindUpdate( + current: AgentChatSessionSummary?, + fallback: AgentChatSessionSummary?, + spawnKind: AgentChatSpawnKind? = nil, + subagentTakeoverPromptShownAt: String?, + shownAtFallback: String +) -> AgentChatSessionSummary? { + guard var summary = current ?? fallback else { return nil } + if let spawnKind { + summary.spawnKind = spawnKind + } + summary.subagentTakeoverPromptShownAt = subagentTakeoverPromptShownAt ?? shownAtFallback + return summary +} + struct WorkScheduledWorkSnapshot: Identifiable, Equatable { let id: String let kind: String diff --git a/apps/ios/ADE/Views/Work/WorkRootComponents.swift b/apps/ios/ADE/Views/Work/WorkRootComponents.swift index 3720f0538..45c59f5b6 100644 --- a/apps/ios/ADE/Views/Work/WorkRootComponents.swift +++ b/apps/ios/ADE/Views/Work/WorkRootComponents.swift @@ -567,6 +567,13 @@ struct WorkSessionListRow: View { var onKeepActive: (TerminalSessionSummary) -> Void = { _ in } var onSnooze: (TerminalSessionSummary, WorkSnoozeDuration) -> Void = { _, _ in } var onWake: (TerminalSessionSummary) -> Void = { _ in } + /// Demote a subagent chat to a peer so it stops reporting to its parent. + var onDemoteToPeer: (TerminalSessionSummary) -> Void = { _ in } + /// Promote a peer chat back to a subagent so it reports to its parent again. + var onPromoteToSubagent: (TerminalSessionSummary) -> Void = { _ in } + /// The host advertises `chat.setSpawnKind`. Older hosts have + /// `chat.updateSession` but cannot apply spawn-kind writes. + var spawnKindUpdateAvailable: Bool = false /// The host advertises `work.deleteSession` — the stop-then-delete path for a /// non-chat row. Older hosts never had it, so a phone talking to one hides the /// two destructive items rather than offering a control that always fails. @@ -671,6 +678,24 @@ struct WorkSessionListRow: View { && canonicalPhase == .settled } + private var canDemoteToPeer: Bool { + workCanDemoteChatToPeer( + isChat: isChat, + spawnKind: chatSummary?.spawnKind, + parentSessionId: chatSummary?.orchestrationParentSessionId, + hostSupportsSpawnKindUpdate: spawnKindUpdateAvailable + ) + } + + private var canPromoteToSubagent: Bool { + workCanPromoteChatToSubagent( + isChat: isChat, + spawnKind: chatSummary?.spawnKind, + parentSessionId: chatSummary?.orchestrationParentSessionId, + hostSupportsSpawnKindUpdate: spawnKindUpdateAvailable + ) + } + private var snoozeOptions: [WorkSnoozeOption] { workSnoozeOptions() } @@ -865,7 +890,7 @@ struct WorkSessionListRow: View { @ViewBuilder private func lifecycleMenuSection(status: String) -> some View { let canStopRuntime = isStoppableRuntimeStatus(session, status: status) - if canStopRuntime || lifecycleAvailable || snoozeAvailable { + if canStopRuntime || lifecycleAvailable || snoozeAvailable || canDemoteToPeer || canPromoteToSubagent { Divider() // Stop runtime moved here from the identity block: it is a lifecycle // change, and it is NOT destructive — the session and its transcript @@ -926,6 +951,20 @@ struct WorkSessionListRow: View { Label("Keep active", systemImage: "pin.circle") } } + if canDemoteToPeer { + Button { + onDemoteToPeer(session) + } label: { + Label("Demote to peer", systemImage: "arrow.down.forward.and.arrow.up.backward") + } + } + if canPromoteToSubagent { + Button { + onPromoteToSubagent(session) + } label: { + Label("Promote to subagent", systemImage: "arrow.up.backward.and.arrow.down.forward") + } + } } } diff --git a/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift b/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift index 48453a09b..01a5a97b3 100644 --- a/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift +++ b/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift @@ -493,6 +493,18 @@ extension WorkRootScreen { } } + func demoteSessionToPeer(_ session: TerminalSessionSummary) { + runSessionLifecycle { [syncService] in + _ = try await syncService.updateChatSession(sessionId: session.id, spawnKind: "peer") + } + } + + func promoteSessionToSubagent(_ session: TerminalSessionSummary) { + runSessionLifecycle { [syncService] in + _ = try await syncService.updateChatSession(sessionId: session.id, spawnKind: "subagent") + } + } + /// The woke marker exists to explain why a snoozed row came back. Visiting /// the row is the explanation being read, so drop it then — quietly, since a /// failure here must never block navigation. diff --git a/apps/ios/ADE/Views/Work/WorkRootScreen.swift b/apps/ios/ADE/Views/Work/WorkRootScreen.swift index b1714e9ba..11cf40474 100644 --- a/apps/ios/ADE/Views/Work/WorkRootScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkRootScreen.swift @@ -1205,6 +1205,9 @@ struct WorkRootScreen: View { onKeepActive: keepSessionActive, onSnooze: snoozeSession, onWake: wakeSession, + onDemoteToPeer: demoteSessionToPeer, + onPromoteToSubagent: promoteSessionToSubagent, + spawnKindUpdateAvailable: syncService.supportsSpawnKindUpdate, deleteSessionAvailable: syncService.supportsWorkSessionDeletion, onDeleteSession: deleteWorkSession, onOpenInWeb: openSessionInWeb, diff --git a/apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift b/apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift index 30dfdd974..271a53dcd 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift @@ -412,6 +412,52 @@ extension WorkSessionDestinationView { } } + @MainActor + func takeOverSubagent() async { + do { + let updated = try await syncService.updateChatSession(sessionId: sessionId, spawnKind: "peer") + applySpawnKindSessionUpdate(updated, spawnKind: updated.spawnKind ?? .peer) + errorMessage = nil + ADEHaptics.light() + } catch { + ADEHaptics.error() + errorMessage = error.localizedDescription + } + } + + @MainActor + func keepReportingSubagent() async { + do { + let updated = try await syncService.updateChatSession( + sessionId: sessionId, + subagentTakeoverPromptShown: true + ) + applySpawnKindSessionUpdate(updated) + errorMessage = nil + } catch { + ADEHaptics.error() + errorMessage = error.localizedDescription + } + } + + @MainActor + func applySpawnKindSessionUpdate( + _ updated: AgentChatSession, + spawnKind: AgentChatSpawnKind? = nil + ) { + let shownAtFallback = ISO8601DateFormatter().string(from: Date()) + guard let summary = workApplyingSpawnKindUpdate( + current: chatSummary, + fallback: lastKnownChatSummary ?? initialChatSummary, + spawnKind: spawnKind ?? updated.spawnKind, + subagentTakeoverPromptShownAt: updated.subagentTakeoverPromptShownAt, + shownAtFallback: shownAtFallback + ) else { return } + chatSummary = summary + lastKnownChatSummary = summary + syncService.cacheChatSummary(summary) + } + @MainActor func selectReasoningEffort(_ effort: String) async { let trimmed = effort.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift index f36d45e33..b8d82c206 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift @@ -1456,6 +1456,7 @@ struct WorkSessionDestinationView: View { "chat.dispatchSteer", sessionId: session.id ) + let canWriteSpawnKind = !viewingSubagent && syncService.supportsSpawnKindUpdate let restoreCancelledQueueAction: (@MainActor (String) async -> Void)? if syncService.supportsChatRemoteAction( "chat.restoreCancelledQueue", @@ -1469,7 +1470,12 @@ struct WorkSessionDestinationView: View { } return WorkChatSessionView( session: WorkChatSessionRenderContext(session), - chatSummaryContext: WorkChatSummaryRenderContext(composerChatSummary), + chatSummaryContext: WorkChatSummaryRenderContext( + composerChatSummary, + parentTitle: composerChatSummary?.orchestrationParentSessionId.flatMap { parentId in + syncService.chatSummaryCache[parentId]?.title + } + ), transcript: transcriptForView, transcriptRenderSignature: viewingSubagent ? subagentTranscriptRenderSignature : transcriptRenderSignature, fallbackEntries: fallbackEntriesForView, @@ -1576,7 +1582,9 @@ struct WorkSessionDestinationView: View { await syncService.retryFullChatEventSnapshot(sessionId: sessionId) await loadTranscript(forceRemote: true, preferLightweight: false) } - } + }, + onTakeOverSubagent: canWriteSpawnKind ? takeOverSubagent : nil, + onKeepReportingSubagent: canWriteSpawnKind ? keepReportingSubagent : nil ) } diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index ed220b89e..fbbeb3983 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -7787,6 +7787,52 @@ final class ADETests: XCTestCase { XCTAssertTrue(supportedService.supportsViewerRemoteAction("chat.saveTempAttachment")) } + @MainActor + func testSpawnKindUpdateRequiresAdvertisedSetSpawnKindAction() throws { + let legacyDatabase = makeDatabase(baseURL: makeTemporaryDirectory()) + defer { legacyDatabase.close() } + let legacyService = SyncService(database: legacyDatabase) + try legacyService.applyHelloPayloadForTesting([ + "brain": [ + "deviceId": "host-legacy", + "deviceName": "Mac Studio", + ], + "features": [ + "projectCatalog": false, + "commandRouting": [ + "mode": "allowlisted", + "actions": [[ + "action": "chat.updateSession", + "policy": ["viewerAllowed": true], + ]], + ], + ], + ]) + XCTAssertTrue(legacyService.supportsRemoteAction("chat.updateSession")) + XCTAssertFalse(legacyService.supportsSpawnKindUpdate) + + let supportedDatabase = makeDatabase(baseURL: makeTemporaryDirectory()) + defer { supportedDatabase.close() } + let supportedService = SyncService(database: supportedDatabase) + try supportedService.applyHelloPayloadForTesting([ + "brain": [ + "deviceId": "host-current", + "deviceName": "Mac Studio", + ], + "features": [ + "projectCatalog": false, + "commandRouting": [ + "mode": "allowlisted", + "actions": [[ + "action": "chat.setSpawnKind", + "policy": ["viewerAllowed": true], + ]], + ], + ], + ]) + XCTAssertTrue(supportedService.supportsSpawnKindUpdate) + } + @MainActor func testPersonalChatsStayLocallyActionGatedOnPartialHost() throws { let remoteCommandDescriptorsKey = "ade.sync.remoteCommandDescriptors" @@ -24350,6 +24396,76 @@ final class ADETests: XCTestCase { XCTAssertNil(session.orchestrationStepId) XCTAssertNil(session.orchestrationBundlePath) } + + func testRoleTransitionActionsAreHiddenWhenSpawnKindUpdateIsUnsupported() { + XCTAssertFalse( + workCanDemoteChatToPeer( + isChat: true, + spawnKind: .subagent, + parentSessionId: "parent-1", + hostSupportsSpawnKindUpdate: false + ) + ) + XCTAssertFalse( + workCanPromoteChatToSubagent( + isChat: true, + spawnKind: .peer, + parentSessionId: "parent-1", + hostSupportsSpawnKindUpdate: false + ) + ) + XCTAssertTrue( + workCanDemoteChatToPeer( + isChat: true, + spawnKind: .subagent, + parentSessionId: "parent-1", + hostSupportsSpawnKindUpdate: true + ) + ) + XCTAssertTrue( + workCanPromoteChatToSubagent( + isChat: true, + spawnKind: .peer, + parentSessionId: "parent-1", + hostSupportsSpawnKindUpdate: true + ) + ) + } + + func testTakeoverAndKeepReportingUpdateFallbackSummaryWhenChatSummaryIsNil() throws { + let fallback = try JSONDecoder().decode(AgentChatSessionSummary.self, from: Data(""" + { + "sessionId": "child-1", + "laneId": "lane-1", + "provider": "claude", + "model": "sonnet", + "status": "idle", + "startedAt": "2026-08-12T00:00:00.000Z", + "lastActivityAt": "2026-08-12T00:00:00.000Z", + "orchestrationParentSessionId": "parent-1", + "spawnKind": "subagent" + } + """.utf8)) + + let takenOver = workApplyingSpawnKindUpdate( + current: nil, + fallback: fallback, + spawnKind: .peer, + subagentTakeoverPromptShownAt: "2026-08-12T00:01:00.000Z", + shownAtFallback: "2026-08-12T00:02:00.000Z" + ) + XCTAssertEqual(takenOver?.spawnKind, .peer) + XCTAssertEqual(takenOver?.subagentTakeoverPromptShownAt, "2026-08-12T00:01:00.000Z") + + let keptReporting = workApplyingSpawnKindUpdate( + current: nil, + fallback: fallback, + subagentTakeoverPromptShownAt: nil, + shownAtFallback: "2026-08-12T00:03:00.000Z" + ) + XCTAssertEqual(keptReporting?.spawnKind, .subagent) + XCTAssertEqual(keptReporting?.subagentTakeoverPromptShownAt, "2026-08-12T00:03:00.000Z") + } } private extension Collection { diff --git a/docs/features/agents/README.md b/docs/features/agents/README.md index 6b84cba58..15924fe43 100644 --- a/docs/features/agents/README.md +++ b/docs/features/agents/README.md @@ -157,11 +157,10 @@ is fire-and-forget and leaves quiet turn-completion notes. Missing types and the legacy `none` value are rejected for new parented sessions. Subagent chat turns return their child turn id and latest bounded assistant -summary, steering an active parent or waking an idle parent. A completion wakes -the parent when the parent started that turn *or* still owns the child's -mission — the most recent directive-class input to the child was -parent-dispatched. The policy lives in -`services/chat/spawnMissionOwnership.ts`. These inputs continue the mission in +summary, steering an active parent or waking an idle parent. A subagent always +wakes its parent; a peer never does. The policy lives in +`services/chat/spawnMissionOwnership.ts` plus the child's persisted +`spawnKind`. These inputs continue the mission in flight rather than reassigning it, so a subagent that self-schedules wakeups — an ADE ship loop polling CI, say — still wakes its parent when the mission finishes: @@ -178,11 +177,13 @@ finishes: - `deliveryState: "queued"` — superseded by the delivered copy, which carries the authoritative metadata (the queue path strips `scheduledWake`). -A direct human message is a directive and moves ownership to the human, making -completions quiet notes until the parent dispatches again. A handoff prompt is -also a directive — it carries a human's continuation intent. Ownership is read -at completion time; ADE keeps no per-schedule provenance. Peer turns are always -quiet notes. +A direct human message on a subagent does not steal the report channel. The +next wake names how many human messages landed in that turn. Taking over +(demote to peer) is an explicit user action — composer banner, session menu, +`ade chat demote`, or `/session demote` — and posts a quiet parent note that +reports stop. Promoting restores the channel when the parent chat still +exists. A later parent dispatch into a peer child auto-promotes it back to +subagent. Peer turns are always quiet notes. All of this provenance is host-authored. `withTrustedAgentProvenance` runs on `chat.messageSession`, `chat.sendMessage` and `chat.steer` before any diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index cda015660..707ac2c59 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -24,7 +24,7 @@ for its separate RPC, sync, storage, and UI contracts. | `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 and chat auto-titling both run through the session-intelligence prompt path over the shared candidate chain in `sessionNaming.ts` (configured `titleModelId` → the model the chat was launched with → a model from another provider → a sibling on the leading provider), and only then fall back to a deterministic prompt-derived title/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. `buildAgentRuntimeEnv(managed)` stamps every SDK-backed provider process with `ADE_CHAT_SESSION_ID`, `ADE_DEFAULT_ROLE=agent` (or `orchestrator` for a lead), `ADE_LANE_ID`, `ADE_PROJECT_ROOT`, and `ADE_WORKSPACE_ROOT`; the persistent guidance also names the concrete `--session ` argument for status commands so shared SDK servers do not depend on process-global env inheritance. `dismissPendingInputForSettlement` is the provider-neutral quieting boundary used by **Dismiss & settle**: it interrupts live Claude/Codex/OpenCode/Cursor/Droid turns best-effort, cancels local/provider waiters, removes Codex plan follow-ups, emits pending-input resolution, and persists an idle session before settle is written. 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. Plan-mode transitions run through `claudePlanMode.ts` and emit a plan-mode notice carrying the resulting access mode, so the renderer composer chip updates from an authoritative value 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 interrupt, reset/dispose, a native subagent exit, 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`; an active Claude parent receives SDK `priority: "next"` delivery, an active Codex parent receives `turn/steer`, and idle or provider-fallback parents receive the normal message path, while scheduled work remains boundary-delivered (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. Fork/import history seeding (`appendImportedChatEvents`) is chunked with event-loop yields, defers transcript flushes to chunk boundaries, and never publishes seeded historical envelopes to live event subscribers — readers load them via history APIs; live-publishing an entire source chat froze the app during fork handoff (ADE-122). The `chat.handoffSession` / `chat.prepareCrossMachineHandoff` runtime actions carry extended timeouts (120s daemon action, 150s IPC) because a brief handoff spans AI-brief generation plus first-message dispatch — the old 30s default fired a false timeout while the daemon-side handoff completed anyway. For orchestrator-lead sessions it builds the read-only capability services (`buildOrchestrationLeadReadServices` → `searchWorkspace` / `readLinearIssue` / `readPr` / `listProofArtifacts` / `mintDeeplink`), wiring each only when the backing service exists so a null service degrades to an omitted tool rather than a crash. Large service file. | | `apps/desktop/src/main/services/chat/sessionNaming.ts` | Canonical home for everything the three naming callers share — automatic lane identity, chat auto-title, and the legacy lane-name suggestion — because each used to carry its own hand-copied chain that had already drifted. Owns the three system prompts and the lane-identity JSON schema, `MAX_NAMING_WORDS` (six words, handed to the model as a **guideline**: an over-long answer is clamped, never rejected, because a clamped real name beats a slug), `isProviderLevelNamingFailure` (a missing/unusable CLI, auth, quota, or an account that cannot run the model — including the "model is not supported when using X with a Y account" 400; it deliberately excludes "not supported for/on/by", which describes one model lacking a capability and must still retry a sibling), `buildNamingModelCandidates` (preferred ids → a model from a provider none of them belong to → a sibling on the leading provider, so a cross-provider candidate is always reachable), and `runNamingAcrossProviders` (walks the chain up to three attempts; a provider-level failure condemns every remaining model behind that provider, `run` returning null means "answered unusably" and the next candidate still gets a turn, and `shouldStop` abandons the chain when the user renames mid-flight). | -| `apps/desktop/src/main/services/chat/spawnMissionOwnership.ts` | The single statement of who a spawned child chat is currently working for, so the policy is written and tested in one place instead of inline in `reportChildSpawnEnded`. `isMissionDirective(userMessageEvent)` decides whether a persisted `user_message` *assigns* work (a human message, or one the host stamped `spawnDispatch`) or merely continues a mission already in flight (`scheduledWake`, `spawnCompletion`, `agentRelay`, `hostContinuation`, the legacy `kind: "continuity_recovery"`, an `orchestrationOrigin` whose `intent` is not `"directive"`, and any `deliveryState: "queued"` row whose delivered twin carries the authoritative metadata). `parentShouldWakeForChildTurn({ history, parentSessionId, turnId })` returns true when the parent dispatched the finished turn *or* still owns the mission at completion time; a queued row is excluded from the per-turn test because it carries the running turn's id. `HOST_AUTHORED_MESSAGE_PROVENANCE_KEYS` / `stripHostAuthoredMessageProvenance` export the same key list to every untrusted entry point (the ADE RPC edge, the automation action bridge) so provenance is always what the host observed, never what a caller asserted. | +| `apps/desktop/src/main/services/chat/spawnMissionOwnership.ts` | The single statement of who a spawned child chat is currently working for, so the policy is written and tested in one place instead of inline in `reportChildSpawnEnded`. Wake vs quiet is the child's persisted `spawnKind` (`subagent` always wakes; `peer` never does). `isHumanChildMessage` / `countHumanChildMessagesForTurn` / `formatHumanChildMessageAnnotation` name how many human messages landed in a finished turn so the next subagent wake can say `The user also sent N message(s) to this chat.` Parent dispatches, scheduled wakes, relays, host continuations, and any orchestration origin are not human messages. `HOST_AUTHORED_MESSAGE_PROVENANCE_KEYS` / `stripHostAuthoredMessageProvenance` export the same key list to every untrusted entry point (the ADE RPC edge, the automation action bridge) so provenance is always what the host observed, never what a caller asserted. | | `apps/desktop/src/main/services/chat/chatMentionService.ts` | Composer @-mention service (chats / lanes / terminals), created inside `agentChatService` with injected roster/transcript/PTY deps. Owns the keystroke-rate `chat.listMentionSuggestions` action (daemon-routed, read-only): one shared 1.5 s-TTL roster cache with a single in-flight promise collapses a typing burst into one sessions/lanes/terminals read, per-source failures degrade only their own menu section, and ranking/caps come from `shared/chatMentions.ts`. Also owns send-time expansion: `applyChatMentionExpansion` rewrites send/steer args so the provider receives `` pointer blocks (identity attributes, a ≤1 KB CRLF-normalized neutralized preview, and literal `ade chat read` / `ade lanes show` / `ade terminal read` / `ade search` commands — double-quoted-only so they paste into sh, PowerShell, and cmd) while `displayText` keeps the user's literal chips. Idempotence uses a module-private Symbol marker (structured clone strips it, so nothing over IPC/sync can pre-mark), the single expansion owner on the steer side is `steerWithOptions`, and slash-command prompt rewrites re-attach blocks via `carryChatMentionBlocks`. Lane details never derive git state from `lane.status` (lanes are listed without a status probe and the unprobed default is indistinguishable from clean). Fires the content-free `onMentionsExpanded` analytics hook once per send that actually gained blocks. | | `apps/desktop/src/shared/chatMentions.ts` | Pure, surface-agnostic mention grammar shared by desktop, TUI, web preview mock, and (future) iOS: `@chat:` / `@lane:` / `@term:` token parsing derived from one prefix table (`CHAT_MENTION_KINDS` is the canonical kind order), word-boundary matching so emails never match, `renderChatMentionBlock` (attribute escaping + preview truncation on line boundaries + neutralization of forged `` tags and block headers so another session's transcript text cannot inject fake pointer blocks), `rankChatMentionSuggestions` (exact > prefix > substring > subsequence, recency tie-break, deterministic id tie-break), and per-message caps (8/kind menu rows, 12 expansions, 1024-char previews). Types live in `shared/types/chatMentions.ts`. | | `apps/desktop/src/main/services/chat/claudePlanMode.ts` | Plan-mode transitions for Claude sessions, extracted from `agentChatService.ts` so the invariant is unit-testable. Entering plan mode sets `claudePermissionMode = "plan"` and stashes the suspended access mode in `claudePrePlanAccessMode` (persisted and rehydrated with the session); leaving restores it. `isSessionInPlanMode` is the single predicate the `ExitPlanMode` gate uses. Moving the access mode is what makes plan mode real: while it stayed on the pre-plan value, a `bypassPermissions` session read as bypass throughout, so the composer chip never left Bypass and the gate auto-approved the plan with no card. See [Agent Routing](agent-routing.md#interaction-mode). | @@ -123,6 +123,7 @@ for its separate RPC, sync, storage, and UI contracts. | `apps/desktop/src/renderer/components/chat/chatTurnState.ts` | Shared renderer turn-state invariant used by cache hydration, history snapshots, live event flushes, and locked-session summary refreshes. A terminal `status`/`done` at the end of the transcript outranks an eventually consistent `status: "active"` session summary, so failed/interrupted turns restore an idle composer. Also resolves the user message associated with a failed turn, including Codex optimistic user rows that predate assignment of a provider `turnId`. | | `apps/desktop/src/renderer/lib/claudeAuthPrompt.ts` | Renderer-side classifier for Claude logged-out / `/login`-required error text. Drives the header and sticky login CTAs; matches both Claude-first wording and ADE's own "Authentication failed for <model>" classified message. | | `apps/desktop/src/renderer/lib/openExternal.ts` | Renderer-side router for outbound URLs. Defines the `ADE_OPEN_BUILT_IN_BROWSER_EVENT` window event plus `openUrlInAdeBrowser(url)` and `openExternalUrl(url)`. `openUrlInAdeBrowser` dispatches the event (so any open `WorkSidebar` can flip to its Browser tab), then calls `window.ade.builtInBrowser.navigate({ url, newTab: true })`. Anything that is not a normal `http`/`https`/`about:blank` URL falls through to `window.ade.app.openExternal` (system browser). All in-renderer URL clicks (markdown links, lane-runtime open buttons, etc.) go through this helper so the user stays inside ADE. | +| `apps/desktop/src/renderer/components/chat/ChatSubagentTakeoverBanner.tsx` | Non-blocking composer banner on a subagent chat that still reports to its parent. **Take over** demotes to peer; **Keep reporting** and dismiss persist `subagentTakeoverPromptShownAt` without closing the report channel. Sending does not answer the prompt. | | `apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx`, `DraftMachinePicker.tsx`, `useDraftMachineRouting.ts`, `draftAttachmentTransfer.ts` | Composer UI and draft runtime routing: single-session prompt entry, attachments, model/permission controls, slash commands, pending input answering, parallel launch slot configuration, and inline smart-link chips. Running chats show a read-only amber tower plus their owning machine name beside the model and thinking controls; moving a chat is the explicit Chat actions → Handoff → Continue on another machine flow. Completed GitHub, Linear, ADE, and generic web URLs become atomic violet chips while their literal URL remains the serialized prompt text; click/keyboard actions offer Copy link and Remove link, hover exposes the canonical URL, and Backspace/Delete removes the whole token. During an active Claude turn, the split Send caret selects inline, after-turn, or interrupt delivery without sending; the primary button and Enter execute the chosen mode. Staged messages expose send-during-turn, interrupt, cancel, and Edit-back-to-composer actions. Permission popover rows keep only the mode title in the visible row (the explanation remains in the tooltip/title) for every provider-backed picker. Codex MCP elicitations show Allow once / Deny, conditionally show Always allow, and expose safe URL authorization through ADE's browser. Pasted, dropped, and native-path attachments are copied through `ade.agentChat.saveTempAttachment` on the draft's selected runtime, so a MacBook chat never receives a Studio-only path (and vice versa). The launch-prompt clipboard helper is gated separately from prompt copying: `launchPromptClipboardEnabled` controls copying and `launchPromptClipboardNoticeEnabled` controls whether composer reminder text is shown. Orchestration model-selection pending inputs decode the full agent briefing metadata (`workDescription`, `filesHint`, `dependsOn`) so the picker can show what the lead is spawning without preselecting a recommended model. The empty-draft launch shelf separates machine selection (`DraftMachinePicker`) from the lane list, scopes lanes to the chosen machine, and keeps Shell and Import beside the resulting target. It hides the machine control when there is only one choice and preserves Auto-create across machine changes. Attachment storage, model/auth discovery, slash commands, file search, parallel launch state, creation, rollback, and recovery all carry that captured `OpenProjectBinding`; unresolved or disconnected bindings fail closed instead of falling back to the tab's machine. `useDraftMachineRouting` restores the project/tab-selected machine before enabling the composer. When the user changes machines within the same draft scope, `draftAttachmentTransfer` copies pasted/local image bytes from the owning runtime into the newly selected runtime and rewrites their attachment paths; portable image URLs remain unchanged. Non-image files and iOS/App Control/built-in-browser visual context are removed because their paths and ownership cannot move safely. The composer blocks sends while a copy is pending, and a failed copy keeps the source images visible but blocks sending until the user switches back or removes them. A project-tab scope change establishes the restored machine as the attachment owner instead of treating tab hydration as a user-requested transfer. The **This computer** option resolves to *this repository's* local checkout through `thisMachineProjectRoot.ts` rather than to the first open local tab; when no matching local checkout exists, the composer shows an inline dismissible amber notice. | | `apps/desktop/src/renderer/components/chat/ComposerPromptStash.tsx` | Desktop prompt-stash control mounted immediately left of the context meter. Cmd/Ctrl+S and the bookmark share one path: non-empty text is persisted before the exact saved draft is cleared, while an empty draft opens the keyboard-navigable stash menu. Restore is a take operation, but it puts text into the composer before waiting for a remote delete so edits cannot be overwritten; delete failure intentionally favors a duplicate over lost text. Attachments and context items never enter the stash. | | `apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortPicker.tsx` | Shared reasoning slider. Supports pointer drag with nearest-tick snap, keyboard arrows/Home/End, a progressive filled gradient, and a directional roll transition for the active tier label, GPT-5.6 labels (Light, Medium, High, Extra High, Max, and Ultra where supported), and an Ultra multi-agent usage note. The collapsed trigger uses full tier names on desktop, keeps abbreviations for narrow/mobile layouts, and does not add a second border around the label. Choosing or dragging to a tier leaves the popover open; outside click or Escape closes it. | @@ -1156,11 +1157,9 @@ Where it is set: When any spawned chat child completes a turn, `reportChildSpawnEnded` in `agentChatService.ts` derives the parent from persisted lineage and reports -based on the child's type and who currently owns the child's mission -(`parentShouldWakeForChildTurn` in `spawnMissionOwnership.ts` — see -[Mission ownership](#mission-ownership-decides-the-wake) below): +based on the child's current `spawnKind`: -- **`subagent` turn the parent owns** — ADE reports completion through its trusted +- **`subagent`** — ADE always wakes the parent through its trusted `messageSession({ kind: "wake", metadata: { spawnCompletion } })` path. If the parent is active, Claude receives the completion inline as SDK `priority: "next"` and Codex receives `turn/steer`; the delivery is allowed @@ -1168,19 +1167,30 @@ based on the child's type and who currently owns the child's mission provider-normalized `steer()` fallback, which may queue at their safe boundary; an idle parent uses the normal message path. The message carries a typed `AgentChatSpawnCompletion` (`childSessionId`, `childTitle`, - `childTurnId`, `spawnKind`, `status`, and the latest bounded assistant - `summary`), and the renderer derives a navigable + `childTurnId`, `spawnKind`, `status`, the latest bounded assistant + `summary`, and `humanMessageCount` when the user also messaged the child + during that turn). The renderer derives a navigable `spawn_wake_divider` labeled **Subagent returned** whether the completion joined an active turn or started idle work. This is intentionally distinct from scheduled wakes, which remain deferred to a safe turn boundary. -- **`subagent` turn a human owns** — leaves the same quiet completion note - as a peer turn because the human already owns that interaction; it does not - start an unsolicited parent turn. Ownership returns to the parent the next - time the parent dispatches. - **`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. Peers never wake, so they never pay for the ownership transcript read. + +`spawnKind` is mutable. Taking over a subagent (desktop/iOS banner, context +menu, `ade chat demote`, `/session demote`) sets `spawnKind = "peer"` and +posts a quiet parent notice `status: "spawn_takeover"`: +`The user took over "‹child title›" — reports stop here.` Promoting +(`ade chat promote`, `/session promote`) restores `subagent` when the parent +chat still exists. A later parent `spawnDispatch` into a peer child +auto-promotes it back to `subagent` without a takeover note. + +A human message to a subagent does **not** steal the report channel. The next +wake appends `The user also sent N message(s) to this chat.` so the parent can +read the transcript before following up. The takeover banner is shown once +(`subagentTakeoverPromptShownAt` on the child); sending does not dismiss it. + There is no new silent spawn type. Delivery retries three times. A final failure is logged as `agent_chat.spawn_completion_delivery_failed` and emits a visible warning in the child with the direct-report recovery command. Per-turn dedupe @@ -1193,32 +1203,24 @@ indistinguishable from a child that never finished. #### Mission ownership decides the wake -A completion wakes the parent when the parent dispatched that specific turn *or* -still owns the child's mission — the most recent directive-class user message in -the child was parent-dispatched. Long missions are mostly driven by the child's -own scheduled wakeups (an ADE ship loop polling CI, say), so strict per-turn -attribution left the parent unnotified on exactly the turn that delivered the -result. +Wake vs quiet is the child's persisted `spawnKind`, not the latest human +message. A plain human message does not close the report channel. +`isHumanChildMessage` in `spawnMissionOwnership.ts` counts those messages for +the next wake. -Non-directive inputs continue a mission rather than reassigning it, and never -take ownership: `scheduledWake` (the child's own scheduler), `spawnCompletion` +Host-authored inputs that are not human messages: +`scheduledWake` (the child's own scheduler), `spawnCompletion` (a result from the child's own grandchild), `agentRelay` (any other bound agent messaging the child), `hostContinuation` (ADE prompting the chat to resume or repair its own work — plan follow-ups, interrupted-turn recovery, provider schedule cleanup, the CTO intro seed, and continuity recovery, which older -transcripts carry as `kind: "continuity_recovery"`), an `orchestrationOrigin` -whose `intent` is not `"directive"` (status, diff notices, questions, -cancellations inside a run), and a `deliveryState: "queued"` row whose delivered -twin carries the authoritative metadata. A direct human message is a directive -and moves ownership to the human; a handoff prompt is also a directive because -it carries a human's continuation intent. Ownership is read once at completion -time, before the delivery retries, so a retry never re-decides it, and ADE keeps -no per-schedule provenance. - -The per-turn half of the test asks whether the finished turn *contains* a parent -dispatch, not whether the parent wrote the last message — an inline steer joins -the running turn and reuses its id — and it skips queued rows, which carry the -running turn's id rather than one they started. +transcripts carry as `kind: "continuity_recovery"`), any `orchestrationOrigin`, +and a `deliveryState: "queued"` row whose delivered twin carries the +authoritative metadata. A parent `spawnDispatch` auto-promotes a peer child +back to subagent; it is not counted as a human message. A human message on a +subagent is counted for the next wake's +`The user also sent N message(s) to this chat.` annotation instead of stealing +the channel. All of this provenance is host-authored and never accepted from a caller. The ADE RPC edge runs `withTrustedAgentProvenance` on `chat.messageSession`, @@ -1271,7 +1273,13 @@ Work sidebar can therefore render, without any extra fetch: `AgentChatPane` renders a type-tinted **View parent thread** header button for a spawned chat. The tooltip names the parent when its title is available, and the button is the keyboard/assistive-technology route back to the parent. -Spawned-chat rows in `ChatSubagentsPanel` derive an explicit `childSessionId` +A subagent that has not yet answered the takeover prompt also shows a +non-blocking composer banner: **Take over** / **Keep reporting**. Take over +demotes to peer; dismiss and Keep reporting persist +`subagentTakeoverPromptShownAt` without changing `spawnKind`. The Work session +context menu (and the iOS long-press menu) offer **Demote to peer** / +**Promote to subagent** for the same write. Spawned-chat rows in +`ChatSubagentsPanel` derive an explicit `childSessionId` and navigate to it directly. Their labels prefer the live child title threaded from `WorkViewArea`; preserving the `chat:` task id and `spawnKind` through the canonical dotted/underscore event twin keeps the row navigable regardless of @@ -1307,7 +1315,7 @@ handlers live in `apps/desktop/src/main/services/ipc/registerIpc.ts`. | `ade.agentChat.approve` | invoke | Legacy approval channel (pre-pending-input). | | `ade.agentChat.respondToInput` | invoke | Unified pending-input answer channel, including Codex MCP elicitation form values and metadata-gated persistent consent. | | `ade.agentChat.delete` | invoke | Permanently remove a chat session: first waits for current-session Claude jobs to confirm provider cancellation, locally tombstones jobs whose earlier provider owner is unreachable, then disposes the runtime if still running, cancels any pending turn collector, resolves outstanding input waiters, removes the persisted JSON + transcript, and deletes the `terminal_sessions` row. A current-provider timeout leaves the chat unchanged. Archiving uses the same cancellation gate. | -| `ade.agentChat.updateSession` | invoke | Mutate permission modes, `manuallyNamed`, capability mode, the legacy-named `codexFastMode` Fast Mode toggle, and Claude SDK session title/tag metadata. An empty tag clears the SDK tag. | +| `ade.agentChat.updateSession` | invoke | Mutate permission modes, `manuallyNamed`, capability mode, the legacy-named `codexFastMode` Fast Mode toggle, Claude SDK session title/tag metadata, `spawnKind` (`subagent` / `peer`), and `subagentTakeoverPromptShown`. An empty tag clears the SDK tag. Demoting to peer posts a quiet takeover note on the parent. | | `ade.agentChat.codex.goal.get` / `.set` / `.setStatus` / `.clear` | invoke | Codex-only IPC channels behind the preload API `window.ade.agentChat.codex.getGoal` / `.setGoal` / `.setGoalStatus` / `.clearGoal`. They call the app-server goal RPCs directly instead of sending `/goal` prompt text through the chat, preserve CLI/PTY sessions, validate objective length, persist goal state into session summaries, and keep ADE goals unlimited by clearing provider token budgets. | | `ade.agentChat.warmupModel` | invoke | Preload a Claude SDK runtime for an eventual turn. | | `ade.agentChat.slashCommands` | invoke | List provider + local slash commands. | diff --git a/docs/logging.md b/docs/logging.md index d204d0f2c..c71603845 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -45,7 +45,10 @@ wake. Write it for every completion, including the quiet ones: a parent that was never woken is otherwise indistinguishable in the logs from a child that never finished, which is how the original mis-attribution went unnoticed. A final delivery failure keeps its own -`agent_chat.spawn_completion_delivery_failed` line. Neither is a PostHog event. +`agent_chat.spawn_completion_delivery_failed` line. Explicit take over / promote +writes `agent_chat.spawn_kind_changed` with `sessionId`, `parentSessionId`, +`previousSpawnKind`, `spawnKind`, and `source` (`takeover`, `promote`, or +`parent_dispatch`). None of these spawn-coordination lines is a PostHog event. Product analytics records a small number of meaningful product facts such as "an anonymous installation opened the Work screen" or "a chat session started." It must never inherit arbitrary fields from a log record, exception, IPC payload, database row, or UI component props. Log calls and product-analytics calls should remain separate at the call site.