@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.
@@ -11,22 +11,30 @@ 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 } from "./rows.ts";
15
- import { spawnSubagent, type ChildSessionFactory, type SpawnResult } from "../engine/spawnSubagent.ts";
14
+ import { fleetRow, agentsRow, agentInfo, backendsRow, backendInfo, lifecycleRow, lifecyclePhaseTimeline } from "./rows.ts";
15
+ import { spawnSubagent, type SpawnResult } from "../engine/spawnSubagent.ts";
16
+ import type { Backend, BackendRegistry } from "../backend/port.ts";
16
17
  import type { RunRegistry } from "../engine/run-registry.ts";
17
18
  import type { SingleSlotLock } from "../engine/concurrency-lock.ts";
18
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";
19
23
 
20
- type View = "fleet" | "agents";
24
+ type View = "fleet" | "lifecycle" | "agents" | "backends";
21
25
 
22
26
  export interface FleetPanelDeps {
23
27
  registry: Map<string, AgentDef>;
24
28
  runRegistry: RunRegistry;
25
29
  lock: SingleSlotLock;
26
30
  todoSync: TodoSyncPort;
27
- childFactory: ChildSessionFactory;
31
+ backendRegistry: BackendRegistry; // SPEC-3: replaces childFactory
28
32
  parentModel: { provider: string; id: string };
29
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">;
30
38
  }
31
39
 
32
40
  export interface FleetPanelOpts {
@@ -48,6 +56,17 @@ export class FleetPanel extends Container {
48
56
  private linkInput: Input | null = null;
49
57
  private linkPhase: "task" | "link" = "task";
50
58
  private infoAgent: AgentDef | null = null;
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;
51
70
 
52
71
  constructor(opts: FleetPanelOpts) {
53
72
  super();
@@ -67,7 +86,11 @@ export class FleetPanel extends Container {
67
86
  const items: SelectItem[] =
68
87
  this.view === "fleet"
69
88
  ? this.deps.runRegistry.list().map((r: RunRecord) => ({ value: r.runId, label: fleetRow(r) }))
70
- : [...this.deps.registry.values()].map((a: AgentDef) => ({ value: a.name, label: agentsRow(a) }));
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) }));
71
94
  const fresh = new SelectList(items, 12, {
72
95
  selectedPrefix: (s: string) => this.theme.fg("accent", s),
73
96
  selectedText: (s: string) => this.theme.fg("accent", s),
@@ -85,7 +108,7 @@ export class FleetPanel extends Container {
85
108
  this.children.length = 0;
86
109
  this.children.push(...keep);
87
110
  const accent = (s: string): string => this.theme.fg("accent", s);
88
- const tabs = (["fleet", "agents"] as View[])
111
+ const tabs = (["fleet", "lifecycle", "agents", "backends"] as View[])
89
112
  .map((v) => (v === this.view ? this.theme.fg("accent", this.theme.bold(`[${v}]`)) : this.theme.fg("dim", v)))
90
113
  .join(" ");
91
114
  this.addChild(new Text(accent(this.theme.bold(" FLEET")) + " " + tabs, 0, 0));
@@ -101,17 +124,53 @@ export class FleetPanel extends Container {
101
124
  for (const line of agentInfo(this.infoAgent).split("\n")) {
102
125
  this.addChild(new Text(this.theme.fg("text", line), 0, 0));
103
126
  }
127
+ } else if (this.selectedBackend) {
128
+ // SPEC-3: i:Info detail pane (backends view)
129
+ this.addChild(new Text(this.theme.fg("dim", " ── backend info ──"), 0, 0));
130
+ for (const line of backendInfo(this.selectedBackend).split("\n")) {
131
+ this.addChild(new Text(this.theme.fg("text", line), 0, 0));
132
+ }
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));
104
155
  } else {
105
156
  this.addChild(this.list);
106
157
  }
107
158
 
108
159
  this.addChild(new Spacer(1));
109
160
  const hint =
110
- this.infoAgent
161
+ this.infoAgent || this.selectedBackend || this.selectedLifecycle
111
162
  ? " esc:Back"
112
- : this.view === "fleet"
113
- ? " r:Run-new s:Stop o:Open-todo tab:Agents q:Quit"
114
- : " r:Run e:Edit i:Info d:Reload 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";
115
174
  this.addChild(new Text(this.theme.fg("dim", hint), 0, 0));
116
175
  this.addChild(new Spacer(1));
117
176
  this.addChild(new DynamicBorder(accent));
@@ -151,7 +210,7 @@ export class FleetPanel extends Container {
151
210
  agent, task, todoId, track: true,
152
211
  registry: this.deps.registry, todoSync: this.deps.todoSync,
153
212
  runRegistry: this.deps.runRegistry, lock: this.deps.lock,
154
- childFactory: this.deps.childFactory,
213
+ backendRegistry: this.deps.backendRegistry,
155
214
  parentModel: this.deps.parentModel, parentCwd: this.deps.parentCwd,
156
215
  // live Fleet row during the run (SPEC-1 §4c) — re-render on each turn_end
157
216
  onEvent: (e) => {
@@ -177,7 +236,11 @@ export class FleetPanel extends Container {
177
236
  }
178
237
 
179
238
  private switchView(): void {
180
- this.view = this.view === "fleet" ? "agents" : "fleet";
239
+ this.view = this.view === "fleet" ? "lifecycle"
240
+ : this.view === "lifecycle" ? "agents"
241
+ : this.view === "agents" ? "backends" : "fleet";
242
+ this.selectedBackend = null;
243
+ this.selectedLifecycle = null;
181
244
  this.list = this.buildList();
182
245
  this.renderShell();
183
246
  }
@@ -187,13 +250,36 @@ export class FleetPanel extends Container {
187
250
  if (matchesKey(data, "escape")) { this.infoAgent = null; this.renderShell(); }
188
251
  return;
189
252
  }
253
+ if (this.selectedBackend) {
254
+ if (matchesKey(data, "escape")) { this.selectedBackend = null; this.renderShell(); }
255
+ return;
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
+ }
190
267
  if (this.runMode && (this.taskInput || this.linkInput)) {
191
268
  if (matchesKey(data, "escape")) { this.cancelRun(); return; }
192
269
  (this.linkPhase === "task" ? this.taskInput! : this.linkInput!).handleInput(data);
193
270
  this.invalidate();
194
271
  return;
195
272
  }
196
- 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
+ }
197
283
  if (matchesKey(data, "tab")) { this.switchView(); return; }
198
284
  if (matchesKey(data, "q")) { this.onDone(); return; }
199
285
  if (matchesKey(data, "r") && this.view === "agents") {
@@ -206,9 +292,115 @@ export class FleetPanel extends Container {
206
292
  if (sel) { this.infoAgent = this.deps.registry.get(sel.value) ?? null; this.renderShell(); }
207
293
  return;
208
294
  }
295
+ if (matchesKey(data, "i") && this.view === "backends") {
296
+ const sel = this.list.getSelectedItem();
297
+ if (sel) { this.selectedBackend = this.deps.backendRegistry.list().find((x) => x.id === sel.value) ?? null; this.renderShell(); }
298
+ return;
299
+ }
300
+ if (matchesKey(data, "r") && this.view === "backends") {
301
+ this.onNotify("Backends reflect init-time detection; restart pi to re-detect.", "info");
302
+ this.renderShell();
303
+ return;
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
+ }
209
340
  this.list.handleInput(data);
210
341
  this.invalidate();
211
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
+ }
212
404
  }
213
405
 
214
406
  /** Factory used by src/index.ts to open the panel via ctx.ui.custom. */
package/src/panel/rows.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  import type { AgentDef } from "../registry/frontmatter.ts";
3
3
  import type { FleetRunStatus } from "../todo-sync/port.ts";
4
4
  import type { RunRecord } from "../engine/run-registry.ts";
5
+ import type { Backend, BackendHookParity } from "../backend/port.ts";
5
6
 
6
7
  export function fmtDuration(ms: number): string {
7
8
  const s = Math.floor(ms / 1000);
@@ -30,7 +31,7 @@ export function agentsRow(agent: AgentDef): string {
30
31
  const chip = `armory:[t${agent.todoSync ? "✓" : "✗"} m${agent.memoryHydrate ? "✓" : "✗"} v${agent.vision ? "✓" : "✗"}]`;
31
32
  const skills = agent.skills?.length ? ` skills: ${agent.skills.join(",")}` : "";
32
33
  const tools = agent.tools?.length ? ` tools: ${agent.tools.join(",")}` : "";
33
- return `${agent.name} [${agent.source}] ${model}${tools}${skills} ${chip}`;
34
+ return `${agent.name} [${agent.backend}] [${agent.source}] ${model}${tools}${skills} ${chip}`;
34
35
  }
35
36
 
36
37
  export function agentInfo(agent: AgentDef): string {
@@ -50,4 +51,68 @@ export function agentInfo(agent: AgentDef): string {
50
51
  agent.rolePrompt.trim(),
51
52
  ];
52
53
  return lines.join("\n");
53
- }
54
+ }
55
+ function chipStr(p: BackendHookParity): string {
56
+ return `t${p.todo} m${p.memory} v${p.vision}`;
57
+ }
58
+
59
+ export function backendsRow(b: Backend): string {
60
+ const avail = b.available() ? "✓" : "✗";
61
+ const vi = b.versionInfo();
62
+ const version = vi?.version ? vi.version : "—";
63
+ const schema = vi ? (vi.schemaOk ? "✓" : "✗") : "—";
64
+ const note = vi && !vi.schemaOk && vi.note ? ` ${vi.note}` : "";
65
+ return `${b.id} ${avail} ${version} schema:${schema} armory:[${chipStr(b.hookParity)}]${note}`;
66
+ }
67
+
68
+ export function backendInfo(b: Backend): string {
69
+ const vi = b.versionInfo();
70
+ const lines = [
71
+ `id: ${b.id}`,
72
+ `available: ${b.available() ? "✓" : "✗"}`,
73
+ `version: ${vi?.version ?? "—"}`,
74
+ `schemaOk: ${vi ? vi.schemaOk : "—"}`,
75
+ ];
76
+ if (vi?.note) lines.push(`note: ${vi.note}`);
77
+ lines.push("flagSupport:");
78
+ for (const [flag, ok] of Object.entries(vi?.flagSupport ?? {})) lines.push(` ${flag}: ${ok ? "✓" : "✗"}`);
79
+ lines.push("hookParity:");
80
+ lines.push(` todo: ${b.hookParity.todo} (excluded via ${b.id === "pi" ? "excludeTools+noExtensions" : "--disallowed-tools/prompt-nudge"})`);
81
+ lines.push(` memory: ${b.hookParity.memory} (${b.id === "pi" ? "CustomResourceLoader systemPromptOverride" : "--append-system-prompt"})`);
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
+ return lines.join("\n");
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
+ }
@@ -16,6 +16,10 @@ export interface AgentDef {
16
16
  todoSync: boolean;
17
17
  memoryHydrate: boolean;
18
18
  vision: boolean;
19
+ /** Cross-harness backend routing (SPEC-3). Invalid value → FrontmatterError. */
20
+ backend: "pi" | "claude";
21
+ /** Stable id for backend-native resume (SPEC-3). Defaults to name. */
22
+ sessionKey: string;
19
23
  source: AgentSource;
20
24
  filePath: string;
21
25
  }
@@ -52,6 +56,13 @@ export function parseAgentFile(content: string, filePath: string, source: AgentS
52
56
  const memoryHydrate = raw.memoryHydrate === undefined ? true : Boolean(raw.memoryHydrate);
53
57
  const vision = raw.vision === undefined ? true : Boolean(raw.vision);
54
58
 
59
+ const rawBackend = typeof raw.backend === "string" ? raw.backend.trim() : "pi";
60
+ if (rawBackend !== "pi" && rawBackend !== "claude") {
61
+ throw new FrontmatterError(`${filePath}: invalid backend '${rawBackend}' (must be 'pi' | 'claude')`);
62
+ }
63
+ const backend = rawBackend as "pi" | "claude";
64
+ const sessionKey = typeof raw.sessionKey === "string" && raw.sessionKey.trim() ? raw.sessionKey.trim() : name;
65
+
55
66
  return {
56
67
  name,
57
68
  description,
@@ -63,6 +74,8 @@ export function parseAgentFile(content: string, filePath: string, source: AgentS
63
74
  todoSync,
64
75
  memoryHydrate,
65
76
  vision,
77
+ backend,
78
+ sessionKey,
66
79
  source,
67
80
  filePath,
68
81
  };
@@ -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
  }
@@ -4,8 +4,11 @@ import type { AgentDef } from "../registry/frontmatter.ts";
4
4
  import type { TodoSyncPort } from "../todo-sync/port.ts";
5
5
  import type { RunRegistry } from "../engine/run-registry.ts";
6
6
  import type { SingleSlotLock } from "../engine/concurrency-lock.ts";
7
- import type { ChildSessionFactory, SpawnResult } from "../engine/spawnSubagent.ts";
7
+ import type { SpawnResult } from "../engine/spawnSubagent.ts";
8
8
  import { spawnSubagent } from "../engine/spawnSubagent.ts";
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";
9
12
 
10
13
  export const subagentParams = Type.Object({
11
14
  agent: Type.String({ description: "Agent name from the registry (builtin, project, or global)." }),
@@ -13,6 +16,8 @@ export const subagentParams = Type.Object({
13
16
  todoId: Type.Optional(Type.String({ description: "Explicit link to an existing open/in_progress armory-todo todo. Omit to create a fleet task." })),
14
17
  track: Type.Optional(Type.Boolean({ description: "Default true. Pass false only for throwaway lookups that don't represent real work." })),
15
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." })),
16
21
  });
17
22
 
18
23
  export type SubagentInput = Static<typeof subagentParams>;
@@ -22,9 +27,13 @@ export interface SubagentToolDeps {
22
27
  runRegistry: RunRegistry;
23
28
  lock: SingleSlotLock;
24
29
  todoSync: TodoSyncPort;
25
- childFactory: ChildSessionFactory;
30
+ backendRegistry: BackendRegistry; // SPEC-3: replaces childFactory
26
31
  parentModel: { provider: string; id: string };
27
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">;
28
37
  }
29
38
 
30
39
  /** Build the pi.registerTool definition. Thin wrapper over spawnSubagent. */
@@ -41,6 +50,30 @@ export function createSubagentTool(deps: SubagentToolDeps) {
41
50
  ],
42
51
  parameters: subagentParams,
43
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
+ }
44
77
  const res: SpawnResult = await spawnSubagent({
45
78
  agent: params.agent,
46
79
  task: params.task,
@@ -51,7 +84,7 @@ export function createSubagentTool(deps: SubagentToolDeps) {
51
84
  todoSync: deps.todoSync,
52
85
  runRegistry: deps.runRegistry,
53
86
  lock: deps.lock,
54
- childFactory: deps.childFactory,
87
+ backendRegistry: deps.backendRegistry,
55
88
  parentModel: deps.parentModel,
56
89
  parentCwd: deps.parentCwd,
57
90
  signal,