Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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: 2 additions & 1 deletion apps/ade-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,8 @@ ade terminal resume --terminal session-id --text
ade new chat --mode chat --lane lane-id --provider codex --model openai/gpt-5.6-sol --reasoning-effort xhigh --no-fast --permissions full-auto --prompt "fix failing tests"
ade new chat --mode cli --lane lane-id --provider codex --model openai/gpt-5.6-sol --reasoning-effort xhigh --no-fast --permissions full-auto --prompt "fix failing tests"
ade new chat --mode chat --lane auto --lane-name fix-checkout-flow --prompt "fix failing tests"
ade new chat --mode chat --lane lane-id --type subagent --prompt "repro the flake" # --type subagent|peer|none (chat mode only): cosmetic relationship + completion-report policy — subagent wakes the parent on completion, peer leaves a quiet note, none (default) is silent; a typed agent is still a full agent
ade new chat --mode chat --lane lane-id --type subagent --prompt "repro the flake" # --type subagent|peer|none: cosmetic relationship + completion-report policy — subagent wakes the parent on completion, peer leaves a quiet note, none (default) is silent; a typed agent is still a full agent
ade new chat --mode cli --lane lane-id --provider codex --type peer --parent chat-session-id --prompt "review the diff" # agent-provider CLI sessions record spawn lineage without becoming attached terminals; shell sessions do not record lineage
ade chat list --lane lane-id --include-automation --no-archived --text
ade chat create --lane lane-id --provider codex --model openai/gpt-5.6-sol --permissions full-auto --print-config --json
ade chat create --lane lane-id --provider codex --no-parent # spawned chats default their parent to $ADE_CHAT_SESSION_ID; --parent <session> overrides, --no-parent opts out
Expand Down
51 changes: 51 additions & 0 deletions apps/ade-cli/src/adeRpcServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1763,6 +1763,57 @@ describe("adeRpcServer", () => {
});
});

it("persists CLI spawn lineage in resume metadata without assigning terminal ownership", async () => {
const fixture = createRuntime();
fixture.runtime.sessionService.get.mockReturnValue({
id: "session-1",
laneId: "lane-1",
ptyId: "pty-1",
tracked: true,
toolType: "codex",
title: "Codex",
status: "running",
resumeCommand: null,
resumeMetadata: {
provider: "codex",
targetKind: "thread",
targetId: null,
launch: { permissionMode: "default" },
orchestrationParentSessionId: "parent-session-1",
spawnKind: "peer",
},
chatSessionId: null,
orchestrationParentSessionId: "parent-session-1",
spawnKind: "peer",
});
const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" });

await initialize(handler, { role: "orchestrator" });
const response = await callTool(handler, "start_cli_session", {
laneId: "lane-1",
provider: "codex",
orchestrationParentSessionId: " parent-session-1 ",
spawnKind: "peer",
});

expect(response?.isError).toBeUndefined();
const createCall = fixture.runtime.ptyService.create.mock.calls.at(-1)?.[0];
expect(createCall).toEqual(expect.objectContaining({
resumeMetadata: expect.objectContaining({
provider: "codex",
targetKind: "thread",
orchestrationParentSessionId: "parent-session-1",
spawnKind: "peer",
}),
}));
expect(createCall).not.toHaveProperty("chatSessionId");
expect(response.structuredContent.session).toEqual(expect.objectContaining({
orchestrationParentSessionId: "parent-session-1",
spawnKind: "peer",
chatSessionId: null,
}));
});

it("preserves wide start_cli_session terminal dimensions", async () => {
const fixture = createRuntime();
fixture.runtime.sessionService.get.mockReturnValue({
Expand Down
35 changes: 33 additions & 2 deletions apps/ade-cli/src/adeRpcServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ import {
type LaunchProfile,
type TrackedCliLaunchCommand,
} from "../../desktop/src/shared/cliLaunch";
import type { AgentChatPermissionMode, TerminalSessionSummary } from "../../desktop/src/shared/types";
import type { AgentChatPermissionMode, AgentChatSpawnKind, TerminalResumeMetadata, TerminalSessionSummary } from "../../desktop/src/shared/types";
import type { AdeRuntime } from "./bootstrap";
import {
recordUsageInteraction,
Expand All @@ -72,6 +72,7 @@ import { getSharedModelPickerStore } from "./services/modelPickerStore";
import { resolveLaneCreateRemoteBase } from "./services/laneCreateRemoteBase";
import { BUILT_IN_BROWSER_ACTOR_CAPABILITY_PARAM } from "./services/builtInBrowser/desktopBridgeMethods";
import { resolveCodexComputerUseMcpConfig } from "../../desktop/src/main/utils/codexComputerUse";
import { parseTrackedCliLaunchConfig } from "../../desktop/src/main/utils/terminalSessionSignals";

// Cross-surface (desktop + TUI + iOS) model picker favorites & recents.
// Backed by the per-project cr-sqlite CRR DB (runtime.db) so the three surfaces
Expand Down Expand Up @@ -337,6 +338,8 @@ const TOOL_SPECS: ToolSpec[] = [
codexFastMode: { type: "boolean", deprecated: true },
cwd: { type: "string" },
chatSessionId: { type: "string" },
orchestrationParentSessionId: { type: "string", minLength: 1 },
spawnKind: { type: "string", enum: ["subagent", "peer", "none"] },
tracked: { type: "boolean", default: true }
}
}
Expand Down Expand Up @@ -1708,6 +1711,18 @@ function parseCliSessionPermissionMode(value: unknown): AgentChatPermissionMode
);
}

function parseCliSessionSpawnKind(value: unknown): AgentChatSpawnKind | null {
if (value == null) return null;
const spawnKind = asTrimmedString(value).toLowerCase();
if (spawnKind === "subagent" || spawnKind === "peer" || spawnKind === "none") {
return spawnKind;
}
throw new JsonRpcError(
JsonRpcErrorCode.invalidParams,
"spawnKind must be one of subagent, peer, or none",
);
}

function clampInteger(value: unknown, fallback: number, min: number, max: number): number {
const raw = typeof value === "number" && Number.isFinite(value) ? value : fallback;
return Math.max(min, Math.min(max, Math.floor(raw)));
Expand Down Expand Up @@ -3589,6 +3604,10 @@ async function runTool(args: {
const laneId = assertNonEmptyString(toolArgs.laneId, "laneId");
const provider = parseCliSessionProvider(toolArgs.provider);
const permissionMode = parseCliSessionPermissionMode(toolArgs.permissionMode);
const orchestrationParentSessionId = toolArgs.orchestrationParentSessionId == null
? null
: assertNonEmptyString(toolArgs.orchestrationParentSessionId, "orchestrationParentSessionId");
const spawnKind = parseCliSessionSpawnKind(toolArgs.spawnKind);
try {
validateLaunchProfilePermissionMode(provider, permissionMode);
} catch (err) {
Expand Down Expand Up @@ -3634,6 +3653,17 @@ async function runTool(args: {
...(provider === "codex" ? { codexComputerUse } : {}),
});
})();
const toolType = LAUNCH_PROFILE_TOOL_TYPE[provider];
const resumeMetadata: TerminalResumeMetadata | null = isCliProvider(provider)
? {
provider,
targetKind: provider === "codex" ? "thread" : "session",
targetId: provider === "claude" ? preassignedSessionId ?? null : null,
launch: parseTrackedCliLaunchConfig(launchFields.startupCommand ?? "", toolType) ?? {},
...(orchestrationParentSessionId ? { orchestrationParentSessionId } : {}),
...(spawnKind ? { spawnKind } : {}),
Comment on lines +3663 to +3664

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Forward CLI spawn parent into the launched environment

When ade new chat --mode cli --type subagent|peer records a parent here, the metadata is persisted but never reaches the spawned CLI process environment: ptyService builds env only from chatSessionId/ownerSessionId, so the child gets ADE_CHAT_SESSION_ID set to its own terminal id and no ADE_PARENT_CHAT_SESSION_ID/ADE_SPAWN_KIND. In that spawned-CLI scenario, any follow-up ade new chat or self-report defaults to the terminal itself instead of the real parent chat, so the documented subagent/peer reporting lineage is lost even though the sidebar shows it.

Useful? React with 👍 / 👎.

}
: null;

const created = await ptyService.create({
...(preassignedSessionId ? { sessionId: preassignedSessionId } : {}),
Expand All @@ -3643,7 +3673,8 @@ async function runTool(args: {
rows,
title,
tracked: toolArgs.tracked !== false,
toolType: LAUNCH_PROFILE_TOOL_TYPE[provider],
toolType,
...(resumeMetadata ? { resumeMetadata } : {}),
...(asOptionalTrimmedString(toolArgs.cwd) ? { cwd: asOptionalTrimmedString(toolArgs.cwd)! } : {}),
...(asOptionalTrimmedString(toolArgs.chatSessionId) ? { chatSessionId: asOptionalTrimmedString(toolArgs.chatSessionId) } : {}),
...launchFields,
Expand Down
59 changes: 59 additions & 0 deletions apps/ade-cli/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2442,6 +2442,56 @@ describe("ADE CLI", () => {
expect((staticPlan.value as { launch: Record<string, unknown> }).launch.orchestrationParentSessionId)
.toBe("parent-session-1");
});

it("ade new chat --mode cli records the env parent as spawn lineage without terminal ownership", () => {
process.env.ADE_CHAT_SESSION_ID = "parent-session-1";
const plan = buildCliPlan([
"new", "chat",
"--mode", "cli",
"--lane", "lane-1",
"--provider", "codex",
"--type", "peer",
"--print-config",
]);
const staticPlan = expectStaticPlan(plan);
const launch = (staticPlan.value as { launch: Record<string, unknown> }).launch;
expect(launch.orchestrationParentSessionId).toBe("parent-session-1");
expect(launch.spawnKind).toBe("peer");
expect(launch).not.toHaveProperty("chatSessionId");
});

it("ade new chat --mode cli --no-parent leaves the terminal unlinked", () => {
process.env.ADE_CHAT_SESSION_ID = "parent-session-1";
const plan = buildCliPlan([
"new", "chat",
"--mode", "cli",
"--lane", "lane-1",
"--provider", "codex",
"--no-parent",
"--print-config",
]);
const staticPlan = expectStaticPlan(plan);
const launch = (staticPlan.value as { launch: Record<string, unknown> }).launch;
expect(launch).not.toHaveProperty("orchestrationParentSessionId");
expect(launch).not.toHaveProperty("chatSessionId");
});

it("does not record spawn lineage for plain shell terminals", () => {
process.env.ADE_CHAT_SESSION_ID = "parent-session-1";
const plan = buildCliPlan([
"new", "chat",
"--mode", "cli",
"--lane", "lane-1",
"--provider", "shell",
"--type", "peer",
"--print-config",
]);
const staticPlan = expectStaticPlan(plan);
const launch = (staticPlan.value as { launch: Record<string, unknown> }).launch;
expect(launch.provider).toBe("shell");
expect(launch).not.toHaveProperty("orchestrationParentSessionId");
expect(launch).not.toHaveProperty("spawnKind");
});
});

it("prints chat create --prompt dry-run with the follow-up send", () => {
Expand Down Expand Up @@ -5680,6 +5730,15 @@ describe("ADE CLI", () => {
expect(newChatHelp.kind).toBe("help");
if (newChatHelp.kind !== "help") return;
expect(newChatHelp.text).toContain("--type <subagent|peer|none>");
expect(newChatHelp.text).toContain("Override with --parent <sessionId>");
expect(newChatHelp.text).toContain("plain shell terminals don't record lineage");

const newHelp = buildCliPlan(["new", "--help"]);
expect(newHelp.kind).toBe("help");
if (newHelp.kind !== "help") return;
expect(newHelp.text).toContain("--type <subagent|peer|none>");
expect(newHelp.text).toContain("--parent <sessionId>");
expect(newHelp.text).toContain("Plain shell terminals do not record lineage");

const laneCommandHelp = buildCliPlan(["help", "lanes"]);
expect(laneCommandHelp.kind).toBe("help");
Expand Down
36 changes: 26 additions & 10 deletions apps/ade-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1378,6 +1378,12 @@ const HELP_BY_COMMAND: Record<string, string> = {
--no-fast, --standard Disable fast service tier explicitly.
--prompt <text> First chat message or CLI initial input.

Spawn lineage:
In tracked agent shells, chat mode and agent-provider CLI mode default
their parent to $ADE_CHAT_SESSION_ID. Use --parent <sessionId> to override
it or --no-parent to opt out. CLI terminals record lineage without taking
attached-terminal ownership. Plain shell terminals do not record lineage.

Compatibility:
ade chat create still creates persistent Work chats.
ade shell start-cli still starts tracked provider CLI sessions.
Expand All @@ -1397,6 +1403,12 @@ const HELP_BY_COMMAND: Record<string, string> = {
The command defaults to the current ADE lane when ADE_LANE_ID is set. Use
--auto-create-lane or --lane auto to create a lane before launching.
--type <subagent|peer|none> sets only the cosmetic relationship and completion-report policy; a typed agent is a full agent. subagent wakes the parent, peer leaves a quiet note, and none (default) sends no report.

Spawn lineage: when run from a tracked agent shell (ADE_CHAT_SESSION_ID set),
the new session links back to the spawning chat. In CLI mode, the terminal
shows that spawn lineage on its own sidebar card and links back to the chat
(agent providers only — plain shell terminals don't record lineage).
Override with --parent <sessionId>; opt out with --no-parent.
`,
lanes: `${ADE_BANNER}
Lanes
Expand Down Expand Up @@ -2535,12 +2547,12 @@ function readLaneId(args: string[]): string | null {
}

/**
* Parent chat-session lineage for spawned chat sessions. Defaults to the
* spawning agent's own session — ADE injects ADE_CHAT_SESSION_ID into every
* tracked agent shell (chat runtimes and tracked CLI/PTY sessions) — so a
* child created via `ade chat create` links back to the chat that spawned it
* instead of becoming an orphan. `--parent <sessionId>` overrides the
* default; `--no-parent` opts out entirely.
* Parent chat-session lineage for spawned chat and agent-provider CLI
* sessions. Defaults to the spawning agent's own session — ADE injects
* ADE_CHAT_SESSION_ID into every tracked agent shell (chat runtimes and
* tracked CLI/PTY sessions). `--parent <sessionId>` overrides the default;
* `--no-parent` opts out entirely. CLI callers keep chatSessionId separate
* because that field represents attached-terminal ownership, not lineage.
*/
function readParentSessionId(args: string[]): string | undefined {
const override = readValue(args, ["--parent", "--parent-session", "--parent-session-id"]);
Expand Down Expand Up @@ -4184,7 +4196,7 @@ function buildNewChatPlan(args: string[], defaultMode: "chat" | "cli"): CliPlan
const reasoningEffort = readValue(args, ["--reasoning-effort", "--effort", "--reasoning"]);
const permissionMode = readValue(args, ["--permission-mode", "--permissions"]);
const spawnTypeArg = readValue(args, ["--type", "--spawn-type"]);
const spawnKind = mode === "chat" ? spawnTypeArg?.trim().toLowerCase() : undefined;
const spawnKind = spawnTypeArg?.trim().toLowerCase();
const fastMode = readFastModeFlag(args);
const title = readValue(args, ["--title"]);
const printConfig = readFlag(args, ["--print-config", "--dry-run"]);
Expand Down Expand Up @@ -4218,10 +4230,10 @@ function buildNewChatPlan(args: string[], defaultMode: "chat" | "cli"): CliPlan
};

// Consume the flags in both modes so they never leak into the generic arg
// bag; lineage only applies to chat mode (a PTY session is not a chat
// record, so there is nothing to link).
// bag. Both chat and CLI modes record the same spawn lineage fields; CLI
// sessions keep chatSessionId free for true attached-terminal ownership.
const parentSessionId = readParentSessionId(args);
const orchestrationParentSessionId = mode === "chat" ? parentSessionId : undefined;
const orchestrationParentSessionId = parentSessionId;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve --parent for CLI auto-lane spawns

When ade new chat --mode cli --auto-create-lane --parent <sessionId> is used, the resolveNewChatLaneArgs call above has already consumed --parent as the new lane's parentLaneId before readParentSessionId runs here. The CLI lineage path added in this commit therefore falls back to $ADE_CHAT_SESSION_ID/undefined and can also pass a chat session id to create_lane as a lane parent, so the documented explicit parent override for auto-lane CLI spawns either fails lane creation or creates an unlinked child. Read the lineage parent before lane parsing or keep --parent lane-only and require the parent-session aliases for lineage.

Useful? React with 👍 / 👎.

const launchArgs = mode === "chat"
? collectGenericObjectArgs(args, {
provider,
Expand Down Expand Up @@ -4249,6 +4261,10 @@ function buildNewChatPlan(args: string[], defaultMode: "chat" | "cli"): CliPlan
modelId: modelArg,
reasoningEffort,
...(fastMode !== undefined ? { fastMode, codexFastMode: fastMode } : {}),
// Spawn lineage rides on resume metadata, which only agent providers
// have — plain shell terminals can't persist it, so don't pretend.
...(provider !== "shell" && orchestrationParentSessionId ? { orchestrationParentSessionId } : {}),
...(provider !== "shell" && spawnKind ? { spawnKind } : {}),
cols: readIntOption(args, ["--cols"], 120),
rows: readIntOption(args, ["--rows"], 36),
cwd: readValue(args, ["--cwd"]),
Expand Down
74 changes: 74 additions & 0 deletions apps/ade-cli/src/tuiClient/__tests__/Drawer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,25 @@ describe("Drawer closed CLI sessions", () => {
});
});

it("projects spawn lineage from tracked CLI resume metadata", () => {
const summary = terminalSessionToChatSummary(terminal({
terminalId: "cli-subagent",
resumeMetadata: {
provider: "codex",
targetKind: "session",
targetId: "cli-subagent",
launch: {},
orchestrationParentSessionId: "parent-chat",
spawnKind: "subagent",
},
}));

expect(summary).toMatchObject({
orchestrationParentSessionId: "parent-chat",
spawnKind: "subagent",
});
});

it.each([
["failed status", { status: "failed", exitCode: 1, runtimeState: "killed" }, "failed"],
["non-zero exit", { status: "completed", exitCode: 2, runtimeState: "exited" }, "failed"],
Expand Down Expand Up @@ -172,6 +191,61 @@ describe("Drawer closed CLI sessions", () => {
});

describe("Drawer lane and chat navigation layout", () => {
it("renders compact spawn-kind markers for chat and tracked CLI rows", () => {
const chat: AgentChatSessionSummary = {
sessionId: "chat-subagent",
laneId: "lane-1",
provider: "claude",
model: "claude-code",
title: "Chat child",
status: "idle",
startedAt: "2026-05-12T11:30:00.000Z",
endedAt: null,
lastActivityAt: "2026-05-12T11:31:00.000Z",
lastOutputPreview: null,
summary: null,
nextWakeAt: null,
orchestrationParentSessionId: "parent-chat",
spawnKind: "subagent",
};
const cli = terminalSessionToChatSummary(terminal({
terminalId: "cli-peer",
resumeMetadata: {
provider: "codex",
targetKind: "session",
targetId: "cli-peer",
launch: {},
orchestrationParentSessionId: "parent-chat",
spawnKind: "peer",
},
}));

const baseProps = {
lanes: [lane("lane-1", "Feature", "feature/spawn-kind", "2026-05-12T11:55:00.000Z")],
activeLaneId: "lane-1",
activeSessionId: null,
browsingLaneId: "lane-1",
selectedLaneIndex: 0,
selectedChatIndex: -1,
panelHeight: 30,
};
const chatFrame = stripAnsi(render(
<Drawer
{...baseProps}
sessions={[chat]}
/>,
).lastFrame() ?? "");
const cliFrame = stripAnsi(render(
<Drawer
{...baseProps}
sessions={[cli]}
/>,
).lastFrame() ?? "");

expect(chatFrame).toContain("Chat child sub");
expect(cliFrame).toContain("Codex CLI peer");
});

it("puts the primary lane first and removes old header/footer controls", () => {
const frame = stripAnsi(render(
<Drawer
Expand Down
1 change: 1 addition & 0 deletions apps/ade-cli/src/tuiClient/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -890,6 +890,7 @@ export function chatSessionToOptimisticSummary(
...(session.orchestrationRunId ? { orchestrationRunId: session.orchestrationRunId } : {}),
...(session.orchestrationRole ? { orchestrationRole: session.orchestrationRole } : {}),
...(session.orchestrationParentSessionId ? { orchestrationParentSessionId: session.orchestrationParentSessionId } : {}),
...(session.spawnKind ? { spawnKind: session.spawnKind } : {}),
...(session.orchestrationTag ? { orchestrationTag: session.orchestrationTag } : {}),
...(session.orchestrationStepId ? { orchestrationStepId: session.orchestrationStepId } : {}),
...(session.orchestrationBundlePath ? { orchestrationBundlePath: session.orchestrationBundlePath } : {}),
Expand Down
Loading