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: 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
55 changes: 55 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,61 @@ describe("adeRpcServer", () => {
});
});

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

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

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

it("preserves wide start_cli_session terminal dimensions", async () => {
const fixture = createRuntime();
fixture.runtime.sessionService.get.mockReturnValue({
Expand Down
38 changes: 36 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,11 @@ async function runTool(args: {
rows,
title,
tracked: toolArgs.tracked !== false,
toolType: LAUNCH_PROFILE_TOOL_TYPE[provider],
toolType,
...(resumeMetadata ? { resumeMetadata } : {}),
...(isCliProvider(provider) && orchestrationParentSessionId
? { spawnLineage: { parentChatSessionId: orchestrationParentSessionId, spawnKind } }
: {}),
...(asOptionalTrimmedString(toolArgs.cwd) ? { cwd: asOptionalTrimmedString(toolArgs.cwd)! } : {}),
...(asOptionalTrimmedString(toolArgs.chatSessionId) ? { chatSessionId: asOptionalTrimmedString(toolArgs.chatSessionId) } : {}),
...launchFields,
Expand Down
93 changes: 93 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,90 @@ 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("--auto-create-lane keeps --parent as the spawn-lineage session, not the lane parent", () => {
delete process.env.ADE_CHAT_SESSION_ID;
const plan = buildCliPlan([
"new", "chat",
"--mode", "chat",
"--auto-create-lane",
"--lane-name", "spawn-target",
"--provider", "codex",
"--parent", "parent-session-1",
"--print-config",
]);
const staticPlan = expectStaticPlan(plan);
const value = staticPlan.value as { launch: Record<string, unknown>; createLane?: Record<string, unknown> };

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Duplicate block-scoped variable declaration.

This duplicate declaration will cause a TypeScript compilation error. This finding shares a root cause with another site; see the consolidated comment at the end of the review for the resolution.

🧰 Tools
🪛 ast-grep (0.44.1)

[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: import { spawn } from "node:child_process";
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(detect-child-process-typescript)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/ade-cli/src/cli.test.ts` at line 2491, Remove the duplicate block-scoped
declaration of value in the affected test scope, retaining a single declaration
with the existing staticPlan launch/createLane type. Update subsequent
references to use that retained variable without redeclaring it.

expect(value.launch.orchestrationParentSessionId).toBe("parent-session-1");
expect(value.createLane ?? {}).not.toHaveProperty("parentLaneId");
});

it("--auto-create-lane --parent-lane still sets the lane parent without touching lineage", () => {
delete process.env.ADE_CHAT_SESSION_ID;
const plan = buildCliPlan([
"new", "chat",
"--mode", "chat",
"--auto-create-lane",
"--lane-name", "spawn-target",
"--provider", "codex",
"--parent-lane", "lane-parent-1",
"--print-config",
]);
const staticPlan = expectStaticPlan(plan);
const value = staticPlan.value as { launch: Record<string, unknown>; createLane?: Record<string, unknown> };

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Duplicate block-scoped variable declaration.

This duplicate declaration will cause a TypeScript compilation error. This finding shares a root cause with another site; see the consolidated comment at the end of the review for the resolution.

🧰 Tools
🪛 ast-grep (0.44.1)

[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: import { spawn } from "node:child_process";
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(detect-child-process-typescript)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/ade-cli/src/cli.test.ts` at line 2508, Remove the duplicate block-scoped
declaration of value in the affected test scope, reusing the existing variable
when validating staticPlan. Ensure the launch and optional createLane type shape
remains available without redeclaring the identifier.

expect(value.createLane?.parentLaneId).toBe("lane-parent-1");
expect(value.launch).not.toHaveProperty("orchestrationParentSessionId");
});

it("does not record spawn lineage for plain shell terminals", () => {
process.env.ADE_CHAT_SESSION_ID = "parent-session-1";
const plan = buildCliPlan([
"new", "chat",
"--mode", "cli",
"--lane", "lane-1",
"--provider", "shell",
"--type", "peer",
"--print-config",
]);
const staticPlan = expectStaticPlan(plan);
const launch = (staticPlan.value as { launch: Record<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 +5764,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
42 changes: 31 additions & 11 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 @@ -4163,7 +4175,11 @@ function resolveNewChatLaneArgs(args: string[], prompt: string | null): {
maybePut(createLaneArgs, "description", readValue(args, ["--description", "--desc"]));
maybePut(createLaneArgs, "baseBranch", readValue(args, ["--base", "--base-branch"]));
maybePut(createLaneArgs, "branchName", readValue(args, ["--branch-name"]));
maybePut(createLaneArgs, "parentLaneId", readValue(args, ["--parent", "--parent-lane", "--parent-lane-id"]));
// No bare `--parent` alias here: in `ade new chat` that flag is documented as
// the spawn-lineage parent SESSION (readParentSessionId), and this resolver
// runs first — consuming it would silently eat the lineage flag whenever
// --auto-create-lane is used. Lane parentage keeps the explicit aliases.
maybePut(createLaneArgs, "parentLaneId", readValue(args, ["--parent-lane", "--parent-lane-id"]));
return { laneId: null, autoCreateLane: true, createLaneArgs };
}

Expand All @@ -4184,7 +4200,7 @@ function buildNewChatPlan(args: string[], defaultMode: "chat" | "cli"): CliPlan
const reasoningEffort = readValue(args, ["--reasoning-effort", "--effort", "--reasoning"]);
const permissionMode = readValue(args, ["--permission-mode", "--permissions"]);
const spawnTypeArg = readValue(args, ["--type", "--spawn-type"]);
const spawnKind = mode === "chat" ? spawnTypeArg?.trim().toLowerCase() : undefined;
const spawnKind = spawnTypeArg?.trim().toLowerCase();
const fastMode = readFastModeFlag(args);
const title = readValue(args, ["--title"]);
const printConfig = readFlag(args, ["--print-config", "--dry-run"]);
Expand Down Expand Up @@ -4218,10 +4234,10 @@ function buildNewChatPlan(args: string[], defaultMode: "chat" | "cli"): CliPlan
};

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

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 +4265,10 @@ function buildNewChatPlan(args: string[], defaultMode: "chat" | "cli"): CliPlan
modelId: modelArg,
reasoningEffort,
...(fastMode !== undefined ? { fastMode, codexFastMode: fastMode } : {}),
// Spawn lineage rides on resume metadata, which only agent providers
// have — plain shell terminals can't persist it, so don't pretend.
...(provider !== "shell" && orchestrationParentSessionId ? { orchestrationParentSessionId } : {}),
...(provider !== "shell" && spawnKind ? { spawnKind } : {}),
cols: readIntOption(args, ["--cols"], 120),
rows: readIntOption(args, ["--rows"], 36),
cwd: readValue(args, ["--cwd"]),
Expand Down
Loading
Loading