@getpipher/armory-fleet 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,11 @@
1
+ ---
2
+ name: general-purpose-cc
3
+ description: A focused general-purpose CC subagent. Use for any task needing Claude Code as the worker.
4
+ backend: claude
5
+ todoSync: true
6
+ memoryHydrate: true
7
+ vision: true
8
+ ---
9
+ You are a focused subagent delegate running under Claude Code. Complete the assigned task
10
+ thoroughly, work autonomously to completion, and return a concise result summary.
11
+ Do not call the `todo` tool — the fleet engine manages todo tracking for you.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getpipher/armory-fleet",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "private": false,
5
5
  "description": "The armory suite's subagent orchestrator for the pi coding agent — a cross-harness, superpowers-native fleet where every agent is armory-native from birth.",
6
6
  "license": "MIT",
@@ -0,0 +1,75 @@
1
+ // src/backend/claude-detector.ts — version + stream-json schema smoke + flag-support probe (SPEC-3 §5).
2
+ import { spawn } from "node:child_process";
3
+ import { existsSync } from "node:fs";
4
+ import { mapClaudeEvent } from "./claude-events.ts";
5
+ import type { BackendVersionInfo } from "./registry.ts";
6
+
7
+ const DEFAULT_BIN = "claude";
8
+
9
+ export interface DetectOpts {
10
+ /** Fixture hook: an arg passed to the fake-claude via FLEET_FAKE_CLAUDE_PROBE env to select init-ok/init-drift. */
11
+ schemaProbeArg?: string;
12
+ }
13
+
14
+ function run(bin: string, args: string[], env?: NodeJS.ProcessEnv): Promise<{ stdout: string; stderr: string; code: number | null }> {
15
+ return new Promise((resolve) => {
16
+ const child = spawn(bin, args, { env: { ...process.env, ...env }, stdio: ["ignore", "pipe", "pipe"] });
17
+ let stdout = "";
18
+ let stderr = "";
19
+ child.stdout?.on("data", (d) => { stdout += d.toString(); });
20
+ child.stderr?.on("data", (d) => { stderr += d.toString(); });
21
+ child.on("close", (code) => resolve({ stdout, stderr, code }));
22
+ // Capture the spawn error message into stderr so the caller can detect ENOENT (binary missing).
23
+ child.on("error", (err) => resolve({ stdout: "", stderr: err.message, code: null }));
24
+ });
25
+ }
26
+
27
+ function parseVersion(stdout: string): string {
28
+ const m = stdout.trim().match(/(\d+\.\d+\.\d+)/);
29
+ return m && m[1] ? m[1] : stdout.trim();
30
+ }
31
+
32
+ function probeFlags(helpText: string): Record<string, boolean> {
33
+ const has = (flag: string): boolean => new RegExp(`(^|\\s)${flag.replace(/-/g, "\\-")}(\\s|$)`).test(helpText);
34
+ return {
35
+ "--disallowed-tools": has("--disallowed-tools"),
36
+ "--allowed-tools": has("--allowed-tools"),
37
+ "--max-turns": has("--max-turns"),
38
+ "--resume": has("--resume"),
39
+ "--append-system-prompt": has("--append-system-prompt"),
40
+ "--output-format": has("--output-format"),
41
+ };
42
+ }
43
+
44
+ export async function detectClaude(bin: string = DEFAULT_BIN, opts: DetectOpts = {}): Promise<BackendVersionInfo | null> {
45
+ // Missing binary check: for an explicit path that doesn't exist, return null;
46
+ // for the default `claude` on PATH, the version run below resolves ENOENT → null.
47
+ if (bin !== DEFAULT_BIN && !existsSync(bin)) return null;
48
+ const versionRun = await run(bin, ["--version"]);
49
+ if (versionRun.code === null && /ENOENT/i.test(versionRun.stderr)) return null;
50
+ if (versionRun.code !== 0 && !versionRun.stdout) {
51
+ return { version: "", schemaOk: false, flagSupport: {}, note: `claude --version failed (code ${versionRun.code})` };
52
+ }
53
+ const version = parseVersion(versionRun.stdout);
54
+
55
+ // Schema smoke: spawn a throwaway ping in stream-json mode; scan the lines for a `system/init` event with session_id.
56
+ // (Real CC emits hook_started/hook_response system events before init; first-line-only would false-fail.)
57
+ const env = opts.schemaProbeArg ? { FLEET_FAKE_CLAUDE_PROBE: opts.schemaProbeArg } : undefined;
58
+ const smoke = await run(bin, ["-p", "--verbose", "--output-format", "stream-json", "ping"], env);
59
+ let schemaOk = false;
60
+ let note: string | undefined;
61
+ const lines = smoke.stdout.split("\n").filter((l) => l.trim());
62
+ for (const line of lines) {
63
+ const ev = mapClaudeEvent(line);
64
+ if (ev && ev.type === "session_init" && ev.backendSessionId) { schemaOk = true; break; }
65
+ }
66
+ if (!schemaOk) {
67
+ note = lines.length ? `schema drift (no init event with session_id; first line: ${lines[0]!.slice(0, 80)})` : "schema drift (no output emitted)";
68
+ }
69
+
70
+ // Flag-support probe (only meaningful if --help works).
71
+ const helpRun = await run(bin, ["--help"]);
72
+ const flagSupport = helpRun.code === 0 ? probeFlags(helpRun.stdout) : {};
73
+
74
+ return { version, schemaOk, flagSupport, note };
75
+ }
@@ -0,0 +1,39 @@
1
+ // src/backend/claude-events.ts — map one CC stream-json NDJSON line → ChildSessionEvent (SPEC-3 §4.2).
2
+ // Returns null for: filtered echoes (our own user writes), unknown types, malformed lines.
3
+ // The caller logs null-but-parseable lines at debug (forward-compat: CC may add types we don't need).
4
+ import type { ChildSessionEvent } from "../engine/spawnSubagent.ts";
5
+
6
+ interface CCMessage { role?: string; content?: Array<{ type: string; text?: string }>; usage?: Record<string, unknown>; }
7
+ interface CCEvent { type: string; subtype?: string; session_id?: string; message?: CCMessage; error?: { message?: string }; }
8
+
9
+ export function mapClaudeEvent(line: string): ChildSessionEvent | null {
10
+ let ev: CCEvent;
11
+ try {
12
+ ev = JSON.parse(line) as CCEvent;
13
+ } catch {
14
+ return null; // malformed line — resilient
15
+ }
16
+ switch (ev.type) {
17
+ case "system":
18
+ if (ev.subtype === "init" && typeof ev.session_id === "string") {
19
+ return { type: "session_init", backendSessionId: ev.session_id };
20
+ }
21
+ return null;
22
+ case "assistant": {
23
+ const msg = ev.message;
24
+ if (!msg) return null;
25
+ const content = (msg.content ?? []).map((c) => ({ type: c.type, text: c.text }));
26
+ const usage = msg.usage as { cost?: { total?: number } } | undefined;
27
+ return { type: "message_end", message: { role: msg.role ?? "assistant", content, usage } };
28
+ }
29
+ case "result":
30
+ // turn boundary (success or error_max_turns) → turn_end drives the budget
31
+ return { type: "turn_end" };
32
+ case "error":
33
+ return { type: "error", message: { role: "error", content: [{ type: "text", text: ev.error?.message ?? "claude error" }] } };
34
+ case "user":
35
+ return null; // echo of our own stdin write — filtered
36
+ default:
37
+ return null; // unknown — forward-compat, caller logs at debug
38
+ }
39
+ }
@@ -0,0 +1,50 @@
1
+ // src/backend/claude-factory.ts — createClaudeChildFactory (SPEC-3 §4.1, §4.5, §4.6, §9.1).
2
+ import { spawn, type ChildProcess } from "node:child_process";
3
+ import type { ChildSessionFactory, ChildSessionOpts } from "../engine/spawnSubagent.ts";
4
+ import type { BackendVersionInfo } from "./registry.ts";
5
+ import type { ResumeStore } from "./resume-store.ts";
6
+ import { ClaudeChildSession } from "./claude-session.ts";
7
+ import { memoryScopesFor } from "../engine/child-loader.ts";
8
+
9
+ export interface ClaudeFactoryOverrides {
10
+ /** Test hook: called instead of `spawn` to inspect args. Returns a ChildProcess-shaped stub. */
11
+ spawnOverride?: (args: string[]) => ChildProcess;
12
+ }
13
+
14
+ export function createClaudeChildFactory(
15
+ detector: BackendVersionInfo | null,
16
+ resumeStore: ResumeStore,
17
+ bin: string = "claude",
18
+ overrides: ClaudeFactoryOverrides = {},
19
+ ): ChildSessionFactory {
20
+ return {
21
+ async create(opts: ChildSessionOpts): Promise<{ session: ClaudeChildSession; model: string }> {
22
+ if (!detector?.schemaOk) {
23
+ throw new Error(`claude backend unavailable: ${detector?.note ?? "schema not ok"}`);
24
+ }
25
+ const memoryBlock = opts.agent.memoryHydrate ? opts.memoryPort.renderScopes(memoryScopesFor(opts.cwd)) : "";
26
+ const sys = memoryBlock ? `${opts.rolePrompt}\n\n${memoryBlock}` : opts.rolePrompt;
27
+ const resumeId = resumeStore.get("claude", opts.agent.sessionKey);
28
+
29
+ const args: string[] = ["-p", "--output-format", "stream-json", "--input-format", "stream-json", "--verbose"];
30
+ if (opts.model) args.push("--model", opts.model);
31
+ args.push("--append-system-prompt", sys);
32
+ // todo exclusion: prefer --disallowed-tools; fall back to --allowed-tools allow-list when the agent pins tools.
33
+ if (detector.flagSupport["--disallowed-tools"]) {
34
+ args.push("--disallowed-tools", "todo");
35
+ } else if (detector.flagSupport["--allowed-tools"] && opts.tools.length) {
36
+ const allowed = opts.tools.filter((t) => t !== "todo").join(",");
37
+ args.push("--allowed-tools", allowed);
38
+ }
39
+ // v0.3 leaves maxTurns to the engine's turn_end belt (no --max-turns flag; avoids double-enforcement).
40
+ if (resumeId && detector.flagSupport["--resume"]) args.push("--resume", resumeId);
41
+ args.push(opts.task);
42
+
43
+ const proc = overrides.spawnOverride
44
+ ? overrides.spawnOverride(args)
45
+ : spawn(bin, args, { cwd: opts.cwd, stdio: ["pipe", "pipe", "pipe"] });
46
+ const session = new ClaudeChildSession(proc, opts.agent.sessionKey, resumeStore);
47
+ return { session, model: opts.model ?? "" };
48
+ },
49
+ };
50
+ }
@@ -0,0 +1,75 @@
1
+ // src/backend/claude-session.ts — ChildSession over a claude -p child process (SPEC-3 §4.4, §4.3).
2
+ import type { ChildProcess } from "node:child_process";
3
+ import { createInterface } from "node:readline";
4
+ import type { ChildSession, ChildSessionEvent } from "../engine/spawnSubagent.ts";
5
+ import { mapClaudeEvent } from "./claude-events.ts";
6
+ import type { ResumeStore } from "./resume-store.ts";
7
+
8
+ export class ClaudeChildSession implements ChildSession {
9
+ private readonly proc: ChildProcess;
10
+ private readonly sessionKey: string;
11
+ private readonly resumeStore: ResumeStore;
12
+ private readonly handlers: Array<(e: ChildSessionEvent) => void> = [];
13
+ private disposed = false;
14
+ private initCaptured = false;
15
+ private turnResolve: (() => void) | null = null;
16
+
17
+ constructor(proc: ChildProcess, sessionKey: string, resumeStore: ResumeStore) {
18
+ this.proc = proc;
19
+ this.sessionKey = sessionKey;
20
+ this.resumeStore = resumeStore;
21
+ const rl = createInterface({ input: proc.stdout! });
22
+ rl.on("line", (line) => this.onLine(line));
23
+ proc.on("close", () => { if (this.turnResolve) { const r = this.turnResolve; this.turnResolve = null; r(); } });
24
+ }
25
+
26
+ private onLine(line: string): void {
27
+ const ev = mapClaudeEvent(line);
28
+ if (!ev) return;
29
+ if (ev.type === "session_init" && ev.backendSessionId && !this.initCaptured) {
30
+ this.initCaptured = true;
31
+ this.resumeStore.set("claude", this.sessionKey, ev.backendSessionId);
32
+ }
33
+ if (ev.type === "turn_end" || ev.type === "error") {
34
+ if (this.turnResolve) { const r = this.turnResolve; this.turnResolve = null; r(); }
35
+ }
36
+ for (const h of this.handlers) h(ev);
37
+ }
38
+
39
+ async prompt(text: string): Promise<void> {
40
+ if (this.disposed) throw new Error("session disposed");
41
+ if (!this.proc.stdin) throw new Error("claude child has no stdin pipe");
42
+ const msg = JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "text", text }] } }) + "\n";
43
+ return new Promise<void>((resolve) => {
44
+ this.turnResolve = resolve;
45
+ this.proc.stdin!.write(msg, () => { /* fire-and-forget; resolved on turn_end/close */ });
46
+ });
47
+ }
48
+
49
+ subscribe(handler: (e: ChildSessionEvent) => void): () => void {
50
+ this.handlers.push(handler);
51
+ return () => {
52
+ const i = this.handlers.indexOf(handler);
53
+ if (i >= 0) this.handlers.splice(i, 1);
54
+ };
55
+ }
56
+
57
+ async abort(): Promise<void> {
58
+ if (this.disposed) return;
59
+ try { this.proc.kill("SIGTERM"); } catch { /* already dead */ }
60
+ this.disposed = true; // SIGTERM kills the child process; the session is irrecoverable
61
+ }
62
+
63
+ dispose(): void {
64
+ if (this.disposed) return;
65
+ this.disposed = true;
66
+ try { this.proc.kill("SIGKILL"); } catch { /* already dead */ }
67
+ this.proc.stdout?.destroy();
68
+ try { this.proc.stdin?.end(); } catch { /* already closed */ }
69
+ this.proc.removeAllListeners();
70
+ }
71
+
72
+ isDisposed(): boolean {
73
+ return this.disposed;
74
+ }
75
+ }
@@ -0,0 +1,19 @@
1
+ // src/backend/hook-parity.ts — declared per-backend hook parity (SPEC-3 §2.3, §4.6).
2
+ // The chip is a static backend property, never inferred at spawn time.
3
+
4
+ export type HookState = "✓" | "~";
5
+
6
+ export interface BackendHookParity {
7
+ /** `todo` tool excluded from the child. */
8
+ todo: HookState;
9
+ /** memory-hydrate (3-scope) active in the child. */
10
+ memory: HookState;
11
+ /** vision: capability-aware. `✓` = full (describe_image fallback); `~` = pass-through only. */
12
+ vision: HookState;
13
+ }
14
+
15
+ /** Pi backend: full moat via loader injection + customTools (SPEC-2). */
16
+ export const PI_HOOK_PARITY: BackendHookParity = { todo: "✓", memory: "✓", vision: "✓" };
17
+
18
+ /** CC backend: moat via prompt/flag translation. Vision has no describe_image fallback (`~`). */
19
+ export const CLAUDE_HOOK_PARITY: BackendHookParity = { todo: "✓", memory: "✓", vision: "~" };
@@ -0,0 +1,5 @@
1
+ // src/backend/port.ts — single import surface for engine + views (SPEC-3 §3).
2
+ export type { BackendHookParity, HookState } from "./hook-parity.ts";
3
+ export { PI_HOOK_PARITY, CLAUDE_HOOK_PARITY } from "./hook-parity.ts";
4
+ export type { Backend, BackendVersionInfo } from "./registry.ts";
5
+ export { BackendRegistry } from "./registry.ts";
@@ -0,0 +1,36 @@
1
+ // src/backend/registry.ts — BackendRegistry + Backend descriptor (SPEC-3 §2.1).
2
+ import type { ChildSessionFactory } from "../engine/spawnSubagent.ts";
3
+ import type { BackendHookParity } from "./hook-parity.ts";
4
+
5
+ export interface BackendVersionInfo {
6
+ version: string;
7
+ schemaOk: boolean;
8
+ /** Flag support matrix probed at detect time (kebab-case flag → supported?). */
9
+ flagSupport: Record<string, boolean>;
10
+ note?: string;
11
+ }
12
+
13
+ export interface Backend {
14
+ id: "pi" | "claude";
15
+ factory: ChildSessionFactory;
16
+ available: () => boolean;
17
+ versionInfo: () => BackendVersionInfo | null;
18
+ hookParity: BackendHookParity;
19
+ }
20
+
21
+ export class BackendRegistry {
22
+ private readonly backends = new Map<string, Backend>();
23
+ private readonly order: string[] = [];
24
+
25
+ register(b: Backend): void {
26
+ if (!this.backends.has(b.id)) this.order.push(b.id);
27
+ this.backends.set(b.id, b);
28
+ }
29
+ get(id: string): Backend | undefined {
30
+ return this.backends.get(id);
31
+ }
32
+ /** Registration-order list — the data source for the Backends view + engine lookup. */
33
+ list(): Backend[] {
34
+ return this.order.map((id) => this.backends.get(id)!).filter(Boolean);
35
+ }
36
+ }
@@ -0,0 +1,44 @@
1
+ // src/backend/resume-store.ts — file-backed sessionKey → backendSessionId, per backend (SPEC-3 §2.4, §4.3).
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ function rootDir(): string {
6
+ return process.env.FLEET_RESUME_ROOT ?? join(process.env.HOME ?? "/tmp", ".pi", "agent", "cache", "fleet-resume");
7
+ }
8
+
9
+ /** Per-backend JSON map: { [sessionKey]: backendSessionId }. */
10
+ function fileFor(backendId: string): string {
11
+ return join(rootDir(), `${backendId}.json`);
12
+ }
13
+
14
+ function readMap(backendId: string): Record<string, string> {
15
+ const f = fileFor(backendId);
16
+ if (!existsSync(f)) return {};
17
+ try {
18
+ return JSON.parse(readFileSync(f, "utf8")) as Record<string, string>;
19
+ } catch {
20
+ return {};
21
+ }
22
+ }
23
+
24
+ function writeMap(backendId: string, m: Record<string, string>): void {
25
+ const dir = rootDir();
26
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
27
+ writeFileSync(fileFor(backendId), JSON.stringify(m, null, 2));
28
+ }
29
+
30
+ export class ResumeStore {
31
+ get(backendId: string, sessionKey: string): string | null {
32
+ return readMap(backendId)[sessionKey] ?? null;
33
+ }
34
+ set(backendId: string, sessionKey: string, backendSessionId: string): void {
35
+ const m = readMap(backendId);
36
+ m[sessionKey] = backendSessionId;
37
+ writeMap(backendId, m);
38
+ }
39
+ clear(backendId: string, sessionKey: string): void {
40
+ const m = readMap(backendId);
41
+ delete m[sessionKey];
42
+ writeMap(backendId, m);
43
+ }
44
+ }
@@ -12,6 +12,10 @@ export interface RunRecord {
12
12
  startedAt: number;
13
13
  endedAt?: number;
14
14
  resultSummary?: string;
15
+ /** Backend-native session id for resume (SPEC-3). */
16
+ backendSessionId?: string | null;
17
+ /** The sessionKey whose resume this run belongs to (SPEC-3). */
18
+ sessionKey?: string | null;
15
19
  }
16
20
 
17
21
  /** runId format: fl-<base36 ms>-<6 random> (SPEC-1 §5.1). */
@@ -3,6 +3,7 @@ import type { AgentDef, ThinkingLevel } from "../registry/frontmatter.ts";
3
3
  import type { FleetRunStatus, TodoSyncPort } from "../todo-sync/port.ts";
4
4
  import type { MemoryHydratePort } from "../memory-hydrate/port.ts";
5
5
  import type { VisionPort } from "../vision/port.ts";
6
+ import type { BackendRegistry } from "../backend/port.ts";
6
7
  import { genRunId, RunRegistry } from "./run-registry.ts";
7
8
  import { createTurnBudget, DEFAULT_MAX_TURNS } from "./turn-budget.ts";
8
9
  import type { SingleSlotLock } from "./concurrency-lock.ts";
@@ -25,6 +26,8 @@ export interface ChildSessionEvent {
25
26
  content?: Array<{ type: string; text?: string }>;
26
27
  usage?: { cost?: { total?: number } };
27
28
  };
29
+ /** Emitted by a backend on session init (SPEC-3). Drives runRecord.backendSessionId. */
30
+ backendSessionId?: string;
28
31
  }
29
32
 
30
33
  export interface ChildSession {
@@ -62,13 +65,22 @@ export interface SpawnOptions {
62
65
  todoSync: TodoSyncPort;
63
66
  runRegistry: RunRegistry;
64
67
  lock: SingleSlotLock;
65
- childFactory: ChildSessionFactory;
68
+ backendRegistry: BackendRegistry; // SPEC-3: replaces childFactory — engine looks up by agentDef.backend
66
69
  parentModel: { provider: string; id: string };
67
70
  parentCwd: string;
68
71
  memoryPort?: MemoryHydratePort;
69
72
  visionPort?: VisionPort;
70
73
  signal?: AbortSignal;
71
74
  onEvent?: (e: ChildSessionEvent) => void;
75
+ /** SPEC-4: when set, this spawn is a lifecycle phase child. It links to this lifecycle todo
76
+ * (not creates a new one) and finishRun skips mark-done/revert — the lifecycle engine owns
77
+ * the lifecycle todo's status + progress block. */
78
+ lifecycleTodoId?: string;
79
+ /** SPEC-4: when set (lifecycle phase child), override the agent's `skills` frontmatter with
80
+ * the lifecycle's merged phase skill bundle (Q1=B) + route to the phase's resolved backend
81
+ * (Q4=C) instead of the agent's `backend`. */
82
+ skillsOverride?: string[];
83
+ backendOverride?: "pi" | "claude";
72
84
  }
73
85
 
74
86
  export interface SpawnResult {
@@ -107,12 +119,24 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
107
119
  return fail(runId, startedAt, `agent '${opts.agent}' not in registry; available: ${available}`, opts.agent);
108
120
  }
109
121
 
122
+ // SPEC-3: route via the backend registry; fail fast if the backend is missing/unavailable.
123
+ // SPEC-4: a lifecycle phase child may override the backend (Q4=C) + the skill bundle (Q1=B).
124
+ const backendId = opts.backendOverride ?? agentDef.backend;
125
+ const backend = opts.backendRegistry.get(backendId);
126
+ if (!backend || !backend.available()) {
127
+ const note = backend?.versionInfo()?.note ?? "not registered";
128
+ return fail(runId, startedAt, `backend '${backendId}' unavailable: ${note}`, opts.agent);
129
+ }
130
+ // SPEC-4: when a lifecycle provides a skills override, clone the agentDef so the factory's
131
+ // skillsOverride (buildChildLoader reads agent.skills) loads the phase's bundle, not the agent's.
132
+ const childAgent = opts.skillsOverride ? { ...agentDef, skills: opts.skillsOverride } : agentDef;
133
+
110
134
  // resolve model
111
135
  const model = opts.model ?? agentDef.model ?? `${opts.parentModel.provider}/${opts.parentModel.id}`;
112
136
 
113
137
  // child tools pass through UNFILTERED — the single-writer `todo`-exclusion is enforced
114
138
  // downstream by the child factory's `excludeTools: ["todo"]` (SPEC-2 §9.1 hardening).
115
- const tools = agentDef.tools ?? PI_DEFAULT_TOOLS;
139
+ const tools = childAgent.tools ?? PI_DEFAULT_TOOLS;
116
140
  const memoryPort = opts.memoryPort ?? NOOP_MEMORY_PORT;
117
141
  const visionPort = opts.visionPort ?? NOOP_VISION_PORT;
118
142
 
@@ -128,7 +152,7 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
128
152
  try {
129
153
  const link = await opts.todoSync.linkOrCreateRunTodo({
130
154
  runId, agent: agentDef.name, task: opts.task,
131
- todoId: opts.todoId, track: track && agentDef.todoSync,
155
+ todoId: opts.lifecycleTodoId ?? opts.todoId, track: track && agentDef.todoSync,
132
156
  });
133
157
  todoId = link.todoId;
134
158
  priorStatus = link.priorStatus;
@@ -138,15 +162,15 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
138
162
  }
139
163
 
140
164
  // spawn child
141
- const { session } = await opts.childFactory.create({
165
+ const { session } = await backend.factory.create({
142
166
  cwd: opts.parentCwd,
143
167
  model,
144
- thinkingLevel: agentDef.thinkingLevel,
168
+ thinkingLevel: childAgent.thinkingLevel,
145
169
  tools,
146
- rolePrompt: agentDef.rolePrompt,
147
- skills: agentDef.skills ?? [],
170
+ rolePrompt: childAgent.rolePrompt,
171
+ skills: childAgent.skills ?? [],
148
172
  task: opts.task,
149
- agent: agentDef,
173
+ agent: childAgent,
150
174
  memoryPort,
151
175
  visionPort,
152
176
  });
@@ -160,7 +184,9 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
160
184
  opts.signal?.addEventListener("abort", onSignalAbort);
161
185
 
162
186
  const unsub = session.subscribe((e) => {
163
- if (e.type === "turn_end") {
187
+ if (e.type === "session_init" && e.backendSessionId) {
188
+ opts.runRegistry.update(runId, { backendSessionId: e.backendSessionId, sessionKey: agentDef.sessionKey });
189
+ } else if (e.type === "turn_end") {
164
190
  if (budget.consume()) void session.abort();
165
191
  } else if (e.type === "message_end" && e.message?.role === "assistant") {
166
192
  const text = e.message.content?.map((c) => (c.type === "text" ? c.text ?? "" : "")).join("") ?? "";
@@ -217,15 +243,18 @@ async function finishRun(
217
243
  ): Promise<SpawnResult> {
218
244
  const endedAt = Date.now();
219
245
  opts.runRegistry.update(runId, { status, endedAt, resultSummary: finalText.slice(0, 120) });
220
- // todo-sync reconciliation must not mask the run result
221
- try {
222
- if (status === "completed") {
223
- await opts.todoSync.markRunTodoDone(todoId, priorStatus, finalText.slice(0, 500));
224
- } else {
225
- await opts.todoSync.markRunTodoReverted(todoId, priorStatus, error ?? status);
246
+ // SPEC-4: lifecycle phase children skip the per-run todo reconciliation — the lifecycle
247
+ // engine owns the lifecycle todo's status + progress block (Q7=C).
248
+ if (!opts.lifecycleTodoId) {
249
+ try {
250
+ if (status === "completed") {
251
+ await opts.todoSync.markRunTodoDone(todoId, priorStatus, finalText.slice(0, 500));
252
+ } else {
253
+ await opts.todoSync.markRunTodoReverted(todoId, priorStatus, error ?? status);
254
+ }
255
+ } catch {
256
+ // swallow — the run result is authoritative; the finally in spawnSubagent releases the lock
226
257
  }
227
- } catch {
228
- // swallow — the run result is authoritative; the finally in spawnSubagent releases the lock
229
258
  }
230
259
  return {
231
260
  status, finalText, runId, todoId, agent: agentName, model,