@getpipher/armory-fleet 0.3.0 → 0.5.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.
Files changed (36) hide show
  1. package/package.json +1 -1
  2. package/src/engine/run-registry.ts +13 -1
  3. package/src/engine/spawnSubagent.ts +33 -16
  4. package/src/index.ts +155 -2
  5. package/src/lifecycle/artifacts-parser.ts +57 -0
  6. package/src/lifecycle/default.ts +74 -0
  7. package/src/lifecycle/lifecycle-todo.ts +84 -0
  8. package/src/lifecycle/lifecycle-types.ts +66 -0
  9. package/src/lifecycle/port.ts +7 -0
  10. package/src/lifecycle/prompt-template.ts +40 -0
  11. package/src/lifecycle/registry.ts +169 -0
  12. package/src/lifecycle/run-lifecycle.ts +251 -0
  13. package/src/panel/bg-runs-store.ts +36 -0
  14. package/src/panel/fleet-items.ts +36 -0
  15. package/src/panel/fleet-panel.ts +334 -18
  16. package/src/panel/rows.ts +90 -0
  17. package/src/runtime/async-runner.ts +123 -0
  18. package/src/runtime/concurrency-pool.ts +27 -0
  19. package/src/runtime/results-inbox.ts +45 -0
  20. package/src/runtime/resume.ts +48 -0
  21. package/src/runtime/run-journal.ts +61 -0
  22. package/src/scheduling/expressions.ts +60 -0
  23. package/src/scheduling/pid-lock.ts +43 -0
  24. package/src/scheduling/scheduler.ts +147 -0
  25. package/src/todo-sync/adapter.ts +6 -0
  26. package/src/todo-sync/port.ts +2 -0
  27. package/src/tools/fleet-results.ts +35 -0
  28. package/src/tools/subagent.ts +58 -0
  29. package/src/vendor/cron-parser/NOTICE.md +23 -0
  30. package/src/vendor/cron-parser/lib/date.js +79 -0
  31. package/src/vendor/cron-parser/lib/expression.js +614 -0
  32. package/src/vendor/cron-parser/lib/number.js +8 -0
  33. package/src/vendor/cron-parser/lib/parser.js +103 -0
  34. package/src/vendor/cron-parser/types.d.ts +12 -0
  35. package/src/worktree/diff-service.ts +40 -0
  36. package/src/worktree/worktree-service.ts +92 -0
@@ -10,15 +10,20 @@ import {
10
10
  type SelectItem,
11
11
  } from "@earendil-works/pi-tui";
12
12
  import type { AgentDef } from "../registry/frontmatter.ts";
13
- import type { RunRecord } from "../engine/run-registry.ts";
14
- import { fleetRow, agentsRow, agentInfo, backendsRow, backendInfo } from "./rows.ts";
13
+ import { agentsRow, agentInfo, backendsRow, backendInfo, lifecycleRow, lifecyclePhaseTimeline, scheduleRow } from "./rows.ts";
14
+ import { buildFleetItems } from "./fleet-items.ts";
15
+ import type { Scheduler, Schedule } from "../scheduling/scheduler.ts";
16
+ import type { BgRunsStore } from "./bg-runs-store.ts";
15
17
  import { spawnSubagent, type SpawnResult } from "../engine/spawnSubagent.ts";
16
18
  import type { Backend, BackendRegistry } from "../backend/port.ts";
17
19
  import type { RunRegistry } from "../engine/run-registry.ts";
18
20
  import type { SingleSlotLock } from "../engine/concurrency-lock.ts";
19
21
  import type { TodoSyncPort } from "../todo-sync/port.ts";
22
+ import type { LifecycleDef, LifecycleRunRecord, CheckpointDecision, PhaseRecord } from "../lifecycle/lifecycle-types.ts";
23
+ import type { LifecycleRunDeps, CheckpointFn } from "../lifecycle/run-lifecycle.ts";
24
+ import { runLifecycle } from "../lifecycle/run-lifecycle.ts";
20
25
 
21
- type View = "fleet" | "agents" | "backends";
26
+ type View = "fleet" | "lifecycle" | "agents" | "backends" | "scheduled";
22
27
 
23
28
  export interface FleetPanelDeps {
24
29
  registry: Map<string, AgentDef>;
@@ -28,6 +33,14 @@ export interface FleetPanelDeps {
28
33
  backendRegistry: BackendRegistry; // SPEC-3: replaces childFactory
29
34
  parentModel: { provider: string; id: string };
30
35
  parentCwd: string;
36
+ /** SPEC-4: lifecycle registry + active/recent run records + deps to drive checkpoints. */
37
+ lifecycleRegistry: Map<string, LifecycleDef>;
38
+ lifecycleRuns: Map<string, LifecycleRunRecord>;
39
+ lifecycleDeps: Omit<LifecycleRunDeps, "spawn">;
40
+ /** SPEC-5a: scheduler for the scheduled tab. Optional — panel degrades to an empty list when absent. */
41
+ scheduler?: Scheduler;
42
+ /** SPEC-5a: live bg run status rows for the fleet tab. Optional. */
43
+ bgRuns?: BgRunsStore;
31
44
  }
32
45
 
33
46
  export interface FleetPanelOpts {
@@ -50,6 +63,27 @@ export class FleetPanel extends Container {
50
63
  private linkPhase: "task" | "link" = "task";
51
64
  private infoAgent: AgentDef | null = null;
52
65
  private selectedBackend: Backend | null = null; // SPEC-3: Backends view i:Info
66
+ private selectedLifecycle: LifecycleRunRecord | null = null; // SPEC-4: Lifecycle view i:Info
67
+ // SPEC-4: Run-lifecycle inline input state
68
+ private lcRunMode = false;
69
+ private lcTaskInput: Input | null = null;
70
+ private lcNameInput: Input | null = null;
71
+ private lcPhase: "task" | "name" = "task";
72
+ // SPEC-4: pending checkpoint (interactive Continue/Revise/Abort)
73
+ private pendingCheckpoint: { phase: PhaseRecord; resolve: (d: CheckpointDecision) => void } | null = null;
74
+ private lcReviseInput: Input | null = null;
75
+ private lcRevising = false;
76
+ // SPEC-5a: scheduled tab — add-schedule inline input state + selected schedule for i:Info
77
+ private schedRunMode = false;
78
+ private schedTaskInput: Input | null = null;
79
+ private schedExprInput: Input | null = null;
80
+ private schedNameInput: Input | null = null;
81
+ private schedPhase: "task" | "expr" | "name" = "task";
82
+ private selectedSchedule: Schedule | null = null;
83
+ /** SPEC-5a proper-fix: store-change subscriptions — fired by RunRegistry + BgRunsStore
84
+ * so the panel re-renders the moment a (fore- or back-ground) run mutates, without a keypress. */
85
+ private readonly unsubs: (() => void)[] = [];
86
+ private closed = false; // SPEC-5a proper-fix: guard against double-close calling onDone twice
53
87
 
54
88
  constructor(opts: FleetPanelOpts) {
55
89
  super();
@@ -63,15 +97,25 @@ export class FleetPanel extends Container {
63
97
  this.addChild(new Spacer(1));
64
98
  this.list = this.buildList();
65
99
  this.renderShell();
100
+
101
+ // SPEC-5a proper-fix: subscribe to run-registry + bg-runs mutations → live refresh.
102
+ // Covers both the model-invoked foreground case (spawnSubagent updates runRegistry)
103
+ // and the async/bg case (onProgress mutates BgRunsStore while the parent is idle).
104
+ this.unsubs.push(this.deps.runRegistry.subscribe(() => this.refresh()));
105
+ if (this.deps.bgRuns) this.unsubs.push(this.deps.bgRuns.subscribe(() => this.refresh()));
66
106
  }
67
107
 
68
108
  private buildList(): SelectList {
69
109
  const items: SelectItem[] =
70
110
  this.view === "fleet"
71
- ? 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) }));
111
+ ? buildFleetItems({ runRegistry: this.deps.runRegistry, bgRuns: this.deps.bgRuns })
112
+ : this.view === "lifecycle"
113
+ ? [...this.deps.lifecycleRuns.values()].map((l: LifecycleRunRecord) => ({ value: l.runId, label: lifecycleRow(l) }))
114
+ : this.view === "agents"
115
+ ? [...this.deps.registry.values()].map((a: AgentDef) => ({ value: a.name, label: agentsRow(a) }))
116
+ : this.view === "scheduled"
117
+ ? (this.deps.scheduler?.list() ?? []).map((s: Schedule) => ({ value: s.id, label: scheduleRow(s) }))
118
+ : this.deps.backendRegistry.list().map((b: Backend) => ({ value: b.id, label: backendsRow(b) }));
75
119
  const fresh = new SelectList(items, 12, {
76
120
  selectedPrefix: (s: string) => this.theme.fg("accent", s),
77
121
  selectedText: (s: string) => this.theme.fg("accent", s),
@@ -80,16 +124,36 @@ export class FleetPanel extends Container {
80
124
  noMatch: (s: string) => this.theme.fg("warning", s),
81
125
  });
82
126
  fresh.onSelect = (item: SelectItem) => this.onSelect(item.value);
83
- fresh.onCancel = () => this.onDone();
127
+ fresh.onCancel = () => this.close();
84
128
  return fresh;
85
129
  }
86
130
 
131
+ /** SPEC-5a proper-fix: re-pull from the run-registry + bg-runs stores and re-render.
132
+ * Called by the store-change subscriptions (constructor) on every mutation, so the
133
+ * fleet tab reflects completion/status changes without a keypress. Safe to call
134
+ * mid-overlay: renderShell preserves the active overlay (input/info/checkpoint). */
135
+ private refresh(): void {
136
+ this.list = this.buildList();
137
+ this.renderShell();
138
+ }
139
+
140
+ /** SPEC-5a proper-fix: tear down store subscriptions then close the panel.
141
+ * Every exit path routes here so listeners never leak past the panel's lifetime.
142
+ * Idempotent — safe to call multiple times (esc + q + pi teardown). */
143
+ private close(): void {
144
+ if (this.closed) return;
145
+ this.closed = true;
146
+ for (const u of this.unsubs) u();
147
+ this.unsubs.length = 0;
148
+ this.onDone();
149
+ }
150
+
87
151
  private renderShell(): void {
88
152
  const keep = this.children.slice(0, 2);
89
153
  this.children.length = 0;
90
154
  this.children.push(...keep);
91
155
  const accent = (s: string): string => this.theme.fg("accent", s);
92
- const tabs = (["fleet", "agents", "backends"] as View[])
156
+ const tabs = (["fleet", "lifecycle", "agents", "backends", "scheduled"] as View[])
93
157
  .map((v) => (v === this.view ? this.theme.fg("accent", this.theme.bold(`[${v}]`)) : this.theme.fg("dim", v)))
94
158
  .join(" ");
95
159
  this.addChild(new Text(accent(this.theme.bold(" FLEET")) + " " + tabs, 0, 0));
@@ -112,19 +176,66 @@ export class FleetPanel extends Container {
112
176
  this.addChild(new Text(this.theme.fg("text", line), 0, 0));
113
177
  }
114
178
  this.addChild(new Text(this.theme.fg("dim", " esc:Back"), 0, 0));
179
+ } else if (this.selectedLifecycle) {
180
+ // SPEC-4: i:Info detail pane (lifecycle view) — phase timeline
181
+ this.addChild(new Text(this.theme.fg("dim", " ── lifecycle phases ──"), 0, 0));
182
+ for (const line of lifecyclePhaseTimeline(this.selectedLifecycle).split("\n")) {
183
+ this.addChild(new Text(this.theme.fg("text", line), 0, 0));
184
+ }
185
+ this.addChild(new Text(this.theme.fg("dim", " esc:Back"), 0, 0));
186
+ } else if (this.selectedSchedule) {
187
+ // SPEC-5a: i:Info detail pane (scheduled view)
188
+ this.addChild(new Text(this.theme.fg("dim", " ── schedule info ──"), 0, 0));
189
+ const s = this.selectedSchedule;
190
+ for (const line of [
191
+ `id: ${s.id}`,
192
+ `expression: ${s.expression}`,
193
+ `lifecycle: ${s.lifecycle}`,
194
+ `task: "${s.task}"`,
195
+ `paused: ${s.paused}`,
196
+ `nextFire: ${s.nextFire?.toLocaleString() ?? "(none)"}`,
197
+ ]) this.addChild(new Text(this.theme.fg("text", line), 0, 0));
198
+ this.addChild(new Text(this.theme.fg("dim", " esc:Back"), 0, 0));
199
+ } else if (this.schedRunMode && (this.schedTaskInput || this.schedExprInput || this.schedNameInput)) {
200
+ const prompt = this.schedPhase === "task" ? " task> " : this.schedPhase === "expr" ? " schedule (cron | interval | one-shot ISO)> " : " lifecycle (blank=default)> ";
201
+ this.addChild(new Text(this.theme.fg("accent", prompt), 0, 0));
202
+ this.addChild(this.schedPhase === "task" ? this.schedTaskInput! : this.schedPhase === "expr" ? this.schedExprInput! : this.schedNameInput!);
203
+ this.addChild(new Text(this.theme.fg("dim", " enter submit • esc cancel"), 0, 0));
204
+ } else if (this.lcRunMode && (this.lcTaskInput || this.lcNameInput)) {
205
+ const prompt = this.lcPhase === "task" ? " task> " : " lifecycle name (blank=default)> ";
206
+ this.addChild(new Text(this.theme.fg("accent", prompt), 0, 0));
207
+ this.addChild(this.lcPhase === "task" ? this.lcTaskInput! : this.lcNameInput!);
208
+ this.addChild(new Text(this.theme.fg("dim", " enter submit • esc cancel"), 0, 0));
209
+ } else if (this.pendingCheckpoint && !this.lcRevising) {
210
+ const pc = this.pendingCheckpoint;
211
+ this.addChild(new Text(this.theme.fg("dim", ` ── checkpoint: phase '${pc.phase.name}' (${pc.phase.status}) ──`), 0, 0));
212
+ this.addChild(new Text(this.theme.fg("text", ` ${pc.phase.summary.slice(0, 200)}`), 0, 0));
213
+ this.addChild(new Text(this.theme.fg("dim", " c:Continue v:Revise a:Abort"), 0, 0));
214
+ } else if (this.lcRevising && this.lcReviseInput) {
215
+ this.addChild(new Text(this.theme.fg("accent", " revise feedback> "), 0, 0));
216
+ this.addChild(this.lcReviseInput);
217
+ this.addChild(new Text(this.theme.fg("dim", " enter submit • esc cancel"), 0, 0));
115
218
  } else {
116
219
  this.addChild(this.list);
117
220
  }
118
221
 
119
222
  this.addChild(new Spacer(1));
120
223
  const hint =
121
- this.infoAgent || this.selectedBackend
224
+ this.infoAgent || this.selectedBackend || this.selectedLifecycle || this.selectedSchedule
122
225
  ? " 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";
226
+ : this.pendingCheckpoint
227
+ ? " c:Continue v:Revise a:Abort"
228
+ : this.lcRevising
229
+ ? " enter:Submit-feedback esc:Cancel"
230
+ : this.view === "fleet"
231
+ ? " r:Run-new s:Stop o:Open-todo tab:Lifecycle q:Quit"
232
+ : this.view === "lifecycle"
233
+ ? " r:Run-lifecycle i:Info tab:Agents q:Quit"
234
+ : this.view === "agents"
235
+ ? " r:Run e:Edit i:Info d:Reload tab:Backends q:Quit"
236
+ : this.view === "scheduled"
237
+ ? " a:Add p:Pause/resume d:Delete i:Info tab:Fleet q:Quit"
238
+ : " r:Refresh i:Info tab:Fleet q:Quit";
128
239
  this.addChild(new Text(this.theme.fg("dim", hint), 0, 0));
129
240
  this.addChild(new Spacer(1));
130
241
  this.addChild(new DynamicBorder(accent));
@@ -190,8 +301,13 @@ export class FleetPanel extends Container {
190
301
  }
191
302
 
192
303
  private switchView(): void {
193
- this.view = this.view === "fleet" ? "agents" : this.view === "agents" ? "backends" : "fleet";
304
+ this.view = this.view === "fleet" ? "lifecycle"
305
+ : this.view === "lifecycle" ? "agents"
306
+ : this.view === "agents" ? "backends"
307
+ : this.view === "backends" ? "scheduled" : "fleet";
194
308
  this.selectedBackend = null;
309
+ this.selectedLifecycle = null;
310
+ this.selectedSchedule = null;
195
311
  this.list = this.buildList();
196
312
  this.renderShell();
197
313
  }
@@ -205,15 +321,44 @@ export class FleetPanel extends Container {
205
321
  if (matchesKey(data, "escape")) { this.selectedBackend = null; this.renderShell(); }
206
322
  return;
207
323
  }
324
+ if (this.selectedLifecycle) {
325
+ if (matchesKey(data, "escape")) { this.selectedLifecycle = null; this.renderShell(); }
326
+ return;
327
+ }
328
+ if (this.selectedSchedule) {
329
+ if (matchesKey(data, "escape")) { this.selectedSchedule = null; this.renderShell(); }
330
+ return;
331
+ }
332
+ if (this.schedRunMode && (this.schedTaskInput || this.schedExprInput || this.schedNameInput)) {
333
+ if (matchesKey(data, "escape")) { this.cancelScheduleAdd(); return; }
334
+ (this.schedPhase === "task" ? this.schedTaskInput! : this.schedPhase === "expr" ? this.schedExprInput! : this.schedNameInput!).handleInput(data);
335
+ this.invalidate();
336
+ return;
337
+ }
338
+ if (this.lcRunMode && (this.lcTaskInput || this.lcNameInput)) {
339
+ if (matchesKey(data, "escape")) { this.cancelLifecycleRun(); return; }
340
+ (this.lcPhase === "task" ? this.lcTaskInput! : this.lcNameInput!).handleInput(data);
341
+ this.invalidate();
342
+ return;
343
+ }
208
344
  if (this.runMode && (this.taskInput || this.linkInput)) {
209
345
  if (matchesKey(data, "escape")) { this.cancelRun(); return; }
210
346
  (this.linkPhase === "task" ? this.taskInput! : this.linkInput!).handleInput(data);
211
347
  this.invalidate();
212
348
  return;
213
349
  }
214
- if (matchesKey(data, "escape")) { this.onDone(); return; }
350
+ if (matchesKey(data, "escape")) {
351
+ // SPEC-4: if a lifecycle checkpoint is pending, resolve it as abort so runLifecycle
352
+ // doesn't hang + the lifecycle TODO is reverted (not orphaned) when the panel closes.
353
+ if (this.pendingCheckpoint) {
354
+ this.pendingCheckpoint.resolve({ action: "abort" });
355
+ this.pendingCheckpoint = null;
356
+ }
357
+ this.close();
358
+ return;
359
+ }
215
360
  if (matchesKey(data, "tab")) { this.switchView(); return; }
216
- if (matchesKey(data, "q")) { this.onDone(); return; }
361
+ if (matchesKey(data, "q")) { this.close(); return; }
217
362
  if (matchesKey(data, "r") && this.view === "agents") {
218
363
  const sel = this.list.getSelectedItem();
219
364
  if (sel) this.startRun(sel.value);
@@ -234,9 +379,180 @@ export class FleetPanel extends Container {
234
379
  this.renderShell();
235
380
  return;
236
381
  }
382
+ // SPEC-4: Lifecycle view — i:Info + r:Run-lifecycle
383
+ if (matchesKey(data, "i") && this.view === "lifecycle") {
384
+ const sel = this.list.getSelectedItem();
385
+ if (sel) { this.selectedLifecycle = this.deps.lifecycleRuns.get(sel.value) ?? null; this.renderShell(); }
386
+ return;
387
+ }
388
+ if (matchesKey(data, "r") && this.view === "lifecycle") {
389
+ this.startLifecycleRun();
390
+ return;
391
+ }
392
+ // SPEC-5a: scheduled view — a:Add p:Pause/resume d:Delete i:Info
393
+ if (this.view === "scheduled" && this.deps.scheduler) {
394
+ if (matchesKey(data, "a")) { this.startScheduleAdd(); return; }
395
+ if (matchesKey(data, "i")) {
396
+ const sel = this.list.getSelectedItem();
397
+ if (sel) { this.selectedSchedule = this.deps.scheduler.list().find((x) => x.id === sel.value) ?? null; this.renderShell(); }
398
+ return;
399
+ }
400
+ if (matchesKey(data, "p")) {
401
+ const sel = this.list.getSelectedItem();
402
+ if (sel) {
403
+ const s = this.deps.scheduler.list().find((x) => x.id === sel.value);
404
+ if (s) { s.paused ? this.deps.scheduler.resume(sel.value) : this.deps.scheduler.pause(sel.value); this.list = this.buildList(); this.renderShell(); }
405
+ }
406
+ return;
407
+ }
408
+ if (matchesKey(data, "d")) {
409
+ const sel = this.list.getSelectedItem();
410
+ if (sel) { this.deps.scheduler.delete(sel.value); this.list = this.buildList(); this.renderShell(); }
411
+ return;
412
+ }
413
+ }
414
+ // SPEC-4: pending checkpoint keys (c/v/a)
415
+ if (this.pendingCheckpoint && !this.lcRevising) {
416
+ if (matchesKey(data, "c")) { this.pendingCheckpoint.resolve({ action: "continue" }); this.pendingCheckpoint = null; this.renderShell(); return; }
417
+ if (matchesKey(data, "a")) { this.pendingCheckpoint!.resolve({ action: "abort" }); this.pendingCheckpoint = null; this.renderShell(); return; }
418
+ if (matchesKey(data, "v")) {
419
+ this.lcRevising = true;
420
+ this.lcReviseInput = new Input();
421
+ this.lcReviseInput.onSubmit = (fb: string) => {
422
+ this.lcRevising = false;
423
+ this.lcReviseInput = null;
424
+ this.pendingCheckpoint!.resolve({ action: "revise", feedback: fb });
425
+ this.pendingCheckpoint = null;
426
+ this.renderShell();
427
+ };
428
+ this.lcReviseInput.onEscape = () => { this.lcRevising = false; this.lcReviseInput = null; this.renderShell(); };
429
+ this.renderShell();
430
+ return;
431
+ }
432
+ }
433
+ if (this.lcRevising && this.lcReviseInput) {
434
+ if (matchesKey(data, "escape")) { this.lcRevising = false; this.lcReviseInput = null; this.renderShell(); return; }
435
+ this.lcReviseInput.handleInput(data);
436
+ this.invalidate();
437
+ return;
438
+ }
237
439
  this.list.handleInput(data);
238
440
  this.invalidate();
239
441
  }
442
+
443
+ /** SPEC-5a: open the Add-schedule inline inputs (task → expression → lifecycle name → register). */
444
+ private startScheduleAdd(): void {
445
+ this.schedPhase = "task";
446
+ this.schedTaskInput = new Input();
447
+ this.schedTaskInput.onSubmit = (task: string) => {
448
+ if (!task.trim()) { this.cancelScheduleAdd(); return; }
449
+ this.schedPhase = "expr";
450
+ this.schedExprInput = new Input();
451
+ this.schedExprInput.onSubmit = (expr: string) => {
452
+ if (!expr.trim()) { this.cancelScheduleAdd(); return; }
453
+ this.schedPhase = "name";
454
+ this.schedNameInput = new Input();
455
+ this.schedNameInput.onSubmit = (name: string) => {
456
+ const lcName = name.trim() || "default";
457
+ this.executeScheduleAdd(task.trim(), expr.trim(), lcName);
458
+ };
459
+ this.schedNameInput.onEscape = () => { this.executeScheduleAdd(task.trim(), expr.trim(), "default"); };
460
+ this.renderShell();
461
+ };
462
+ this.schedExprInput.onEscape = () => this.cancelScheduleAdd();
463
+ this.renderShell();
464
+ };
465
+ this.schedTaskInput.onEscape = () => this.cancelScheduleAdd();
466
+ this.schedRunMode = true;
467
+ this.renderShell();
468
+ }
469
+
470
+ private cancelScheduleAdd(): void {
471
+ this.schedRunMode = false;
472
+ this.schedTaskInput = null;
473
+ this.schedExprInput = null;
474
+ this.schedNameInput = null;
475
+ this.renderShell();
476
+ }
477
+
478
+ private executeScheduleAdd(task: string, expression: string, lifecycleName: string): void {
479
+ this.schedRunMode = false;
480
+ this.schedTaskInput = null;
481
+ this.schedExprInput = null;
482
+ this.schedNameInput = null;
483
+ if (!this.deps.scheduler) { this.onNotify("scheduling not configured", "error"); this.renderShell(); return; }
484
+ try {
485
+ const id = this.deps.scheduler.register({ task, expression, lifecycle: lifecycleName, auto: true });
486
+ this.list = this.buildList();
487
+ this.renderShell();
488
+ const entry = this.deps.scheduler.list().find((s) => s.id === id);
489
+ this.onNotify(`scheduled: ${id} · next fire: ${entry?.nextFire?.toLocaleString() ?? "(paused)"}`, "info");
490
+ } catch (e) {
491
+ this.onNotify(`schedule register failed: ${(e as Error).message}`, "error");
492
+ }
493
+ this.renderShell();
494
+ }
495
+
496
+ /** SPEC-4: open the Run-lifecycle inline inputs (task → lifecycle name → start runLifecycle). */
497
+ private startLifecycleRun(): void {
498
+ this.lcPhase = "task";
499
+ this.lcTaskInput = new Input();
500
+ this.lcTaskInput.onSubmit = (task: string) => {
501
+ if (!task.trim()) { this.cancelLifecycleRun(); return; }
502
+ this.lcPhase = "name";
503
+ this.lcNameInput = new Input();
504
+ this.lcNameInput.onSubmit = (name: string) => {
505
+ const lcName = name.trim() || "default";
506
+ void this.executeLifecycleRun(task.trim(), lcName);
507
+ };
508
+ this.lcNameInput.onEscape = () => { void this.executeLifecycleRun(task.trim(), "default"); };
509
+ this.renderShell();
510
+ };
511
+ this.lcTaskInput.onEscape = () => this.cancelLifecycleRun();
512
+ this.lcRunMode = true;
513
+ this.renderShell();
514
+ }
515
+
516
+ private cancelLifecycleRun(): void {
517
+ this.lcRunMode = false;
518
+ this.lcTaskInput = null;
519
+ this.lcNameInput = null;
520
+ this.renderShell();
521
+ }
522
+
523
+ private async executeLifecycleRun(task: string, lifecycleName: string): Promise<void> {
524
+ this.lcRunMode = false;
525
+ this.lcTaskInput = null;
526
+ this.lcNameInput = null;
527
+ this.renderShell();
528
+ if (!this.deps.lifecycleRegistry.has(lifecycleName)) {
529
+ this.onNotify(`lifecycle '${lifecycleName}' not found; available: ${[...this.deps.lifecycleRegistry.keys()].sort().join(", ")}`, "error");
530
+ return;
531
+ }
532
+ const onCheckpoint: CheckpointFn = (phase) => new Promise<CheckpointDecision>((resolve) => {
533
+ this.pendingCheckpoint = { phase, resolve };
534
+ this.renderShell();
535
+ });
536
+ const lifecycleFullDeps: LifecycleRunDeps = {
537
+ ...this.deps.lifecycleDeps,
538
+ spawn: async (o) => {
539
+ const { spawnSubagent } = await import("../engine/spawnSubagent.ts");
540
+ return spawnSubagent({
541
+ agent: o.agent, task: o.task, lifecycleTodoId: o.lifecycleTodoId, model: o.model,
542
+ skillsOverride: o.skills, backendOverride: o.backend,
543
+ registry: this.deps.registry, todoSync: this.deps.todoSync, runRegistry: this.deps.runRegistry, lock: this.deps.lock,
544
+ backendRegistry: this.deps.backendRegistry, parentModel: this.deps.parentModel, parentCwd: this.deps.parentCwd,
545
+ });
546
+ },
547
+ };
548
+ const res = await runLifecycle(task, lifecycleName, { deps: lifecycleFullDeps, mode: "checkpointed", onCheckpoint });
549
+ this.pendingCheckpoint = null;
550
+ // record the run so the Lifecycle view shows it
551
+ this.deps.lifecycleRuns.set(res.runId, res);
552
+ this.list = this.buildList();
553
+ this.renderShell();
554
+ this.onNotify(`lifecycle ${res.status}: ${res.runId}${res.error ? " — " + res.error : ""}`, res.status === "completed" ? "info" : "warning");
555
+ }
240
556
  }
241
557
 
242
558
  /** Factory used by src/index.ts to open the panel via ctx.ui.custom. */
package/src/panel/rows.ts CHANGED
@@ -82,3 +82,93 @@ 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
+
89
+ // SPEC-5a §11 — bg run row status (Q8=A). The fleet tab gains live status icons + phase progress
90
+ // for async/bg runs; foreground rows are unchanged.
91
+ export type BgStatus = "running" | "paused" | "completed" | "failed" | "queued";
92
+
93
+ export interface BgRunStatus {
94
+ runId: string;
95
+ lifecycle: string;
96
+ status: BgStatus;
97
+ phase: string;
98
+ phaseIndex: number;
99
+ phaseTotal: number;
100
+ mode: "auto" | "checkpointed";
101
+ backend: string;
102
+ task: string;
103
+ branch?: string;
104
+ elapsedMs?: number;
105
+ }
106
+
107
+ export function bgStatusIcon(s: BgStatus): string {
108
+ switch (s) {
109
+ case "running": return "▶";
110
+ case "paused": return "⏸";
111
+ case "completed": return "✓";
112
+ case "failed": return "✗";
113
+ case "queued": return "⏳";
114
+ }
115
+ }
116
+
117
+ export function renderBgRow(r: BgRunStatus): string {
118
+ const icon = bgStatusIcon(r.status);
119
+ const phase = r.phase ? `●${r.phase} ${r.phaseIndex}/${r.phaseTotal}` : `${r.phaseIndex}/${r.phaseTotal}`;
120
+ const branch = r.branch ? ` ${r.branch}` : "";
121
+ const elapsed = r.elapsedMs ? ` ${fmtDuration(r.elapsedMs)}` : "";
122
+ const task = r.task.length > 30 ? r.task.slice(0, 29) + "…" : r.task;
123
+ return `${icon} ${r.runId} ${r.lifecycle} ${phase} ${r.mode}${elapsed} ${r.backend}${branch} "${task}"`;
124
+ }
125
+
126
+ // SPEC-5a §11 — scheduled tab row rendering.
127
+ export interface ScheduleRow {
128
+ id: string;
129
+ expression: string;
130
+ lifecycle?: string;
131
+ task: string;
132
+ nextFire: Date | null;
133
+ paused: boolean;
134
+ }
135
+
136
+ export function scheduleRow(s: ScheduleRow): string {
137
+ const icon = s.paused ? "⏸" : "▶";
138
+ const next = s.nextFire ? `next: ${s.nextFire.toLocaleString()}` : "paused";
139
+ const task = s.task.length > 24 ? s.task.slice(0, 23) + "…" : s.task;
140
+ const lc = s.lifecycle ?? "default";
141
+ return `${icon} ${s.expression} ${lc} "${task}" ${next} ${s.id}`;
142
+ }
143
+
144
+ const LC_GLYPH: Record<LifecycleStatus, string> = {
145
+ running: "▶", checkpoint: "⏸", completed: "✓", failed: "✗", aborted: "✗",
146
+ };
147
+
148
+ export function lifecycleRow(r: LifecycleRunRecord): string {
149
+ const dur = r.endedAt ? fmtDuration(r.endedAt - r.startedAt) : "—";
150
+ const curIdx = r.phases.findIndex((p) => p.status === "running");
151
+ const cur = curIdx >= 0 ? r.phases[curIdx] : r.phases[r.phases.length - 1];
152
+ const curName = cur ? `●${cur.name}` : "—";
153
+ // N/M = current phase position / total (1-indexed); falls back to last phase when none running.
154
+ const counts = `${(curIdx >= 0 ? curIdx + 1 : r.phases.length)}/${r.phases.length}`;
155
+ return `${LC_GLYPH[r.status]} ${r.runId} ${r.lifecycleName} ${curName} ${counts} ${r.mode} ${dur} ${r.backend} "${r.task}"`;
156
+ }
157
+
158
+ export function lifecyclePhaseTimeline(r: LifecycleRunRecord): string {
159
+ const lines: string[] = [
160
+ `Lifecycle ${r.runId} — ${r.lifecycleName} — "${r.task}"`,
161
+ `Backend: ${r.backend} · Mode: ${r.mode} · Status: ${r.status}`,
162
+ "",
163
+ "Phases:",
164
+ ];
165
+ for (const p of r.phases) {
166
+ const mark = p.reviseCount > 0 ? "[~]" : p.status === "completed" ? "[x]" : "[ ]";
167
+ const art = p.paths.length ? ` → ${p.paths.join(", ")}` : "";
168
+ lines.push(` ${mark} ${p.name} ${p.status}${art}${p.paths.length ? " [Open]" : ""}`);
169
+ }
170
+ if (r.status === "checkpoint") {
171
+ lines.push("", "── Checkpoint ──", "[Continue] [Revise] [Abort]");
172
+ }
173
+ return lines.join("\n");
174
+ }
@@ -0,0 +1,123 @@
1
+ // src/runtime/async-runner.ts
2
+ // SPEC-5a §2/§6/§7/§8/§10 — the async/bg path. Layers ABOVE the unchanged runLifecycle:
3
+ // creates a worktree, journals events, drives runLifecycle with the worktree cwd, discovers
4
+ // artifacts via DiffService, commits on completion, pushes to the inbox, notifies.
5
+ import type { WorktreeService } from "../worktree/worktree-service.ts";
6
+ import type { DiffService } from "../worktree/diff-service.ts";
7
+ import type { RunJournal, JournalEvent } from "./run-journal.ts";
8
+ import type { ConcurrencyPool } from "./concurrency-pool.ts";
9
+ import type { ResultsInbox, RunResult } from "./results-inbox.ts";
10
+ import { execSync } from "node:child_process";
11
+
12
+ // Thin shape of the LifecycleRunResult we need (avoids importing the full type here — the
13
+ // real adapter in index.ts maps the full LifecycleRunResult to this shape).
14
+ export interface FakeLifecycleResult {
15
+ runId: string;
16
+ lifecycleName: string;
17
+ task: string;
18
+ status: "completed" | "failed" | "aborted";
19
+ phases: Array<{ name: string; status: string; summary: string; paths: string[]; reviseCount: number }>;
20
+ todoId: string | null;
21
+ error?: string;
22
+ }
23
+
24
+ export interface RunLifecycleOpts {
25
+ runId: string;
26
+ worktreePath: string;
27
+ branch: string;
28
+ mode: "auto" | "checkpointed";
29
+ }
30
+
31
+ export type RunLifecycleFn = (task: string, lifecycleName: string, opts: RunLifecycleOpts) => Promise<FakeLifecycleResult>;
32
+
33
+ export interface AsyncRunnerDeps {
34
+ worktree: WorktreeService;
35
+ diff: DiffService;
36
+ journal: RunJournal;
37
+ pool: ConcurrencyPool;
38
+ inbox: ResultsInbox;
39
+ runLifecycle: RunLifecycleFn;
40
+ notify: (msg: string, level?: "info" | "warning" | "error") => void;
41
+ genRunId: () => string;
42
+ /** SPEC-5a: called at each run/phase transition so the host (index.ts) can update the live bgRuns map. */
43
+ onProgress?: (runId: string, status: import("../panel/rows.ts").BgRunStatus) => void;
44
+ }
45
+
46
+ export interface RunBackgroundOpts {
47
+ deps: AsyncRunnerDeps;
48
+ lifecycle: string;
49
+ mode: "auto" | "checkpointed";
50
+ }
51
+
52
+ export interface RunBackgroundHandle {
53
+ runId: string;
54
+ status: "background";
55
+ }
56
+
57
+ function emitProgress(deps: AsyncRunnerDeps, runId: string, partial: Partial<import("../panel/rows.ts").BgRunStatus> & { status: import("../panel/rows.ts").BgStatus; phase: string; phaseIndex: number; phaseTotal: number }): void {
58
+ if (!deps.onProgress) return;
59
+ deps.onProgress(runId, {
60
+ runId,
61
+ lifecycle: "",
62
+ mode: "auto",
63
+ backend: "pi",
64
+ task: "",
65
+ ...partial,
66
+ });
67
+ }
68
+
69
+ function sh(cmd: string, cwd: string): void {
70
+ execSync(cmd, { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] });
71
+ }
72
+
73
+ export function runBackground(task: string, opts: RunBackgroundOpts): RunBackgroundHandle {
74
+ const { deps } = opts;
75
+ const runId = deps.genRunId();
76
+ const baseRef = "HEAD";
77
+
78
+ // Fire-and-forget: the pool gates concurrency; the journal records the run.
79
+ void deps.pool.withSlot(async () => {
80
+ let wt: { path: string; branch: string } | null = null;
81
+ try {
82
+ wt = deps.worktree.create(runId, baseRef);
83
+ const ev0: JournalEvent = { type: "run:started", runId, task, lifecycle: opts.lifecycle, worktree: { path: wt.path, branch: wt.branch }, mode: opts.mode, ts: Date.now() };
84
+ deps.journal.append(runId, ev0);
85
+ emitProgress(deps, runId, { status: "running", phase: "", phaseIndex: 0, phaseTotal: 0, lifecycle: opts.lifecycle, mode: opts.mode, task });
86
+
87
+ const res = await deps.runLifecycle(task, opts.lifecycle, { runId, worktreePath: wt.path, branch: wt.branch, mode: opts.mode });
88
+
89
+ if (res.status === "completed") {
90
+ // commit the worktree to the branch (lifecycle finish phase or single-delegate completion)
91
+ try { sh("git add -A && git commit -m 'fleet run complete'", wt.path); } catch { /* nothing to commit */ }
92
+ deps.journal.append(runId, { type: "run:completed", runId, branch: wt.branch, ts: Date.now() });
93
+ const total = res.phases.length;
94
+ const lastIdx = total; // completed = past the last phase
95
+ emitProgress(deps, runId, { status: "completed", phase: res.phases[total - 1]?.name ?? "finish", phaseIndex: lastIdx, phaseTotal: total, lifecycle: opts.lifecycle, mode: opts.mode, task, branch: wt.branch });
96
+ const lastPhase = res.phases[res.phases.length - 1];
97
+ const result: RunResult = {
98
+ runId, task, status: "completed",
99
+ summary: lastPhase?.summary ?? "",
100
+ paths: res.phases.flatMap((p) => p.paths),
101
+ branch: wt.branch, completedAt: Date.now(),
102
+ };
103
+ deps.inbox.push(result);
104
+ deps.notify(`fleet run ${runId} completed`, "info");
105
+ // SPEC-5a: the worktree dir is temporary scaffolding; remove it but keep the branch for merge/inspection.
106
+ deps.worktree.removeWorktree(runId);
107
+ } else {
108
+ deps.journal.append(runId, { type: "run:aborted", runId, reason: res.error ?? res.status, ts: Date.now() });
109
+ deps.worktree.remove(runId);
110
+ emitProgress(deps, runId, { status: "failed", phase: "", phaseIndex: 0, phaseTotal: res.phases.length, lifecycle: opts.lifecycle, mode: opts.mode, task });
111
+ deps.notify(`fleet run ${runId} ${res.status}: ${res.error ?? ""}`, "warning");
112
+ }
113
+ } catch (e) {
114
+ const msg = (e as Error).message;
115
+ deps.journal.append(runId, { type: "run:aborted", runId, reason: msg, ts: Date.now() });
116
+ if (wt) deps.worktree.remove(runId);
117
+ deps.notify(`fleet run ${runId} failed: ${msg}`, "error");
118
+ emitProgress(deps, runId, { status: "failed", phase: "", phaseIndex: 0, phaseTotal: 0, lifecycle: opts.lifecycle, mode: opts.mode, task });
119
+ }
120
+ });
121
+
122
+ return { runId, status: "background" };
123
+ }