@getpipher/armory-fleet 0.11.1 → 0.12.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 (37) hide show
  1. package/package.json +1 -1
  2. package/src/engine/concurrency-lock.ts +20 -15
  3. package/src/engine/spawnSubagent.ts +8 -2
  4. package/src/index.ts +104 -3
  5. package/src/panel/fleet-panel.ts +165 -11
  6. package/src/runtime/reconcile.ts +12 -9
  7. package/src/tools/fleet.ts +179 -0
  8. package/src/workflows/builtin/adversarial-review.js +19 -0
  9. package/src/workflows/builtin/code-review.js +13 -0
  10. package/src/workflows/builtin/codebase-audit.js +16 -0
  11. package/src/workflows/builtin/deep-research.js +12 -0
  12. package/src/workflows/builtin/multi-perspective.js +17 -0
  13. package/src/workflows/helpers/checkpoint.ts +15 -0
  14. package/src/workflows/helpers/completeness-check.ts +18 -0
  15. package/src/workflows/helpers/gate.ts +22 -0
  16. package/src/workflows/helpers/index.ts +8 -0
  17. package/src/workflows/helpers/judge-panel.ts +33 -0
  18. package/src/workflows/helpers/loop-until-dry.ts +21 -0
  19. package/src/workflows/helpers/retry.ts +17 -0
  20. package/src/workflows/helpers/types.ts +19 -0
  21. package/src/workflows/helpers/verify.ts +27 -0
  22. package/src/workflows/journal.ts +76 -0
  23. package/src/workflows/keyword.ts +22 -0
  24. package/src/workflows/panel/workflows-items.ts +150 -0
  25. package/src/workflows/panel/workflows-rows.ts +3 -0
  26. package/src/workflows/panel-host.ts +179 -0
  27. package/src/workflows/registry.ts +68 -0
  28. package/src/workflows/runner.ts +507 -0
  29. package/src/workflows/runtime/adapters.ts +182 -0
  30. package/src/workflows/runtime/controller.ts +493 -0
  31. package/src/workflows/runtime/hydrate.ts +116 -0
  32. package/src/workflows/runtime/pause-gate.ts +41 -0
  33. package/src/workflows/runtime/run-store.ts +31 -0
  34. package/src/workflows/runtime/save.ts +111 -0
  35. package/src/workflows/runtime/types.ts +78 -0
  36. package/src/workflows/source.ts +156 -0
  37. package/src/workflows/vm-realm.ts +106 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getpipher/armory-fleet",
3
- "version": "0.11.1",
3
+ "version": "0.12.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",
@@ -6,19 +6,24 @@ export interface SingleSlotLock {
6
6
  current(): string | null;
7
7
  }
8
8
 
9
+ export class SingleSlotLockImpl implements SingleSlotLock {
10
+ private holding: string | null = null;
11
+ tryAcquire(id: string): boolean {
12
+ if (this.holding !== null) return false;
13
+ this.holding = id;
14
+ return true;
15
+ }
16
+ release(): void {
17
+ this.holding = null;
18
+ }
19
+ current(): string | null {
20
+ return this.holding;
21
+ }
22
+ }
23
+
24
+ /** Alias so tests can `new SingleSlotLock()`. */
25
+ export const SingleSlotLock = SingleSlotLockImpl;
26
+
9
27
  export function createSingleSlotLock(): SingleSlotLock {
10
- let holding: string | null = null;
11
- return {
12
- tryAcquire(id): boolean {
13
- if (holding !== null) return false;
14
- holding = id;
15
- return true;
16
- },
17
- release(): void {
18
- holding = null;
19
- },
20
- current(): string | null {
21
- return holding;
22
- },
23
- };
24
- }
28
+ return new SingleSlotLock();
29
+ }
@@ -140,6 +140,8 @@ export interface SpawnOptions {
140
140
  tierRegistry?: TierRegistry;
141
141
  /** SPEC-6-1: model catalog for contextFloor filtering. Optional — absent means no catalog filtering. */
142
142
  modelRegistry?: ModelRegistryLike;
143
+ /** SPEC-6-3: workflow adapter tier override — replaces agent.tier before model resolution. */
144
+ tierOverride?: string;
143
145
  }
144
146
 
145
147
  export interface SpawnResult {
@@ -195,8 +197,12 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
195
197
  const childAgent = opts.skillsOverride ? { ...agentDef, skills: opts.skillsOverride } : agentDef;
196
198
 
197
199
  // SPEC-6-1: resolve model via tier registry (Q4 precedence + Q5 contextFloor/catalog filter).
200
+ // SPEC-6-3: workflow tierOverride replaces agent.tier before resolution.
201
+ const effectiveAgent = opts.tierOverride
202
+ ? { ...agentDef, tier: opts.tierOverride }
203
+ : agentDef;
198
204
  const resolved = resolveAgentModel(
199
- agentDef, opts.model, opts.parentModel,
205
+ effectiveAgent, opts.model, opts.parentModel,
200
206
  opts.tierRegistry ?? new TierRegistry({ tiers: [], agents: new Map() }),
201
207
  opts.modelRegistry ?? { find: () => undefined },
202
208
  );
@@ -410,4 +416,4 @@ async function finishRun(
410
416
  status, finalText, runId, todoId, agent: agentName, model,
411
417
  durationMs: endedAt - startedAt, tokenTotal, costTotal, contextTokens, error,
412
418
  };
413
- }
419
+ }
package/src/index.ts CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  } from "@earendil-works/pi-coding-agent";
10
10
  import type { Model } from "@earendil-works/pi-ai";
11
11
  import { createSubagentTool, type SubagentToolDeps } from "./tools/subagent.ts";
12
- import { openFleetPanel } from "./panel/fleet-panel.ts";
12
+ // SPEC-6-3: /fleet uses openWorkflowPanelLoop (Task 12) instead of the raw openFleetPanel factory.
13
13
  import { discoverAgents } from "./registry/discovery.ts";
14
14
  import { RunRegistry } from "./engine/run-registry.ts";
15
15
  import { createSingleSlotLock } from "./engine/concurrency-lock.ts";
@@ -49,6 +49,16 @@ import { TierRegistry, mergeTiers } from "./tiers/tier-registry.ts";
49
49
  import { BUILTIN_TIERS } from "./tiers/builtin.ts";
50
50
  import { TierStore } from "./tiers/tier-store.ts";
51
51
  import { splitModel } from "./tiers/resolve.ts";
52
+ import { WorkflowJournal } from "./workflows/journal.ts";
53
+ import { discoverWorkflows, WorkflowRegistry, type WorkflowDef } from "./workflows/registry.ts";
54
+ import { runWorkflow, type WorkflowRunDeps, type WorkflowRunResult } from "./workflows/runner.ts";
55
+ import { scanWorkflowResumeCandidates } from "./runtime/reconcile.ts";
56
+ import { WorkflowController } from "./workflows/runtime/controller.ts";
57
+ import { WorkflowRunStore } from "./workflows/runtime/run-store.ts";
58
+ import { createWorkflowAdapters } from "./workflows/runtime/adapters.ts";
59
+ import { openWorkflowPanelLoop } from "./workflows/panel-host.ts";
60
+ import { workflowKeywordHint } from "./workflows/keyword.ts";
61
+ import { createFleetTool } from "./tools/fleet.ts";
52
62
 
53
63
  /** The package builtin agents/ dir, resolved relative to this module. */
54
64
  function builtinAgentsDir(): string {
@@ -200,6 +210,11 @@ export default async function (pi: ExtensionAPI): Promise<void> {
200
210
  // SPEC-5b-2: the live widget (above editor) controller.
201
211
  // Display-only, independent of the /fleet panel; constructed per-session in session_start.
202
212
  let fleetWidget: FleetWidgetController | null = null;
213
+ // SPEC-6-3: hoisted so /fleet command handler + session_shutdown can reach them.
214
+ let wfController: WorkflowController | null = null;
215
+ let wfStore: WorkflowRunStore | null = null;
216
+ let wfRegistry: WorkflowRegistry | null = null;
217
+ let wfSessionAbort: AbortController | null = null;
203
218
  // The async runner's runLifecycle adapter: call the real runLifecycle with the worktree as the
204
219
  // spawn cwd + override genRunId so the lifecycle runId IS the async runner's runId (Q1=B seam).
205
220
  const asyncRunLifecycle: AsyncRunnerDeps["runLifecycle"] = async (task, lifecycleName, opts) => {
@@ -342,10 +357,86 @@ export default async function (pi: ExtensionAPI): Promise<void> {
342
357
  };
343
358
  deps.reloadTiers = reloadTiers;
344
359
  reloadTiers(); // build the real merged registry (replaces the builtin-only placeholder)
360
+
361
+ // SPEC-6-3: workflow journal + registry + runner + fleet tool wiring.
362
+ const workflowJournal = new WorkflowJournal(join(dir, "workflows"));
363
+ wfRegistry = new WorkflowRegistry(discoverWorkflows({
364
+ projectDir: join(ctx.cwd, ".pi", "fleet", "workflows"),
365
+ globalDir: join(process.env.HOME ?? "", ".pi", "agent", "fleet", "workflows"),
366
+ builtinDir: join(new URL(".", import.meta.url).pathname, "workflows", "builtin"),
367
+ }).workflows);
368
+ const workflowRegistry = wfRegistry;
369
+ // SPEC-6-3: production workflow child/lifecycle adapters (Task 5). Replace the inline spawn stub
370
+ // with the adapter factory so workflow-internal parallel calls use the per-workflow ConcurrencyPool
371
+ // + fresh per-child locks (not the foreground singleton), and lifecycle phase spawns share the pool.
372
+ wfSessionAbort = new AbortController();
373
+ const adapters = createWorkflowAdapters({
374
+ registry: deps.registry as unknown as Map<string, unknown>,
375
+ todoSync: deps.todoSync,
376
+ runRegistry: deps.runRegistry,
377
+ backendRegistry: deps.backendRegistry,
378
+ parentModel: deps.parentModel,
379
+ parentCwd: ctx.cwd,
380
+ runLog: deps.runLog,
381
+ tierRegistry: deps.tierRegistry,
382
+ modelRegistry: deps.modelRegistry,
383
+ lifecycleDeps: deps.lifecycleDeps,
384
+ spawnSubagentFn: async (opts) => { const { spawnSubagent } = await import("./engine/spawnSubagent.ts"); return spawnSubagent(opts); },
385
+ runLifecycleFn: async (task, name, lcOpts) => { const { runLifecycle } = await import("./lifecycle/run-lifecycle.ts"); return runLifecycle(task, name, lcOpts); },
386
+ }, { concurrency: 3, signal: wfSessionAbort.signal });
387
+ const wfRunnerDeps: WorkflowRunDeps = {
388
+ spawn: adapters.spawn,
389
+ runLifecycle: adapters.runLifecycle,
390
+ worktree: deps.asyncRunner.worktree,
391
+ tierRegistry: deps.tierRegistry ?? new TierRegistry({ tiers: BUILTIN_TIERS, agents: deps.registry }),
392
+ journal: workflowJournal,
393
+ runRegistry: deps.runRegistry,
394
+ getModelContextWindow: (m: string) => deps.getModelContextWindow?.(m),
395
+ genRunId: () => "wf-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8),
396
+ notify: (m: string, l?: "info" | "warning" | "error") => ctx.ui.notify(m, l ?? "info"),
397
+ onCheckpoint: async () => undefined,
398
+ resolveWorkflow: (name: string) => workflowRegistry.get(name) as { sourceText: string; executable: string } | undefined,
399
+ };
400
+ wfStore = new WorkflowRunStore();
401
+ wfController = new WorkflowController({
402
+ registry: workflowRegistry,
403
+ projectDir: join(ctx.cwd, ".pi", "fleet", "workflows"),
404
+ store: wfStore,
405
+ journal: workflowJournal,
406
+ runWorkflow,
407
+ runDepsFactory: () => wfRunnerDeps,
408
+ inbox: resultsInbox,
409
+ genRunId: wfRunnerDeps.genRunId,
410
+ notify: (m: string, l?: "info" | "warning" | "error") => ctx.ui.notify(m, l ?? "info"),
411
+ });
412
+ wfController.hydrate();
413
+ const wfCands = scanWorkflowResumeCandidates(join(dir, "workflows"));
414
+ if (wfCands.length > 0) {
415
+ ctx.ui.notify(`${wfCands.length} interrupted workflow${wfCands.length > 1 ? "s" : ""} — open /fleet Workflows to resume`, "info");
416
+ }
417
+ pi.registerTool(createFleetTool({
418
+ getController: () => {
419
+ if (!wfController) throw new Error("workflow runtime not initialized for this session");
420
+ return wfController;
421
+ },
422
+ }) as never);
345
423
  });
346
424
 
347
425
  pi.on("session_shutdown", () => {
348
426
  if (fleetWidget) { fleetWidget.dispose(); fleetWidget = null; }
427
+ // SPEC-6-3: abort in-flight workflow children via the session-wide adapter signal.
428
+ // Terminal runs are not re-journaled — only non-terminal spawns observe the abort.
429
+ wfSessionAbort?.abort();
430
+ });
431
+
432
+ // SPEC-6-3: inject bounded ResultsInbox + workflow-keyword hints into the system prompt without
433
+ // consuming the inbox. renderHint() is read-only; pull() consumes (left to the model's fleet.results).
434
+ pi.on("before_agent_start", async (event) => {
435
+ const hint = resultsInbox.renderHint();
436
+ const kw = workflowKeywordHint(event.prompt) ?? "";
437
+ if (!hint && !kw) return undefined;
438
+ const block = [hint, kw].filter(Boolean).join("\n");
439
+ return { systemPrompt: event.systemPrompt + "\n\n" + block };
349
440
  });
350
441
 
351
442
  pi.on("resources_discover", (event, ctx) => {
@@ -364,7 +455,17 @@ export default async function (pi: ExtensionAPI): Promise<void> {
364
455
  ctx.ui.notify("fleet panel is TUI-only; use the subagent tool in non-interactive modes.", "info");
365
456
  return;
366
457
  }
367
- openFleetPanel(deps, ctx as never);
458
+ await openWorkflowPanelLoop(
459
+ { ...deps, workflowController: wfController!, workflowStore: wfStore!, workflowRegistry: wfRegistry! },
460
+ {
461
+ custom: (factory) => { ctx.ui.custom(factory as never); },
462
+ editor: (initial) => ctx.ui.editor("Edit workflow source", initial) as Promise<string>,
463
+ input: (prompt) => ctx.ui.input(prompt) as Promise<string>,
464
+ confirm: (prompt) => ctx.ui.confirm(prompt, "Proceed?") as Promise<boolean>,
465
+ notify: (m, t) => ctx.ui.notify(m, t ?? "info"),
466
+ sendUserMessage: (text) => { void pi.sendUserMessage(text); },
467
+ },
468
+ );
368
469
  },
369
470
  });
370
471
 
@@ -423,4 +524,4 @@ export default async function (pi: ExtensionAPI): Promise<void> {
423
524
  ctx.ui.notify("fleet-register-gate: custom gates must be registered via the GateRegistry module export (see src/lifecycle/gates/registry.ts).", "info");
424
525
  },
425
526
  });
426
- }
527
+ }
@@ -29,8 +29,13 @@ import { runLifecycle } from "../lifecycle/run-lifecycle.ts";
29
29
  import type { TierRegistry } from "../tiers/tier-registry.ts";
30
30
  import type { TierStore } from "../tiers/tier-store.ts";
31
31
  import { buildTiersItems, setTierCostCap, setTierModels, setTierContextFloor, addTier, deleteTier } from "./tiers-items.ts";
32
+ import { buildWorkflowPanelItems, actionsForWorkflowItem, parseWorkflowPanelKey, type WorkflowPanelItem, type WorkflowPanelAction } from "../workflows/panel/workflows-rows.ts";
33
+ import type { WorkflowController } from "../workflows/runtime/controller.ts";
34
+ import type { WorkflowRunStore } from "../workflows/runtime/run-store.ts";
35
+ import type { WorkflowRegistry } from "../workflows/registry.ts";
36
+ import type { WorkflowPanelIntent } from "../workflows/panel-host.ts";
32
37
 
33
- type View = "fleet" | "lifecycle" | "runs" | "agents" | "backends" | "scheduled" | "tiers";
38
+ type View = "fleet" | "lifecycle" | "runs" | "agents" | "backends" | "scheduled" | "tiers" | "workflows";
34
39
 
35
40
  export interface FleetPanelDeps {
36
41
  registry: Map<string, AgentDef>;
@@ -58,19 +63,25 @@ export interface FleetPanelDeps {
58
63
  reloadTiers?: () => void;
59
64
  /** SPEC-6-1: model contextWindow resolver for Runs-tab ctx% (Surface C). Optional — ctx% hidden when absent. */
60
65
  getModelContextWindow?: (model: string) => number | undefined;
66
+ /** SPEC-6-3: live workflow controller + store + registry for the Workflows view. */
67
+ workflowController: WorkflowController;
68
+ workflowStore: WorkflowRunStore;
69
+ workflowRegistry: WorkflowRegistry;
70
+ /** SPEC-6-3: panel intent callback for host-only actions (Task 12 wires). */
71
+ onWorkflowIntent?: (intent: { action: string; runId?: string; definitionName?: string }) => void;
61
72
  }
62
73
 
63
74
  export interface FleetPanelOpts {
64
75
  theme: Theme;
65
76
  deps: FleetPanelDeps;
66
- onDone: () => void;
77
+ onDone: (intent?: WorkflowPanelIntent | null) => void;
67
78
  onNotify: (msg: string, type?: "info" | "warning" | "error") => void;
68
79
  }
69
80
 
70
81
  export class FleetPanel extends Container {
71
82
  private readonly theme: Theme;
72
83
  private readonly deps: FleetPanelDeps;
73
- private readonly onDone: () => void;
84
+ private readonly onDone: (intent?: WorkflowPanelIntent | null) => void;
74
85
  private readonly onNotify: (msg: string, type?: "info" | "warning" | "error") => void;
75
86
  private view: View = "fleet";
76
87
  private list: SelectList;
@@ -120,6 +131,10 @@ export class FleetPanel extends Container {
120
131
  * so the panel re-renders the moment a (fore- or back-ground) run mutates, without a keypress. */
121
132
  private readonly unsubs: (() => void)[] = [];
122
133
  private closed = false; // SPEC-5a proper-fix: guard against double-close calling onDone twice
134
+ // SPEC-6-3: Workflows view — inline Run prompt input state
135
+ private wfRunMode = false;
136
+ private wfPromptInput: Input | null = null;
137
+ private wfRunDefinitionName = "";
123
138
 
124
139
  constructor(opts: FleetPanelOpts) {
125
140
  super();
@@ -139,6 +154,8 @@ export class FleetPanel extends Container {
139
154
  // and the async/bg case (onProgress mutates BgRunsStore while the parent is idle).
140
155
  this.unsubs.push(this.deps.runRegistry.subscribe(() => this.refresh()));
141
156
  if (this.deps.bgRuns) this.unsubs.push(this.deps.bgRuns.subscribe(() => this.refresh()));
157
+ // SPEC-6-3: subscribe to workflow store mutations → live Workflows view refresh.
158
+ this.unsubs.push(this.deps.workflowStore.subscribe(() => this.refresh()));
142
159
  }
143
160
 
144
161
  private buildList(): SelectList {
@@ -155,7 +172,9 @@ export class FleetPanel extends Container {
155
172
  ? (this.deps.scheduler?.list() ?? []).map((s: Schedule) => ({ value: s.id, label: scheduleRow(s) }))
156
173
  : this.view === "tiers"
157
174
  ? (this.deps.tierRegistry ? buildTiersItems({ tierRegistry: this.deps.tierRegistry, runRegistry: this.deps.runRegistry }) : [])
158
- : this.deps.backendRegistry.list().map((b: Backend) => ({ value: b.id, label: backendsRow(b) }));
175
+ : this.view === "workflows"
176
+ ? buildWorkflowPanelItems({ definitions: this.deps.workflowRegistry.list(), runs: this.deps.workflowStore.values() })
177
+ : this.deps.backendRegistry.list().map((b: Backend) => ({ value: b.id, label: backendsRow(b) }));
159
178
  const fresh = new SelectList(items, 12, {
160
179
  selectedPrefix: (s: string) => this.theme.fg("accent", s),
161
180
  selectedText: (s: string) => this.theme.fg("accent", s),
@@ -180,12 +199,12 @@ export class FleetPanel extends Container {
180
199
  /** SPEC-5a proper-fix: tear down store subscriptions then close the panel.
181
200
  * Every exit path routes here so listeners never leak past the panel's lifetime.
182
201
  * Idempotent — safe to call multiple times (esc + q + pi teardown). */
183
- private close(): void {
202
+ private close(intent?: WorkflowPanelIntent | null): void {
184
203
  if (this.closed) return;
185
204
  this.closed = true;
186
205
  for (const u of this.unsubs) u();
187
206
  this.unsubs.length = 0;
188
- this.onDone();
207
+ this.onDone(intent ?? null);
189
208
  }
190
209
 
191
210
  private renderShell(): void {
@@ -193,7 +212,7 @@ export class FleetPanel extends Container {
193
212
  this.children.length = 0;
194
213
  this.children.push(...keep);
195
214
  const accent = (s: string): string => this.theme.fg("accent", s);
196
- const tabs = (["fleet", "lifecycle", "runs", "agents", "backends", "scheduled", "tiers"] as View[])
215
+ const tabs = (["fleet", "lifecycle", "runs", "agents", "backends", "scheduled", "tiers", "workflows"] as View[])
197
216
  .map((v) => (v === this.view ? this.theme.fg("accent", this.theme.bold(`[${v}]`)) : this.theme.fg("dim", v)))
198
217
  .join(" ");
199
218
  this.addChild(new Text(accent(this.theme.bold(" FLEET")) + " " + tabs, 0, 0));
@@ -309,6 +328,11 @@ export class FleetPanel extends Container {
309
328
  this.addChild(tl);
310
329
  }
311
330
  this.addChild(new Text(this.theme.fg("dim", " enter:Full-message esc:Back"), 0, 0));
331
+ } else if (this.wfRunMode && this.wfPromptInput) {
332
+ // SPEC-6-3: Workflows tab — inline Run prompt input.
333
+ this.addChild(new Text(this.theme.fg("accent", ` run ${this.wfRunDefinitionName}> `), 0, 0));
334
+ this.addChild(this.wfPromptInput);
335
+ this.addChild(new Text(this.theme.fg("dim", " enter submit (blank=direct run) • esc cancel"), 0, 0));
312
336
  } else if (this.steerMode && this.steerInput) {
313
337
  // SPEC-5b-4: Fleet tab — mid-run steer input.
314
338
  this.addChild(new Text(this.theme.fg("accent", " steer> "), 0, 0));
@@ -374,8 +398,10 @@ export class FleetPanel extends Container {
374
398
  : this.view === "scheduled"
375
399
  ? " a:Add p:Pause/resume d:Delete i:Info tab:Tiers q:Quit"
376
400
  : this.view === "tiers"
377
- ? " m:Models c:costCap f:contextFloor a:Add d:Delete g:scope tab:Fleet q:Quit"
378
- : " r:Refresh i:Info tab:Fleet q:Quit";
401
+ ? " m:Models c:costCap f:contextFloor a:Add d:Delete g:scope tab:Workflows q:Quit"
402
+ : this.view === "workflows"
403
+ ? " r:Run e:Edit-and-resume o:Open p:Pause u:Resume x:Stop s:Save-as v:View-result tab:Fleet q:Quit"
404
+ : " r:Refresh i:Info tab:Fleet q:Quit";
379
405
  this.addChild(new Text(this.theme.fg("dim", hint), 0, 0));
380
406
  this.addChild(new Spacer(1));
381
407
  this.addChild(new DynamicBorder(accent));
@@ -446,7 +472,7 @@ export class FleetPanel extends Container {
446
472
  : this.view === "lifecycle" ? "runs"
447
473
  : this.view === "runs" ? "agents"
448
474
  : this.view === "agents" ? "backends"
449
- : this.view === "backends" ? "scheduled" : this.view === "scheduled" ? "tiers" : "fleet";
475
+ : this.view === "backends" ? "scheduled" : this.view === "scheduled" ? "tiers" : this.view === "tiers" ? "workflows" : "fleet";
450
476
  this.selectedBackend = null;
451
477
  this.selectedLifecycle = null;
452
478
  this.selectedSchedule = null;
@@ -530,6 +556,12 @@ export class FleetPanel extends Container {
530
556
  this.invalidate();
531
557
  return;
532
558
  }
559
+ if (this.wfRunMode && this.wfPromptInput) {
560
+ if (matchesKey(data, "escape")) { this.cancelWorkflowRun(); return; }
561
+ this.wfPromptInput.handleInput(data);
562
+ this.invalidate();
563
+ return;
564
+ }
533
565
  if (this.steerMode && this.steerInput) {
534
566
  if (matchesKey(data, "escape")) { this.cancelSteer(); return; }
535
567
  this.steerInput.handleInput(data);
@@ -663,6 +695,95 @@ export class FleetPanel extends Container {
663
695
  return;
664
696
  }
665
697
  }
698
+ // SPEC-6-3: Workflows view — direct p/u/x controls + host-only intent completion
699
+ if (this.view === "workflows") {
700
+ const sel = this.list.getSelectedItem()
701
+ if (!sel) return
702
+ const parsed = parseWorkflowPanelKey(sel.value)
703
+ const item: WorkflowPanelItem = parsed.kind === "definition"
704
+ ? { kind: "definition", definition: this.deps.workflowRegistry.get(parsed.name) ?? { name: parsed.name, description: "", phases: [], sourceText: "", body: "", executable: "", source: "builtin", filePath: "" } }
705
+ : { kind: "run", run: this.deps.workflowStore.get(parsed.runId) ?? { runId: parsed.runId, name: parsed.runId, script: "", mode: "auto", status: "completed", startedAt: 0, currentPhase: "default", phases: [], childRunIds: [], logs: [], tokenTotal: 0, costTotal: 0 } }
706
+ const available = actionsForWorkflowItem(item)
707
+
708
+ const keyAction: Record<string, WorkflowPanelAction> = {
709
+ r: "run", e: "edit-resume", o: "open", p: "pause", u: "resume", x: "stop", s: "save", v: "view-result", c: "respond",
710
+ }
711
+ const action = keyAction[data]
712
+ if (!action) return
713
+ if (!available.includes(action)) {
714
+ this.onNotify(`action '${action}' not available for this item`, "warning")
715
+ return
716
+ }
717
+
718
+ // Direct controls (no panel close)
719
+ if (action === "pause") {
720
+ if (parsed.kind === "run") this.deps.workflowController.pause(parsed.runId)
721
+ return
722
+ }
723
+ if (action === "resume") {
724
+ if (parsed.kind === "run") {
725
+ void this.deps.workflowController.resume(parsed.runId).then(() => {
726
+ this.refresh()
727
+ }).catch((e: unknown) => {
728
+ this.onNotify(`resume failed: ${(e as Error).message}`, "error")
729
+ })
730
+ }
731
+ return
732
+ }
733
+ if (action === "stop") {
734
+ if (parsed.kind === "run") {
735
+ void this.deps.workflowController.stop(parsed.runId).then(() => {
736
+ this.refresh()
737
+ }).catch((e: unknown) => {
738
+ this.onNotify(`stop failed: ${(e as Error).message}`, "error")
739
+ })
740
+ }
741
+ return
742
+ }
743
+
744
+ // Run action: inline prompt input for definitions
745
+ if (action === "run" && parsed.kind === "definition") {
746
+ this.startWorkflowRun(parsed.name)
747
+ return
748
+ }
749
+
750
+ // Host-only actions: close panel with intent (Task 12 host loop handles them)
751
+ if (action === "run" && parsed.kind === "run") {
752
+ this.close({ action: "run", definitionName: parsed.runId, prompt: "" })
753
+ return
754
+ }
755
+ if (action === "edit-resume") {
756
+ if (parsed.kind === "run") this.close({ action: "edit-resume", runId: parsed.runId })
757
+ return
758
+ }
759
+ if (action === "open") {
760
+ if (parsed.kind === "definition") {
761
+ this.close({ action: "open-definition", name: parsed.name })
762
+ } else {
763
+ const run = this.deps.workflowStore.get(parsed.runId)
764
+ const childId = run?.childRunIds[0]
765
+ if (childId) {
766
+ this.close({ action: "open-child", runId: parsed.runId, childRunId: childId })
767
+ } else {
768
+ this.onNotify(`run '${parsed.runId}' has no child runs to open`, "info")
769
+ }
770
+ }
771
+ return
772
+ }
773
+ if (action === "save") {
774
+ if (parsed.kind === "run") this.close({ action: "save", runId: parsed.runId })
775
+ return
776
+ }
777
+ if (action === "view-result") {
778
+ if (parsed.kind === "run") this.close({ action: "view-result", runId: parsed.runId })
779
+ return
780
+ }
781
+ if (action === "respond") {
782
+ if (parsed.kind === "run") this.close({ action: "respond", runId: parsed.runId })
783
+ return
784
+ }
785
+ return
786
+ }
666
787
  // SPEC-4: pending checkpoint keys (c/v/a)
667
788
  if (this.pendingCheckpoint && !this.lcRevising) {
668
789
  if (matchesKey(data, "c")) { this.pendingCheckpoint.resolve({ action: "continue" }); this.pendingCheckpoint = null; this.renderShell(); return; }
@@ -840,6 +961,39 @@ export class FleetPanel extends Container {
840
961
  this.renderShell();
841
962
  }
842
963
 
964
+ // ───────────────────────────────── SPEC-6-3: Workflows Run prompt ─────────────────────────────────
965
+
966
+ private startWorkflowRun(definitionName: string): void {
967
+ this.wfRunDefinitionName = definitionName;
968
+ this.wfPromptInput = new Input();
969
+ this.wfPromptInput.onSubmit = (prompt: string) => {
970
+ if (prompt.trim()) {
971
+ this.close({ action: "run", definitionName: this.wfRunDefinitionName, prompt: prompt.trim() });
972
+ } else {
973
+ // Blank prompt → execute the definition directly
974
+ void this.deps.workflowController.start({
975
+ workflowName: this.wfRunDefinitionName,
976
+ mode: "checkpointed",
977
+ }).then(() => {
978
+ this.refresh();
979
+ }).catch((e: unknown) => {
980
+ this.onNotify(`start failed: ${(e as Error).message}`, "error");
981
+ });
982
+ }
983
+ this.cancelWorkflowRun();
984
+ };
985
+ this.wfPromptInput.onEscape = () => this.cancelWorkflowRun();
986
+ this.wfRunMode = true;
987
+ this.renderShell();
988
+ }
989
+
990
+ private cancelWorkflowRun(): void {
991
+ this.wfRunMode = false;
992
+ this.wfPromptInput = null;
993
+ this.wfRunDefinitionName = "";
994
+ this.renderShell();
995
+ }
996
+
843
997
  private cancelTiersEdit(): void {
844
998
  this.tiersEditPhase = null;
845
999
  this.tiersInput = null;
@@ -1031,4 +1185,4 @@ export function openFleetPanel(
1031
1185
  ctx.ui.custom((_tui, theme, _kb, done) => {
1032
1186
  return new FleetPanel({ theme, deps, onDone: done, onNotify: (m, t) => ctx.ui.notify(m, t) });
1033
1187
  });
1034
- }
1188
+ }
@@ -1,12 +1,7 @@
1
1
  // src/runtime/reconcile.ts
2
- // SPEC-5b-1on pi boot, mark orphan RunLog runs (run:meta with no run:ended whose process
3
- // is gone) as aborted so the Runs tab doesn't show stale "running" rows across restarts.
4
- // Foreground orphans; bg/lifecycle orphans are already handled by scanResumeCandidates (SPEC-5a).
5
- //
6
- // SPEC-6-2: rewritten to be probe-driven — uses `probeRun` (handle/pid/age-fallback) instead of
7
- // age+grace alone. This catches orphans whose process died but whose startedAt is within grace
8
- // (e.g. a crash seconds after start), and avoids aborting runs whose process is alive but old
9
- // (e.g. a long-running build).
2
+ // SPEC-6-3restart-recovery: scan workflow journals for non-terminal runs
3
+ // (interrupted workflows become resume candidates in the Workflows view).
4
+ import { WorkflowJournal } from "../workflows/journal.ts";
10
5
  import type { RunLog } from "./run-log.ts";
11
6
  import type { RunRegistry, RunRecord } from "../engine/run-registry.ts";
12
7
 
@@ -52,4 +47,12 @@ export function reconcileRuns(log: RunLog, opts: ReconcileOpts = {}): string[] {
52
47
  aborted.push(meta.runId);
53
48
  }
54
49
  return aborted;
55
- }
50
+ }
51
+
52
+ /** SPEC-6-3 — on pi start, scan .pi/fleet/workflows/ for non-terminal workflow journals.
53
+ * Returns runIds whose journal has no wf:completed/wf:aborted event — these are
54
+ * interrupted workflows that the Workflows view surfaces as resume candidates. */
55
+ export function scanWorkflowResumeCandidates(workflowsDir: string): string[] {
56
+ const journal = new WorkflowJournal(workflowsDir);
57
+ return journal.scanNonTerminal();
58
+ }