Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/ade-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <lane-id> 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
Expand Down
16 changes: 14 additions & 2 deletions apps/ade-cli/src/adeRpcServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2668,16 +2668,25 @@ const SCOPED_CHAT_ACTIONS = new Set([
"interrupt",
"interruptWithQueueMode",
"restoreCancelledQueue",
"setSpawnKind",
"dismissSubagentTakeoverPrompt",
]);

function chatUpdateSessionMutatesSpawnKind(chatArgs: Record<string, unknown>): boolean {
return chatArgs.spawnKind === "subagent"
|| chatArgs.spawnKind === "peer"
|| chatArgs.subagentTakeoverPromptShown === true;
}

function scopeChatAdeActionArgs(
session: SessionState,
action: string,
chatArgs: Record<string, unknown>,
domain: "chat" | "session" = "chat",
): Record<string, unknown> {
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 };
Expand Down Expand Up @@ -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,
Expand Down
44 changes: 44 additions & 0 deletions apps/ade-cli/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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"],
Expand Down
72 changes: 66 additions & 6 deletions apps/ade-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1940,6 +1940,9 @@ const HELP_BY_COMMAND: Record<string, string> = {
Detach one issue (or all) from a session
$ ade chat linear-issues <session> --text List issues attached to a session
$ ade chat interrupt <session> Stop an active turn and clear its queued messages
$ ade chat demote <session> Take over a subagent: it becomes a peer and reports stop
$ ade chat promote <session> Restore a peer as a subagent so it reports to its parent again
$ ade chat keep-reporting <session> Dismiss the takeover prompt without changing the report channel
$ ade chat interrupt <session> --keep-queue Stop the turn but preserve queued messages
$ ade chat restore-queue <session> <recovery> Restore a recently cleared queue during its undo window
$ ade chat slash <session> --text List slash commands for a session
Expand All @@ -1961,9 +1964,8 @@ const HELP_BY_COMMAND: Record<string, string> = {
--parent <sessionId> 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 <subagent|peer> Required with a parent. subagent wakes the parent
after every turn while the parent owns the mission;
peer leaves quiet notes.
--type <subagent|peer> Required with a parent. subagent always wakes the
parent after every turn; peer leaves quiet notes.

Transcript read flags:
--limit <n> Messages per bounded window (default 50, max 100).
Expand Down Expand Up @@ -2017,9 +2019,8 @@ const HELP_BY_COMMAND: Record<string, string> = {
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 <subagent|peer> Required with a parent. subagent wakes the parent
after every turn while the parent owns the mission;
peer leaves quiet notes.
--type <subagent|peer> 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.
Expand Down Expand Up @@ -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}`,
Expand Down
2 changes: 2 additions & 0 deletions apps/ade-cli/src/services/sync/syncHostService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
33 changes: 33 additions & 0 deletions apps/ade-cli/src/services/sync/syncRemoteCommandService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ import type {
AgentChatRecoverTurnArgs,
AgentChatResolveUnprocessedMessageArgs,
AgentChatUpdateSessionArgs,
AgentChatSetSpawnKindArgs,
AgentChatDismissSubagentTakeoverPromptArgs,
AddGitHubPrStackPullRequestsArgs,
AddPrCommentArgs,
AiReviewSummaryArgs,
Expand Down Expand Up @@ -2587,9 +2589,34 @@ function parseAgentChatUpdateSessionArgs(value: Record<string, unknown>): 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<string, unknown>): 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<string, unknown>,
): AgentChatDismissSubagentTakeoverPromptArgs {
return {
sessionId: requireString(value.sessionId, "chat.dismissSubagentTakeoverPrompt requires sessionId."),
};
}

function parseAgentChatCodexGetGoalArgs(value: Record<string, unknown>): AgentChatCodexGetGoalArgs {
return {
sessionId: requireString(value.sessionId, "chat.getCodexGoal requires sessionId."),
Expand Down Expand Up @@ -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) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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",
Expand Down
11 changes: 11 additions & 0 deletions apps/ade-cli/src/tuiClient/adeApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1049,6 +1049,17 @@ export async function tagChat(connection: AdeCodeConnection, sessionId: string,
});
}

export async function setChatSpawnKind(
connection: AdeCodeConnection,
sessionId: string,
spawnKind: "subagent" | "peer",
): Promise<AgentChatSession> {
return await connection.action("chat", "setSpawnKind", {
sessionId,
spawnKind,
});
}

export async function updateChatModel(args: {
connection: AdeCodeConnection;
sessionId: string;
Expand Down
14 changes: 13 additions & 1 deletion apps/ade-cli/src/tuiClient/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ import {
saveRuntimeTempAttachment,
sendChatMessage,
sendToTerminalSession,
setChatSpawnKind,
signalTerminal,
setClaudeOutputStyle,
setSessionStatusNote,
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -11129,14 +11132,23 @@ 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
// in the active list. Nothing DERIVES a settle — a clean process
// 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) {
Expand Down
4 changes: 3 additions & 1 deletion apps/ade-cli/src/tuiClient/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<snooze|wake|settle|unsettle|keep-active>", category: "Chats" },
{ name: "/session", description: "Run a session lifecycle command", placement: "right", argumentHint: "<snooze|wake|settle|unsettle|keep-active|demote|promote>", 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: "<tag|clear>", 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" },
Expand Down
6 changes: 5 additions & 1 deletion apps/ade-cli/src/tuiClient/sessionLifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -46,6 +48,8 @@ export const SESSION_LIFECYCLE_COMMAND_BY_NAME: Readonly<Record<string, SessionL
"/session settle": "settle",
"/session unsettle": "unsettle",
"/session keep-active": "keep-active",
"/session demote": "demote",
"/session promote": "promote",
};

export function sessionLifecycleCommandFor(name: string): SessionLifecycleCommand | null {
Expand Down
Loading
Loading