@getpipher/armory-fleet 0.3.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.
@@ -11,14 +11,17 @@ import {
11
11
  } from "@earendil-works/pi-tui";
12
12
  import type { AgentDef } from "../registry/frontmatter.ts";
13
13
  import type { RunRecord } from "../engine/run-registry.ts";
14
- import { fleetRow, agentsRow, agentInfo, backendsRow, backendInfo } from "./rows.ts";
14
+ import { fleetRow, agentsRow, agentInfo, backendsRow, backendInfo, lifecycleRow, lifecyclePhaseTimeline } from "./rows.ts";
15
15
  import { spawnSubagent, type SpawnResult } from "../engine/spawnSubagent.ts";
16
16
  import type { Backend, BackendRegistry } from "../backend/port.ts";
17
17
  import type { RunRegistry } from "../engine/run-registry.ts";
18
18
  import type { SingleSlotLock } from "../engine/concurrency-lock.ts";
19
19
  import type { TodoSyncPort } from "../todo-sync/port.ts";
20
+ import type { LifecycleDef, LifecycleRunRecord, CheckpointDecision, PhaseRecord } from "../lifecycle/lifecycle-types.ts";
21
+ import type { LifecycleRunDeps, CheckpointFn } from "../lifecycle/run-lifecycle.ts";
22
+ import { runLifecycle } from "../lifecycle/run-lifecycle.ts";
20
23
 
21
- type View = "fleet" | "agents" | "backends";
24
+ type View = "fleet" | "lifecycle" | "agents" | "backends";
22
25
 
23
26
  export interface FleetPanelDeps {
24
27
  registry: Map<string, AgentDef>;
@@ -28,6 +31,10 @@ export interface FleetPanelDeps {
28
31
  backendRegistry: BackendRegistry; // SPEC-3: replaces childFactory
29
32
  parentModel: { provider: string; id: string };
30
33
  parentCwd: string;
34
+ /** SPEC-4: lifecycle registry + active/recent run records + deps to drive checkpoints. */
35
+ lifecycleRegistry: Map<string, LifecycleDef>;
36
+ lifecycleRuns: Map<string, LifecycleRunRecord>;
37
+ lifecycleDeps: Omit<LifecycleRunDeps, "spawn">;
31
38
  }
32
39
 
33
40
  export interface FleetPanelOpts {
@@ -50,6 +57,16 @@ export class FleetPanel extends Container {
50
57
  private linkPhase: "task" | "link" = "task";
51
58
  private infoAgent: AgentDef | null = null;
52
59
  private selectedBackend: Backend | null = null; // SPEC-3: Backends view i:Info
60
+ private selectedLifecycle: LifecycleRunRecord | null = null; // SPEC-4: Lifecycle view i:Info
61
+ // SPEC-4: Run-lifecycle inline input state
62
+ private lcRunMode = false;
63
+ private lcTaskInput: Input | null = null;
64
+ private lcNameInput: Input | null = null;
65
+ private lcPhase: "task" | "name" = "task";
66
+ // SPEC-4: pending checkpoint (interactive Continue/Revise/Abort)
67
+ private pendingCheckpoint: { phase: PhaseRecord; resolve: (d: CheckpointDecision) => void } | null = null;
68
+ private lcReviseInput: Input | null = null;
69
+ private lcRevising = false;
53
70
 
54
71
  constructor(opts: FleetPanelOpts) {
55
72
  super();
@@ -69,9 +86,11 @@ export class FleetPanel extends Container {
69
86
  const items: SelectItem[] =
70
87
  this.view === "fleet"
71
88
  ? this.deps.runRegistry.list().map((r: RunRecord) => ({ value: r.runId, label: fleetRow(r) }))
72
- : this.view === "agents"
73
- ? [...this.deps.registry.values()].map((a: AgentDef) => ({ value: a.name, label: agentsRow(a) }))
74
- : this.deps.backendRegistry.list().map((b: Backend) => ({ value: b.id, label: backendsRow(b) }));
89
+ : this.view === "lifecycle"
90
+ ? [...this.deps.lifecycleRuns.values()].map((l: LifecycleRunRecord) => ({ value: l.runId, label: lifecycleRow(l) }))
91
+ : this.view === "agents"
92
+ ? [...this.deps.registry.values()].map((a: AgentDef) => ({ value: a.name, label: agentsRow(a) }))
93
+ : this.deps.backendRegistry.list().map((b: Backend) => ({ value: b.id, label: backendsRow(b) }));
75
94
  const fresh = new SelectList(items, 12, {
76
95
  selectedPrefix: (s: string) => this.theme.fg("accent", s),
77
96
  selectedText: (s: string) => this.theme.fg("accent", s),
@@ -89,7 +108,7 @@ export class FleetPanel extends Container {
89
108
  this.children.length = 0;
90
109
  this.children.push(...keep);
91
110
  const accent = (s: string): string => this.theme.fg("accent", s);
92
- const tabs = (["fleet", "agents", "backends"] as View[])
111
+ const tabs = (["fleet", "lifecycle", "agents", "backends"] as View[])
93
112
  .map((v) => (v === this.view ? this.theme.fg("accent", this.theme.bold(`[${v}]`)) : this.theme.fg("dim", v)))
94
113
  .join(" ");
95
114
  this.addChild(new Text(accent(this.theme.bold(" FLEET")) + " " + tabs, 0, 0));
@@ -112,19 +131,46 @@ export class FleetPanel extends Container {
112
131
  this.addChild(new Text(this.theme.fg("text", line), 0, 0));
113
132
  }
114
133
  this.addChild(new Text(this.theme.fg("dim", " esc:Back"), 0, 0));
134
+ } else if (this.selectedLifecycle) {
135
+ // SPEC-4: i:Info detail pane (lifecycle view) — phase timeline
136
+ this.addChild(new Text(this.theme.fg("dim", " ── lifecycle phases ──"), 0, 0));
137
+ for (const line of lifecyclePhaseTimeline(this.selectedLifecycle).split("\n")) {
138
+ this.addChild(new Text(this.theme.fg("text", line), 0, 0));
139
+ }
140
+ this.addChild(new Text(this.theme.fg("dim", " esc:Back"), 0, 0));
141
+ } else if (this.lcRunMode && (this.lcTaskInput || this.lcNameInput)) {
142
+ const prompt = this.lcPhase === "task" ? " task> " : " lifecycle name (blank=default)> ";
143
+ this.addChild(new Text(this.theme.fg("accent", prompt), 0, 0));
144
+ this.addChild(this.lcPhase === "task" ? this.lcTaskInput! : this.lcNameInput!);
145
+ this.addChild(new Text(this.theme.fg("dim", " enter submit • esc cancel"), 0, 0));
146
+ } else if (this.pendingCheckpoint && !this.lcRevising) {
147
+ const pc = this.pendingCheckpoint;
148
+ this.addChild(new Text(this.theme.fg("dim", ` ── checkpoint: phase '${pc.phase.name}' (${pc.phase.status}) ──`), 0, 0));
149
+ this.addChild(new Text(this.theme.fg("text", ` ${pc.phase.summary.slice(0, 200)}`), 0, 0));
150
+ this.addChild(new Text(this.theme.fg("dim", " c:Continue v:Revise a:Abort"), 0, 0));
151
+ } else if (this.lcRevising && this.lcReviseInput) {
152
+ this.addChild(new Text(this.theme.fg("accent", " revise feedback> "), 0, 0));
153
+ this.addChild(this.lcReviseInput);
154
+ this.addChild(new Text(this.theme.fg("dim", " enter submit • esc cancel"), 0, 0));
115
155
  } else {
116
156
  this.addChild(this.list);
117
157
  }
118
158
 
119
159
  this.addChild(new Spacer(1));
120
160
  const hint =
121
- this.infoAgent || this.selectedBackend
161
+ this.infoAgent || this.selectedBackend || this.selectedLifecycle
122
162
  ? " esc:Back"
123
- : this.view === "fleet"
124
- ? " r:Run-new s:Stop o:Open-todo tab:Agents q:Quit"
125
- : this.view === "agents"
126
- ? " r:Run e:Edit i:Info d:Reload tab:Backends q:Quit"
127
- : " r:Refresh i:Info tab:Fleet q:Quit";
163
+ : this.pendingCheckpoint
164
+ ? " c:Continue v:Revise a:Abort"
165
+ : this.lcRevising
166
+ ? " enter:Submit-feedback esc:Cancel"
167
+ : this.view === "fleet"
168
+ ? " r:Run-new s:Stop o:Open-todo tab:Lifecycle q:Quit"
169
+ : this.view === "lifecycle"
170
+ ? " r:Run-lifecycle i:Info tab:Agents q:Quit"
171
+ : this.view === "agents"
172
+ ? " r:Run e:Edit i:Info d:Reload tab:Backends q:Quit"
173
+ : " r:Refresh i:Info tab:Fleet q:Quit";
128
174
  this.addChild(new Text(this.theme.fg("dim", hint), 0, 0));
129
175
  this.addChild(new Spacer(1));
130
176
  this.addChild(new DynamicBorder(accent));
@@ -190,8 +236,11 @@ export class FleetPanel extends Container {
190
236
  }
191
237
 
192
238
  private switchView(): void {
193
- this.view = this.view === "fleet" ? "agents" : this.view === "agents" ? "backends" : "fleet";
239
+ this.view = this.view === "fleet" ? "lifecycle"
240
+ : this.view === "lifecycle" ? "agents"
241
+ : this.view === "agents" ? "backends" : "fleet";
194
242
  this.selectedBackend = null;
243
+ this.selectedLifecycle = null;
195
244
  this.list = this.buildList();
196
245
  this.renderShell();
197
246
  }
@@ -205,13 +254,32 @@ export class FleetPanel extends Container {
205
254
  if (matchesKey(data, "escape")) { this.selectedBackend = null; this.renderShell(); }
206
255
  return;
207
256
  }
257
+ if (this.selectedLifecycle) {
258
+ if (matchesKey(data, "escape")) { this.selectedLifecycle = null; this.renderShell(); }
259
+ return;
260
+ }
261
+ if (this.lcRunMode && (this.lcTaskInput || this.lcNameInput)) {
262
+ if (matchesKey(data, "escape")) { this.cancelLifecycleRun(); return; }
263
+ (this.lcPhase === "task" ? this.lcTaskInput! : this.lcNameInput!).handleInput(data);
264
+ this.invalidate();
265
+ return;
266
+ }
208
267
  if (this.runMode && (this.taskInput || this.linkInput)) {
209
268
  if (matchesKey(data, "escape")) { this.cancelRun(); return; }
210
269
  (this.linkPhase === "task" ? this.taskInput! : this.linkInput!).handleInput(data);
211
270
  this.invalidate();
212
271
  return;
213
272
  }
214
- if (matchesKey(data, "escape")) { this.onDone(); return; }
273
+ if (matchesKey(data, "escape")) {
274
+ // SPEC-4: if a lifecycle checkpoint is pending, resolve it as abort so runLifecycle
275
+ // doesn't hang + the lifecycle TODO is reverted (not orphaned) when the panel closes.
276
+ if (this.pendingCheckpoint) {
277
+ this.pendingCheckpoint.resolve({ action: "abort" });
278
+ this.pendingCheckpoint = null;
279
+ }
280
+ this.onDone();
281
+ return;
282
+ }
215
283
  if (matchesKey(data, "tab")) { this.switchView(); return; }
216
284
  if (matchesKey(data, "q")) { this.onDone(); return; }
217
285
  if (matchesKey(data, "r") && this.view === "agents") {
@@ -234,9 +302,105 @@ export class FleetPanel extends Container {
234
302
  this.renderShell();
235
303
  return;
236
304
  }
305
+ // SPEC-4: Lifecycle view — i:Info + r:Run-lifecycle
306
+ if (matchesKey(data, "i") && this.view === "lifecycle") {
307
+ const sel = this.list.getSelectedItem();
308
+ if (sel) { this.selectedLifecycle = this.deps.lifecycleRuns.get(sel.value) ?? null; this.renderShell(); }
309
+ return;
310
+ }
311
+ if (matchesKey(data, "r") && this.view === "lifecycle") {
312
+ this.startLifecycleRun();
313
+ return;
314
+ }
315
+ // SPEC-4: pending checkpoint keys (c/v/a)
316
+ if (this.pendingCheckpoint && !this.lcRevising) {
317
+ if (matchesKey(data, "c")) { this.pendingCheckpoint.resolve({ action: "continue" }); this.pendingCheckpoint = null; this.renderShell(); return; }
318
+ if (matchesKey(data, "a")) { this.pendingCheckpoint!.resolve({ action: "abort" }); this.pendingCheckpoint = null; this.renderShell(); return; }
319
+ if (matchesKey(data, "v")) {
320
+ this.lcRevising = true;
321
+ this.lcReviseInput = new Input();
322
+ this.lcReviseInput.onSubmit = (fb: string) => {
323
+ this.lcRevising = false;
324
+ this.lcReviseInput = null;
325
+ this.pendingCheckpoint!.resolve({ action: "revise", feedback: fb });
326
+ this.pendingCheckpoint = null;
327
+ this.renderShell();
328
+ };
329
+ this.lcReviseInput.onEscape = () => { this.lcRevising = false; this.lcReviseInput = null; this.renderShell(); };
330
+ this.renderShell();
331
+ return;
332
+ }
333
+ }
334
+ if (this.lcRevising && this.lcReviseInput) {
335
+ if (matchesKey(data, "escape")) { this.lcRevising = false; this.lcReviseInput = null; this.renderShell(); return; }
336
+ this.lcReviseInput.handleInput(data);
337
+ this.invalidate();
338
+ return;
339
+ }
237
340
  this.list.handleInput(data);
238
341
  this.invalidate();
239
342
  }
343
+
344
+ /** SPEC-4: open the Run-lifecycle inline inputs (task → lifecycle name → start runLifecycle). */
345
+ private startLifecycleRun(): void {
346
+ this.lcPhase = "task";
347
+ this.lcTaskInput = new Input();
348
+ this.lcTaskInput.onSubmit = (task: string) => {
349
+ if (!task.trim()) { this.cancelLifecycleRun(); return; }
350
+ this.lcPhase = "name";
351
+ this.lcNameInput = new Input();
352
+ this.lcNameInput.onSubmit = (name: string) => {
353
+ const lcName = name.trim() || "default";
354
+ void this.executeLifecycleRun(task.trim(), lcName);
355
+ };
356
+ this.lcNameInput.onEscape = () => { void this.executeLifecycleRun(task.trim(), "default"); };
357
+ this.renderShell();
358
+ };
359
+ this.lcTaskInput.onEscape = () => this.cancelLifecycleRun();
360
+ this.lcRunMode = true;
361
+ this.renderShell();
362
+ }
363
+
364
+ private cancelLifecycleRun(): void {
365
+ this.lcRunMode = false;
366
+ this.lcTaskInput = null;
367
+ this.lcNameInput = null;
368
+ this.renderShell();
369
+ }
370
+
371
+ private async executeLifecycleRun(task: string, lifecycleName: string): Promise<void> {
372
+ this.lcRunMode = false;
373
+ this.lcTaskInput = null;
374
+ this.lcNameInput = null;
375
+ this.renderShell();
376
+ if (!this.deps.lifecycleRegistry.has(lifecycleName)) {
377
+ this.onNotify(`lifecycle '${lifecycleName}' not found; available: ${[...this.deps.lifecycleRegistry.keys()].sort().join(", ")}`, "error");
378
+ return;
379
+ }
380
+ const onCheckpoint: CheckpointFn = (phase) => new Promise<CheckpointDecision>((resolve) => {
381
+ this.pendingCheckpoint = { phase, resolve };
382
+ this.renderShell();
383
+ });
384
+ const lifecycleFullDeps: LifecycleRunDeps = {
385
+ ...this.deps.lifecycleDeps,
386
+ spawn: async (o) => {
387
+ const { spawnSubagent } = await import("../engine/spawnSubagent.ts");
388
+ return spawnSubagent({
389
+ agent: o.agent, task: o.task, lifecycleTodoId: o.lifecycleTodoId, model: o.model,
390
+ skillsOverride: o.skills, backendOverride: o.backend,
391
+ registry: this.deps.registry, todoSync: this.deps.todoSync, runRegistry: this.deps.runRegistry, lock: this.deps.lock,
392
+ backendRegistry: this.deps.backendRegistry, parentModel: this.deps.parentModel, parentCwd: this.deps.parentCwd,
393
+ });
394
+ },
395
+ };
396
+ const res = await runLifecycle(task, lifecycleName, { deps: lifecycleFullDeps, mode: "checkpointed", onCheckpoint });
397
+ this.pendingCheckpoint = null;
398
+ // record the run so the Lifecycle view shows it
399
+ this.deps.lifecycleRuns.set(res.runId, res);
400
+ this.list = this.buildList();
401
+ this.renderShell();
402
+ this.onNotify(`lifecycle ${res.status}: ${res.runId}${res.error ? " — " + res.error : ""}`, res.status === "completed" ? "info" : "warning");
403
+ }
240
404
  }
241
405
 
242
406
  /** Factory used by src/index.ts to open the panel via ctx.ui.custom. */
package/src/panel/rows.ts CHANGED
@@ -82,3 +82,37 @@ export function backendInfo(b: Backend): string {
82
82
  lines.push(` vision: ${b.hookParity.vision} (${b.hookParity.vision === "✓" ? "describe_image fallback injected" : "pass-through only; no describe_image fallback — customTools not injectable into claude -p"})`);
83
83
  return lines.join("\n");
84
84
  }
85
+
86
+ import type { LifecycleRunRecord, LifecycleStatus } from "../lifecycle/lifecycle-types.ts";
87
+
88
+ const LC_GLYPH: Record<LifecycleStatus, string> = {
89
+ running: "▶", checkpoint: "⏸", completed: "✓", failed: "✗", aborted: "✗",
90
+ };
91
+
92
+ export function lifecycleRow(r: LifecycleRunRecord): string {
93
+ const dur = r.endedAt ? fmtDuration(r.endedAt - r.startedAt) : "—";
94
+ const curIdx = r.phases.findIndex((p) => p.status === "running");
95
+ const cur = curIdx >= 0 ? r.phases[curIdx] : r.phases[r.phases.length - 1];
96
+ const curName = cur ? `●${cur.name}` : "—";
97
+ // N/M = current phase position / total (1-indexed); falls back to last phase when none running.
98
+ const counts = `${(curIdx >= 0 ? curIdx + 1 : r.phases.length)}/${r.phases.length}`;
99
+ return `${LC_GLYPH[r.status]} ${r.runId} ${r.lifecycleName} ${curName} ${counts} ${r.mode} ${dur} ${r.backend} "${r.task}"`;
100
+ }
101
+
102
+ export function lifecyclePhaseTimeline(r: LifecycleRunRecord): string {
103
+ const lines: string[] = [
104
+ `Lifecycle ${r.runId} — ${r.lifecycleName} — "${r.task}"`,
105
+ `Backend: ${r.backend} · Mode: ${r.mode} · Status: ${r.status}`,
106
+ "",
107
+ "Phases:",
108
+ ];
109
+ for (const p of r.phases) {
110
+ const mark = p.reviseCount > 0 ? "[~]" : p.status === "completed" ? "[x]" : "[ ]";
111
+ const art = p.paths.length ? ` → ${p.paths.join(", ")}` : "";
112
+ lines.push(` ${mark} ${p.name} ${p.status}${art}${p.paths.length ? " [Open]" : ""}`);
113
+ }
114
+ if (r.status === "checkpoint") {
115
+ lines.push("", "── Checkpoint ──", "[Continue] [Revise] [Abort]");
116
+ }
117
+ return lines.join("\n");
118
+ }
@@ -81,4 +81,10 @@ export class ArmoryTodoAdapter implements TodoSyncPort {
81
81
  }
82
82
  appendNote(todoId, `fleet-run reverted: ${reason}`);
83
83
  }
84
+
85
+ async updateLifecycleProgress(todoId: string, progressBlock: string): Promise<void> {
86
+ if (!todoId) return;
87
+ // single-writer: replace notes wholesale with the progress block (the lifecycle owns it)
88
+ updateTodo(todoId, { notes: progressBlock });
89
+ }
84
90
  }
@@ -38,4 +38,6 @@ export interface TodoSyncPort {
38
38
  markRunTodoDone(todoId: string | null, priorStatus: string | undefined, result: string): Promise<void>;
39
39
  /** After a failed/aborted run: fleet-created -> open; linked -> restore prior. + reason note. */
40
40
  markRunTodoReverted(todoId: string | null, priorStatus: string | undefined, reason: string): Promise<void>;
41
+ /** SPEC-4: replace a lifecycle todo's notes with the phase-progress block (single source of truth). */
42
+ updateLifecycleProgress(todoId: string, progressBlock: string): Promise<void>;
41
43
  }
@@ -7,6 +7,8 @@ import type { SingleSlotLock } from "../engine/concurrency-lock.ts";
7
7
  import type { SpawnResult } from "../engine/spawnSubagent.ts";
8
8
  import { spawnSubagent } from "../engine/spawnSubagent.ts";
9
9
  import type { BackendRegistry } from "../backend/port.ts";
10
+ import type { LifecycleRunDeps } from "../lifecycle/run-lifecycle.ts";
11
+ import type { LifecycleDef } from "../lifecycle/lifecycle-types.ts";
10
12
 
11
13
  export const subagentParams = Type.Object({
12
14
  agent: Type.String({ description: "Agent name from the registry (builtin, project, or global)." }),
@@ -14,6 +16,8 @@ export const subagentParams = Type.Object({
14
16
  todoId: Type.Optional(Type.String({ description: "Explicit link to an existing open/in_progress armory-todo todo. Omit to create a fleet task." })),
15
17
  track: Type.Optional(Type.Boolean({ description: "Default true. Pass false only for throwaway lookups that don't represent real work." })),
16
18
  model: Type.Optional(Type.String({ description: 'Override the agent model, e.g. "anthropic/claude-sonnet-4".' })),
19
+ lifecycle: Type.Optional(Type.String({ description: "Run a multi-phase superpowers lifecycle by name (e.g. 'default') instead of a single delegate. Tool-driven lifecycles run end-to-end (auto) — checkpoints are a /fleet panel feature." })),
20
+ auto: Type.Optional(Type.Boolean({ description: "Only relevant with `lifecycle`. Tool-driven is always auto; this flag is forward-compat. Panel-driven uses --auto on /fleet-implement." })),
17
21
  });
18
22
 
19
23
  export type SubagentInput = Static<typeof subagentParams>;
@@ -26,6 +30,10 @@ export interface SubagentToolDeps {
26
30
  backendRegistry: BackendRegistry; // SPEC-3: replaces childFactory
27
31
  parentModel: { provider: string; id: string };
28
32
  parentCwd: string;
33
+ /** SPEC-4: lifecycle registry + spawn adapter (tool-driven = auto). */
34
+ lifecycleRegistry: Map<string, LifecycleDef>;
35
+ lifecycleRuns: Map<string, import("../lifecycle/lifecycle-types.ts").LifecycleRunRecord>;
36
+ lifecycleDeps: Omit<LifecycleRunDeps, "spawn">;
29
37
  }
30
38
 
31
39
  /** Build the pi.registerTool definition. Thin wrapper over spawnSubagent. */
@@ -42,6 +50,30 @@ export function createSubagentTool(deps: SubagentToolDeps) {
42
50
  ],
43
51
  parameters: subagentParams,
44
52
  async execute(_toolCallId: string, params: SubagentInput, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
53
+ if (params.lifecycle) {
54
+ const { runLifecycle } = await import("../lifecycle/run-lifecycle.ts");
55
+ const lifecycleFullDeps: LifecycleRunDeps = {
56
+ ...deps.lifecycleDeps,
57
+ spawn: async (o) => spawnSubagent({
58
+ agent: o.agent, task: o.task, lifecycleTodoId: o.lifecycleTodoId, model: o.model,
59
+ skillsOverride: o.skills, backendOverride: o.backend,
60
+ registry: deps.registry, todoSync: deps.todoSync, runRegistry: deps.runRegistry, lock: deps.lock,
61
+ backendRegistry: deps.backendRegistry, parentModel: deps.parentModel, parentCwd: deps.parentCwd, signal,
62
+ }),
63
+ };
64
+ const res = await runLifecycle(params.task, params.lifecycle, {
65
+ deps: lifecycleFullDeps, mode: "auto",
66
+ onCheckpoint: async (phase) => phase.status === "failed" ? { action: "abort" } : { action: "continue" },
67
+ });
68
+ const isError = res.status === "failed" || res.status === "aborted";
69
+ const summary = `lifecycle ${res.lifecycleName}: ${res.status} (${res.phases.length} phases)\n` +
70
+ res.phases.map((p) => ` ${p.name}: ${p.status}${p.paths.length ? " → " + p.paths.join(", ") : ""}`).join("\n");
71
+ return {
72
+ content: [{ type: "text" as const, text: isError ? (res.error ?? res.status) : summary }],
73
+ details: { runId: res.runId, todoId: res.todoId, lifecycle: res.lifecycleName, status: res.status, phases: res.phases.length },
74
+ isError,
75
+ };
76
+ }
45
77
  const res: SpawnResult = await spawnSubagent({
46
78
  agent: params.agent,
47
79
  task: params.task,