@getpipher/armory-fleet 0.4.0 → 0.5.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getpipher/armory-fleet",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
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",
@@ -23,11 +23,15 @@ export function genRunId(): string {
23
23
  return "fl-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8);
24
24
  }
25
25
 
26
+ export type RunRegistryChangeListener = () => void;
27
+
26
28
  export class RunRegistry {
27
29
  private readonly runs = new Map<string, RunRecord>();
30
+ private readonly listeners = new Set<RunRegistryChangeListener>();
28
31
 
29
32
  add(r: RunRecord): void {
30
33
  this.runs.set(r.runId, r);
34
+ this.emit();
31
35
  }
32
36
  get(id: string): RunRecord | undefined {
33
37
  return this.runs.get(id);
@@ -38,6 +42,14 @@ export class RunRegistry {
38
42
  }
39
43
  update(id: string, patch: Partial<Omit<RunRecord, "runId">>): void {
40
44
  const r = this.runs.get(id);
41
- if (r) this.runs.set(id, { ...r, ...patch });
45
+ if (r) { this.runs.set(id, { ...r, ...patch }); this.emit(); }
46
+ }
47
+ /** SPEC-5a proper-fix: subscribe to add/update mutations. Returns an unsubscribe fn. */
48
+ subscribe(fn: RunRegistryChangeListener): () => void {
49
+ this.listeners.add(fn);
50
+ return () => { this.listeners.delete(fn); };
51
+ }
52
+ private emit(): void {
53
+ for (const fn of this.listeners) fn();
42
54
  }
43
55
  }
package/src/index.ts CHANGED
@@ -30,6 +30,16 @@ import { discoverLifecycles } from "./lifecycle/registry.ts";
30
30
  import { DEFAULT_LIFECYCLE, builtinLifecyclesDir } from "./lifecycle/default.ts";
31
31
  import type { LifecycleDef } from "./lifecycle/lifecycle-types.ts";
32
32
  import type { LifecycleRunDeps } from "./lifecycle/run-lifecycle.ts";
33
+ import { WorktreeService } from "./worktree/worktree-service.ts";
34
+ import { DiffService } from "./worktree/diff-service.ts";
35
+ import { RunJournal } from "./runtime/run-journal.ts";
36
+ import { ConcurrencyPool } from "./runtime/concurrency-pool.ts";
37
+ import { ResultsInbox } from "./runtime/results-inbox.ts";
38
+ import { runBackground, type AsyncRunnerDeps } from "./runtime/async-runner.ts";
39
+ import { scanResumeCandidates } from "./runtime/resume.ts";
40
+ import { Scheduler } from "./scheduling/scheduler.ts";
41
+ import { createFleetResultsTool } from "./tools/fleet-results.ts";
42
+ import { BgRunsStore } from "./panel/bg-runs-store.ts";
33
43
 
34
44
  /** The package builtin agents/ dir, resolved relative to this module. */
35
45
  function builtinAgentsDir(): string {
@@ -151,6 +161,35 @@ export default async function (pi: ExtensionAPI): Promise<void> {
151
161
  deps.lifecycleDeps.registry = deps.lifecycleRegistry;
152
162
  deps.lifecycleDeps.agentRegistry = deps.registry;
153
163
 
164
+ // ── SPEC-5a: operational runtime (async/bg + scheduling + worktree isolation) ──
165
+ const fleetDir = (cwd: string) => join(cwd, ".pi", "fleet");
166
+ const bgRuns = new BgRunsStore();
167
+ const resultsInbox = new ResultsInbox();
168
+ // The async runner's runLifecycle adapter: call the real runLifecycle with the worktree as the
169
+ // spawn cwd + override genRunId so the lifecycle runId IS the async runner's runId (Q1=B seam).
170
+ const asyncRunLifecycle: AsyncRunnerDeps["runLifecycle"] = async (task, lifecycleName, opts) => {
171
+ const { runLifecycle } = await import("./lifecycle/run-lifecycle.ts");
172
+ const { spawnSubagent } = await import("./engine/spawnSubagent.ts");
173
+ const lifecycleFullDeps: LifecycleRunDeps = {
174
+ ...deps.lifecycleDeps,
175
+ genRunId: () => opts.runId, // override: use the async runner's runId
176
+ // SPEC-5a (Q3=A): isolated run — worktree-diff artifact discovery instead of the prompt-baked block.
177
+ artifactDiscovery: ({ finalText, cwd, baseRef }) => (deps.asyncRunner as AsyncRunnerDeps).diff.diffPhase(cwd, baseRef, finalText),
178
+ spawn: async (o) => spawnSubagent({
179
+ agent: o.agent, task: o.task, lifecycleTodoId: o.lifecycleTodoId, model: o.model,
180
+ skillsOverride: o.skills, backendOverride: o.backend,
181
+ registry: deps.registry, todoSync: deps.todoSync, runRegistry: deps.runRegistry, lock: deps.lock,
182
+ backendRegistry: deps.backendRegistry, parentModel: deps.parentModel, parentCwd: opts.worktreePath, // child runs in the worktree
183
+ }),
184
+ };
185
+ const res = await runLifecycle(task, lifecycleName, { deps: lifecycleFullDeps, mode: opts.mode, worktreePath: opts.worktreePath, baseRef: "HEAD", onCheckpoint: async (p) => p.status === "failed" ? { action: "abort" } : { action: "continue" } });
186
+ return res as unknown as import("./runtime/async-runner.ts").FakeLifecycleResult;
187
+ };
188
+ // asyncRunnerDeps + scheduler are built per-session (need the session cwd); wired on session_start.
189
+ // At init they're undefined — the subagent tool's `if (!deps.asyncRunner)` guard returns an
190
+ // actionable "not configured" error if called before session_start (can't happen in practice).
191
+ deps.bgRuns = bgRuns;
192
+
154
193
  const refresh = (ctx: { cwd: string; ui: { notify: (m: string, t?: "info" | "warning" | "error") => void } }): void => {
155
194
  const r = discoverAgents({
156
195
  projectDir: join(ctx.cwd, ".pi", "agents"),
@@ -184,6 +223,32 @@ export default async function (pi: ExtensionAPI): Promise<void> {
184
223
  const m = ctx.model;
185
224
  deps.parentModel = m ? { provider: m.provider, id: m.id } : { provider: "", id: "" };
186
225
  deps.parentCwd = ctx.cwd;
226
+ // SPEC-5a: build the per-session async runner + scheduler, start firing, scan for interrupted runs.
227
+ const dir = fleetDir(ctx.cwd);
228
+ deps.asyncRunner = {
229
+ worktree: new WorktreeService({ rootDir: ctx.cwd }),
230
+ diff: new DiffService(),
231
+ journal: new RunJournal(join(dir, "runs")),
232
+ pool: new ConcurrencyPool(3),
233
+ inbox: resultsInbox,
234
+ runLifecycle: asyncRunLifecycle,
235
+ notify: (m, lvl) => ctx.ui.notify(m, lvl),
236
+ genRunId: () => "fl-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8),
237
+ onProgress: (runId, status) => { bgRuns.set(runId, status); },
238
+ };
239
+ deps.scheduler = new Scheduler({
240
+ storePath: join(dir, "schedules.json"),
241
+ lockPath: join(dir, "schedules.lock"),
242
+ onFire: (spec) => {
243
+ if (!deps.asyncRunner) return;
244
+ runBackground(spec.task, { deps: deps.asyncRunner, lifecycle: spec.lifecycle ?? "default", mode: spec.auto ? "auto" : "checkpointed" });
245
+ },
246
+ });
247
+ deps.scheduler.start();
248
+ const cands = scanResumeCandidates(ctx.cwd, { runsDir: join(dir, "runs"), worktree: deps.asyncRunner.worktree });
249
+ if (cands.length > 0) {
250
+ ctx.ui.notify(`${cands.length} interrupted fleet run${cands.length > 1 ? "s" : ""} — open /fleet to resume`, "info");
251
+ }
187
252
  });
188
253
 
189
254
  pi.on("resources_discover", (event, ctx) => {
@@ -192,6 +257,8 @@ export default async function (pi: ExtensionAPI): Promise<void> {
192
257
  });
193
258
 
194
259
  pi.registerTool(createSubagentTool(deps) as never);
260
+ // SPEC-5a: fleet.results — the agent pulls completed bg-run results from the inbox (Q6=C).
261
+ pi.registerTool(createFleetResultsTool({ inbox: resultsInbox }) as never);
195
262
 
196
263
  pi.registerCommand("fleet", {
197
264
  description: "Open the armory-fleet panel (running + recent subagents + agent registry).",
@@ -33,12 +33,20 @@ export interface LifecycleRunDeps {
33
33
  /** Resolve the backend for a phase: phase.backend → lifecycle.backend → "pi" (+ availability check). */
34
34
  resolveBackend: (phaseBackend: BackendId | undefined, lifecycleBackend: BackendId) => BackendId;
35
35
  genRunId: () => string;
36
+ /** SPEC-5a (Q3=A): when present, isolated runs use worktree-diff artifact discovery
37
+ * instead of the prompt-baked `Artifacts:` block parser. Foreground runs leave this undefined. */
38
+ artifactDiscovery?: (o: { finalText: string; cwd: string; baseRef: string; terminal: boolean }) => { summary: string; paths: string[] } | { error: string };
36
39
  }
37
40
 
38
41
  export interface LifecycleRunOpts {
39
42
  deps: LifecycleRunDeps;
40
43
  mode: LifecycleMode;
41
44
  onCheckpoint: CheckpointFn;
45
+ /** SPEC-5a (Q3=A): the worktree path for isolated runs. When set + deps.artifactDiscovery is present,
46
+ * artifact discovery uses worktree-diff instead of parseArtifacts. Foreground runs leave this undefined. */
47
+ worktreePath?: string;
48
+ /** SPEC-5a: the base ref to diff against (default "HEAD"). */
49
+ baseRef?: string;
42
50
  }
43
51
 
44
52
  export interface LifecycleRunResult {
@@ -143,6 +151,14 @@ export async function runLifecycle(task: string, lifecycleName: string, opts: Li
143
151
  let phaseRec: PhaseRecord;
144
152
  if (spawnRes.status === "failed") {
145
153
  phaseRec = { name: phaseDef.name, summary: spawnRes.error ?? spawnRes.finalText.slice(0, 120), paths: [], status: "failed", reviseCount };
154
+ } else if (deps.artifactDiscovery && opts.worktreePath) {
155
+ // SPEC-5a (Q3=A): isolated run — structural worktree-diff (robust to models that omit the Artifacts block).
156
+ const art = deps.artifactDiscovery({ finalText: spawnRes.finalText, cwd: opts.worktreePath, baseRef: opts.baseRef ?? "HEAD", terminal: isTerminal });
157
+ if ("error" in art) {
158
+ phaseRec = { name: phaseDef.name, summary: art.error, paths: [], status: "failed", reviseCount };
159
+ } else {
160
+ phaseRec = { name: phaseDef.name, summary: art.summary, paths: art.paths, status: "completed", reviseCount };
161
+ }
146
162
  } else {
147
163
  const art = parseArtifacts(spawnRes.finalText, { terminal: isTerminal });
148
164
  if ("error" in art) {
@@ -0,0 +1,36 @@
1
+ // src/panel/bg-runs-store.ts
2
+ // SPEC-5a proper-fix: a change-emitting store for live bg-run status rows.
3
+ //
4
+ // Replaces the bare `Map<string, BgRunStatus>` that `onProgress` used to mutate.
5
+ // The async runner's `onProgress` callback writes here on every phase transition
6
+ // and on completion; the /fleet panel subscribes so it re-renders the moment a
7
+ // bg run's status changes — even while the parent is idle (no `turn_end` fires
8
+ // for an async/fire-and-forget run). This is the SPEC-5b live-widget seam,
9
+ // delivered early as the proper fix for the stale "running" row bug.
10
+ import type { BgRunStatus } from "./rows.ts";
11
+
12
+ export type BgRunsChangeListener = (runId: string) => void;
13
+
14
+ export class BgRunsStore {
15
+ private readonly runs = new Map<string, BgRunStatus>();
16
+ private readonly listeners = new Set<BgRunsChangeListener>();
17
+
18
+ set(runId: string, status: BgRunStatus): void {
19
+ this.runs.set(runId, status);
20
+ for (const fn of this.listeners) fn(runId);
21
+ }
22
+
23
+ get(runId: string): BgRunStatus | undefined {
24
+ return this.runs.get(runId);
25
+ }
26
+
27
+ values(): IterableIterator<BgRunStatus> {
28
+ return this.runs.values();
29
+ }
30
+
31
+ /** Subscribe to status changes. Returns an unsubscribe function. */
32
+ subscribe(fn: BgRunsChangeListener): () => void {
33
+ this.listeners.add(fn);
34
+ return () => { this.listeners.delete(fn); };
35
+ }
36
+ }
@@ -0,0 +1,36 @@
1
+ // src/panel/fleet-items.ts
2
+ // SPEC-5a proper-fix: pure builder for the /fleet fleet-tab list items.
3
+ //
4
+ // Extracted from FleetPanel.buildList so the merge + dedup of foreground
5
+ // (RunRegistry) rows and live bg (BgRunsStore) rows is unit-testable without
6
+ // a TUI harness. Foreground rows render via `fleetRow`; bg rows via
7
+ // `renderBgRow` (the Q8=A live status icons + phase progress). The two stores
8
+ // are disjoint by runId in practice (bg runs never enter RunRegistry under
9
+ // their own runId; the lifecycle's child spawns use their own runIds), but we
10
+ // dedup defensively in case a future change overlaps them.
11
+ import type { RunRecord } from "../engine/run-registry.ts";
12
+ import { fleetRow, renderBgRow, type BgRunStatus } from "./rows.ts";
13
+ import type { SelectItem } from "@earendil-works/pi-tui";
14
+
15
+ export interface FleetItemSources {
16
+ runRegistry: { list(): RunRecord[] };
17
+ bgRuns?: { values(): IterableIterator<BgRunStatus> };
18
+ }
19
+
20
+ export function buildFleetItems(src: FleetItemSources): SelectItem[] {
21
+ const items: SelectItem[] = [];
22
+ const seen = new Set<string>();
23
+ for (const r of src.runRegistry.list()) {
24
+ if (seen.has(r.runId)) continue;
25
+ seen.add(r.runId);
26
+ items.push({ value: r.runId, label: fleetRow(r) });
27
+ }
28
+ if (src.bgRuns) {
29
+ for (const b of src.bgRuns.values()) {
30
+ if (seen.has(b.runId)) continue;
31
+ seen.add(b.runId);
32
+ items.push({ value: b.runId, label: renderBgRow(b) });
33
+ }
34
+ }
35
+ return items;
36
+ }
@@ -10,8 +10,10 @@ 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, lifecycleRow, lifecyclePhaseTimeline } 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";
@@ -21,7 +23,7 @@ import type { LifecycleDef, LifecycleRunRecord, CheckpointDecision, PhaseRecord
21
23
  import type { LifecycleRunDeps, CheckpointFn } from "../lifecycle/run-lifecycle.ts";
22
24
  import { runLifecycle } from "../lifecycle/run-lifecycle.ts";
23
25
 
24
- type View = "fleet" | "lifecycle" | "agents" | "backends";
26
+ type View = "fleet" | "lifecycle" | "agents" | "backends" | "scheduled";
25
27
 
26
28
  export interface FleetPanelDeps {
27
29
  registry: Map<string, AgentDef>;
@@ -35,6 +37,10 @@ export interface FleetPanelDeps {
35
37
  lifecycleRegistry: Map<string, LifecycleDef>;
36
38
  lifecycleRuns: Map<string, LifecycleRunRecord>;
37
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;
38
44
  }
39
45
 
40
46
  export interface FleetPanelOpts {
@@ -67,6 +73,17 @@ export class FleetPanel extends Container {
67
73
  private pendingCheckpoint: { phase: PhaseRecord; resolve: (d: CheckpointDecision) => void } | null = null;
68
74
  private lcReviseInput: Input | null = null;
69
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
70
87
 
71
88
  constructor(opts: FleetPanelOpts) {
72
89
  super();
@@ -80,17 +97,25 @@ export class FleetPanel extends Container {
80
97
  this.addChild(new Spacer(1));
81
98
  this.list = this.buildList();
82
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()));
83
106
  }
84
107
 
85
108
  private buildList(): SelectList {
86
109
  const items: SelectItem[] =
87
110
  this.view === "fleet"
88
- ? this.deps.runRegistry.list().map((r: RunRecord) => ({ value: r.runId, label: fleetRow(r) }))
111
+ ? buildFleetItems({ runRegistry: this.deps.runRegistry, bgRuns: this.deps.bgRuns })
89
112
  : this.view === "lifecycle"
90
113
  ? [...this.deps.lifecycleRuns.values()].map((l: LifecycleRunRecord) => ({ value: l.runId, label: lifecycleRow(l) }))
91
114
  : this.view === "agents"
92
115
  ? [...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) }));
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) }));
94
119
  const fresh = new SelectList(items, 12, {
95
120
  selectedPrefix: (s: string) => this.theme.fg("accent", s),
96
121
  selectedText: (s: string) => this.theme.fg("accent", s),
@@ -99,16 +124,36 @@ export class FleetPanel extends Container {
99
124
  noMatch: (s: string) => this.theme.fg("warning", s),
100
125
  });
101
126
  fresh.onSelect = (item: SelectItem) => this.onSelect(item.value);
102
- fresh.onCancel = () => this.onDone();
127
+ fresh.onCancel = () => this.close();
103
128
  return fresh;
104
129
  }
105
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
+
106
151
  private renderShell(): void {
107
152
  const keep = this.children.slice(0, 2);
108
153
  this.children.length = 0;
109
154
  this.children.push(...keep);
110
155
  const accent = (s: string): string => this.theme.fg("accent", s);
111
- const tabs = (["fleet", "lifecycle", "agents", "backends"] as View[])
156
+ const tabs = (["fleet", "lifecycle", "agents", "backends", "scheduled"] as View[])
112
157
  .map((v) => (v === this.view ? this.theme.fg("accent", this.theme.bold(`[${v}]`)) : this.theme.fg("dim", v)))
113
158
  .join(" ");
114
159
  this.addChild(new Text(accent(this.theme.bold(" FLEET")) + " " + tabs, 0, 0));
@@ -138,6 +183,24 @@ export class FleetPanel extends Container {
138
183
  this.addChild(new Text(this.theme.fg("text", line), 0, 0));
139
184
  }
140
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));
141
204
  } else if (this.lcRunMode && (this.lcTaskInput || this.lcNameInput)) {
142
205
  const prompt = this.lcPhase === "task" ? " task> " : " lifecycle name (blank=default)> ";
143
206
  this.addChild(new Text(this.theme.fg("accent", prompt), 0, 0));
@@ -158,7 +221,7 @@ export class FleetPanel extends Container {
158
221
 
159
222
  this.addChild(new Spacer(1));
160
223
  const hint =
161
- this.infoAgent || this.selectedBackend || this.selectedLifecycle
224
+ this.infoAgent || this.selectedBackend || this.selectedLifecycle || this.selectedSchedule
162
225
  ? " esc:Back"
163
226
  : this.pendingCheckpoint
164
227
  ? " c:Continue v:Revise a:Abort"
@@ -170,7 +233,9 @@ export class FleetPanel extends Container {
170
233
  ? " r:Run-lifecycle i:Info tab:Agents q:Quit"
171
234
  : this.view === "agents"
172
235
  ? " r:Run e:Edit i:Info d:Reload tab:Backends q:Quit"
173
- : " r:Refresh i:Info tab:Fleet 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";
174
239
  this.addChild(new Text(this.theme.fg("dim", hint), 0, 0));
175
240
  this.addChild(new Spacer(1));
176
241
  this.addChild(new DynamicBorder(accent));
@@ -238,9 +303,11 @@ export class FleetPanel extends Container {
238
303
  private switchView(): void {
239
304
  this.view = this.view === "fleet" ? "lifecycle"
240
305
  : this.view === "lifecycle" ? "agents"
241
- : this.view === "agents" ? "backends" : "fleet";
306
+ : this.view === "agents" ? "backends"
307
+ : this.view === "backends" ? "scheduled" : "fleet";
242
308
  this.selectedBackend = null;
243
309
  this.selectedLifecycle = null;
310
+ this.selectedSchedule = null;
244
311
  this.list = this.buildList();
245
312
  this.renderShell();
246
313
  }
@@ -258,6 +325,16 @@ export class FleetPanel extends Container {
258
325
  if (matchesKey(data, "escape")) { this.selectedLifecycle = null; this.renderShell(); }
259
326
  return;
260
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
+ }
261
338
  if (this.lcRunMode && (this.lcTaskInput || this.lcNameInput)) {
262
339
  if (matchesKey(data, "escape")) { this.cancelLifecycleRun(); return; }
263
340
  (this.lcPhase === "task" ? this.lcTaskInput! : this.lcNameInput!).handleInput(data);
@@ -277,11 +354,11 @@ export class FleetPanel extends Container {
277
354
  this.pendingCheckpoint.resolve({ action: "abort" });
278
355
  this.pendingCheckpoint = null;
279
356
  }
280
- this.onDone();
357
+ this.close();
281
358
  return;
282
359
  }
283
360
  if (matchesKey(data, "tab")) { this.switchView(); return; }
284
- if (matchesKey(data, "q")) { this.onDone(); return; }
361
+ if (matchesKey(data, "q")) { this.close(); return; }
285
362
  if (matchesKey(data, "r") && this.view === "agents") {
286
363
  const sel = this.list.getSelectedItem();
287
364
  if (sel) this.startRun(sel.value);
@@ -312,6 +389,28 @@ export class FleetPanel extends Container {
312
389
  this.startLifecycleRun();
313
390
  return;
314
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
+ }
315
414
  // SPEC-4: pending checkpoint keys (c/v/a)
316
415
  if (this.pendingCheckpoint && !this.lcRevising) {
317
416
  if (matchesKey(data, "c")) { this.pendingCheckpoint.resolve({ action: "continue" }); this.pendingCheckpoint = null; this.renderShell(); return; }
@@ -341,6 +440,59 @@ export class FleetPanel extends Container {
341
440
  this.invalidate();
342
441
  }
343
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
+
344
496
  /** SPEC-4: open the Run-lifecycle inline inputs (task → lifecycle name → start runLifecycle). */
345
497
  private startLifecycleRun(): void {
346
498
  this.lcPhase = "task";
package/src/panel/rows.ts CHANGED
@@ -85,6 +85,62 @@ export function backendInfo(b: Backend): string {
85
85
 
86
86
  import type { LifecycleRunRecord, LifecycleStatus } from "../lifecycle/lifecycle-types.ts";
87
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
+
88
144
  const LC_GLYPH: Record<LifecycleStatus, string> = {
89
145
  running: "▶", checkpoint: "⏸", completed: "✓", failed: "✗", aborted: "✗",
90
146
  };