@getpipher/armory-fleet 0.7.0 → 0.9.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getpipher/armory-fleet",
3
- "version": "0.7.0",
3
+ "version": "0.9.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",
@@ -1,5 +1,6 @@
1
1
  // src/engine/run-registry.ts
2
2
  import type { FleetRunStatus } from "../todo-sync/port.ts";
3
+ import type { LiveSessionHandle } from "./spawnSubagent.ts";
3
4
 
4
5
  export interface RunRecord {
5
6
  runId: string;
@@ -22,6 +23,10 @@ export interface RunRecord {
22
23
  forkedFrom?: string;
23
24
  /** SPEC-5b-2: cumulative real tokens (input+output+cacheRead+cacheWrite) — live, updated on each message_end. */
24
25
  tokenTotal?: number;
26
+ /** SPEC-5b-4: live session handle while status === "running"; cleared by finishRun.
27
+ * Transient, in-memory only — never written to RunLog (the journal append constructs
28
+ * a plain object, not RunRecord). */
29
+ session?: LiveSessionHandle;
25
30
  }
26
31
 
27
32
  /** runId format: fl-<base36 ms>-<6 random> (SPEC-1 §5.1). */
@@ -43,6 +43,33 @@ export interface ChildSession {
43
43
  subscribe(handler: (event: ChildSessionEvent) => void): () => void;
44
44
  abort(): Promise<void>;
45
45
  dispose(): void;
46
+ /** SPEC-5b-4: optional mid-run steering. Pi backend forwards the native SDK `steer()`; claude omits. */
47
+ steer?(text: string): Promise<void>;
48
+ /** SPEC-5b-4: optional live streaming flag. Pi backend forwards the native SDK getter; claude omits. */
49
+ readonly isStreaming?: boolean;
50
+ }
51
+
52
+ /** SPEC-5b-4: narrow live-session handle retained on RunRecord while status === "running".
53
+ * Exposes only redirect/cancel/observe/liveness — deliberately no `prompt` or `dispose`
54
+ * (the panel must not start new prompts or tear down the session). */
55
+ export interface LiveSessionHandle {
56
+ steer(text: string): Promise<void>;
57
+ abort(): Promise<void>;
58
+ subscribe(handler: (e: ChildSessionEvent) => void): () => void;
59
+ readonly isStreaming: boolean;
60
+ readonly supportsSteer: boolean;
61
+ }
62
+
63
+ /** SPEC-5b-4: wrap a ChildSession into a narrow LiveSessionHandle for the panel.
64
+ * `supportsSteer` is derived from whether the backend implemented the optional `steer`. */
65
+ export function toLiveHandle(session: ChildSession): LiveSessionHandle {
66
+ return {
67
+ steer: (text) => session.steer ? session.steer(text) : Promise.reject(new Error("steer not supported on this backend")),
68
+ abort: () => session.abort(),
69
+ subscribe: (h) => session.subscribe(h),
70
+ get isStreaming() { return session.isStreaming ?? false; },
71
+ get supportsSteer() { return typeof session.steer === "function"; },
72
+ };
46
73
  }
47
74
 
48
75
  export interface ChildSessionOpts {
@@ -193,6 +220,10 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
193
220
  visionPort,
194
221
  });
195
222
 
223
+ // SPEC-5b-4: retain a narrow live-session handle on the run record so the panel can
224
+ // steer/abort mid-flight. Cleared by finishRun in the same patch as the terminal status.
225
+ opts.runRegistry.update(runId, { session: toLiveHandle(session) });
226
+
196
227
  const budget = createTurnBudget(maxTurns);
197
228
  let finalText = "";
198
229
  let tokenTotal = 0;
@@ -281,6 +312,7 @@ async function finishRun(
281
312
  opts.runRegistry.update(runId, {
282
313
  status, endedAt, resultSummary: finalText.slice(0, 120),
283
314
  resumedFrom: opts.resumeLink, forkedFrom: opts.forkLink,
315
+ session: undefined, // SPEC-5b-4: clear the live handle (invariant: session ⟺ running)
284
316
  });
285
317
  try {
286
318
  opts.runLog?.append(runId, {
package/src/index.ts CHANGED
@@ -61,6 +61,9 @@ function wrapPiSession(inner: ChildSession, backendSessionId: string): ChildSess
61
61
  handler({ type: "session_init", backendSessionId });
62
62
  return inner.subscribe(handler);
63
63
  },
64
+ // SPEC-5b-4: forward the native SDK steer + isStreaming to the real pi session.
65
+ steer: (t) => inner.steer ? inner.steer(t) : Promise.reject(new Error("pi session has no steer")),
66
+ get isStreaming() { return inner.isStreaming ?? false; },
64
67
  };
65
68
  }
66
69
 
@@ -0,0 +1,76 @@
1
+ // src/panel/conversation-rows.ts
2
+ // SPEC-5b-3 — pure renderers for the full-message overlay (second level over the 5b-1 timeline).
3
+ // Plain strings, no theme (codebase convention: theming is applied at the SelectList callback layer).
4
+ //
5
+ // Journal fidelity (Q2=C): MessageEvent.text is already the FULL assistant text (5b-1 Q1). Tool
6
+ // args/result are the journaled excerpt (5b-1: args≤200ch, result≤500ch, errors in-full). The
7
+ // toolHeader notes "args/result excerpted" so the asymmetry is honest.
8
+ import type { MessageEvent, ToolEvent } from "../runtime/run-log.ts";
9
+
10
+ /** Word-wrap `text` to `width` columns. Long tokens (no break opportunity) hard-split at `width`.
11
+ * Explicit `\n` is preserved as a row break (multi-line tool results). Empty → [""]; width≤0 → [""].
12
+ * Operates on the string by `.length` (UTF-16 code units) — sufficient for the overlay's purposes;
13
+ * CJK chars count as 1 col each in most terminals, matching the test expectations. */
14
+ export function wrapToLines(text: string, width: number): string[] {
15
+ if (width <= 0) return [""];
16
+ if (text.length === 0) return [""];
17
+ const out: string[] = [];
18
+ for (const para of text.split("\n")) {
19
+ if (para.length === 0) { out.push(""); continue; }
20
+ const words = para.split(" ");
21
+ let line = "";
22
+ for (const w of words) {
23
+ if (w.length === 0) continue; // collapse runs of spaces
24
+ if (line.length === 0) {
25
+ // long token with no break opportunity: hard-split at width
26
+ let rest = w;
27
+ while (rest.length > width) {
28
+ out.push(rest.slice(0, width));
29
+ rest = rest.slice(width);
30
+ }
31
+ line = rest;
32
+ } else if (line.length + 1 + w.length <= width) {
33
+ line += " " + w;
34
+ } else {
35
+ out.push(line);
36
+ let rest = w;
37
+ while (rest.length > width) {
38
+ out.push(rest.slice(0, width));
39
+ rest = rest.slice(width);
40
+ }
41
+ line = rest;
42
+ }
43
+ }
44
+ out.push(line);
45
+ }
46
+ return out;
47
+ }
48
+
49
+ /** Body for an assistant message: the full text, wrapped. */
50
+ export function messageBody(e: MessageEvent, width: number): string[] {
51
+ return wrapToLines(e.text, width);
52
+ }
53
+
54
+ /** Body for a tool event: `args:` + indented args, `result:` + indented result. */
55
+ export function toolBody(e: ToolEvent, width: number): string[] {
56
+ const inner = Math.max(2, width - 2);
57
+ const lines: string[] = ["args:"];
58
+ for (const l of wrapToLines(e.args, inner)) lines.push(" " + l);
59
+ lines.push("result:");
60
+ for (const l of wrapToLines(e.result, inner)) lines.push(" " + l);
61
+ return lines;
62
+ }
63
+
64
+ /** Header for an assistant message event. Omits the token segment when usage.total is absent. */
65
+ export function messageHeader(e: MessageEvent): string {
66
+ const turn = Math.max(0, e.turnIndex);
67
+ const tok = e.usage?.total != null ? ` · ${e.usage.total} tok` : "";
68
+ return `── assistant · turn ${turn}${tok} ──`;
69
+ }
70
+
71
+ /** Header for a tool event. Notes "args/result excerpted" (Q2=C honest asymmetry). */
72
+ export function toolHeader(e: ToolEvent): string {
73
+ const turn = Math.max(0, e.turnIndex);
74
+ const glyph = e.isError ? "✗" : "✓";
75
+ return `── tool: ${e.toolName} · turn ${turn} · ${glyph} · args/result excerpted ──`;
76
+ }
@@ -13,8 +13,9 @@ import type { AgentDef } from "../registry/frontmatter.ts";
13
13
  import { agentsRow, agentInfo, backendsRow, backendInfo, lifecycleRow, lifecyclePhaseTimeline, scheduleRow } from "./rows.ts";
14
14
  import { buildFleetItems } from "./fleet-items.ts";
15
15
  import { runsRow, runTimelineRow } from "./runs-rows.ts";
16
+ import { messageBody, toolBody, messageHeader, toolHeader } from "./conversation-rows.ts";
16
17
  import { buildRunsIndex } from "./runs-index.ts";
17
- import type { RunLog, RunMeta, RunLogEvent } from "../runtime/run-log.ts";
18
+ import type { RunLog, RunMeta, RunLogEvent, MessageEvent, ToolEvent } from "../runtime/run-log.ts";
18
19
  import type { Scheduler, Schedule } from "../scheduling/scheduler.ts";
19
20
  import type { BgRunsStore } from "./bg-runs-store.ts";
20
21
  import { spawnSubagent, type SpawnResult } from "../engine/spawnSubagent.ts";
@@ -90,6 +91,16 @@ export class FleetPanel extends Container {
90
91
  private runTimeline: RunLogEvent[] | null = null;
91
92
  private resumeInput: Input | null = null;
92
93
  private resumeMode = false;
94
+ // SPEC-5b-4: Steer inline input state (mid-run redirect; mirrors resumeMode/resumeInput).
95
+ private steerInput: Input | null = null;
96
+ private steerMode = false;
97
+ // SPEC-5b-3: full-message overlay (second level over the 5b-1 timeline) + stored SelectList refs
98
+ // so handleInput can forward keys to the active overlay (Container/TUI routes input only to the
99
+ // focused component = this panel; children receive keys only if we forward them).
100
+ private selectedEventIndex: number | null = null;
101
+ private fullMessageEvent: MessageEvent | ToolEvent | null = null;
102
+ private timelineList: SelectList | null = null;
103
+ private messageBodyList: SelectList | null = null;
93
104
  /** SPEC-5a proper-fix: store-change subscriptions — fired by RunRegistry + BgRunsStore
94
105
  * so the panel re-renders the moment a (fore- or back-ground) run mutates, without a keypress. */
95
106
  private readonly unsubs: (() => void)[] = [];
@@ -208,15 +219,50 @@ export class FleetPanel extends Container {
208
219
  `nextFire: ${s.nextFire?.toLocaleString() ?? "(none)"}`,
209
220
  ]) this.addChild(new Text(this.theme.fg("text", line), 0, 0));
210
221
  this.addChild(new Text(this.theme.fg("dim", " esc:Back"), 0, 0));
222
+ } else if (this.fullMessageEvent) {
223
+ // SPEC-5b-3: full-message overlay (top of the stack). Header + scrollable wrapped body.
224
+ const e = this.fullMessageEvent;
225
+ const isMsg = e.type === "message";
226
+ const header = isMsg ? messageHeader(e) : toolHeader(e);
227
+ this.addChild(new Text(this.theme.fg("dim", ` ${header}`), 0, 0));
228
+ // Width: the panel renders at the terminal width pi gives ctx.ui.custom. Rows are pre-baked
229
+ // into SelectItem.label, so wrap now. Fall back to 80 if the live width isn't reachable here
230
+ // — the list still scrolls; a resize re-wraps on the next renderShell().
231
+ const width = 80;
232
+ const bodyLines = isMsg ? messageBody(e, width) : toolBody(e, width);
233
+ const body = new SelectList(
234
+ bodyLines.map((line) => ({ value: "", label: line })),
235
+ Math.min(bodyLines.length, 12),
236
+ {
237
+ selectedPrefix: (s: string) => this.theme.fg("accent", s),
238
+ selectedText: (s: string) => this.theme.fg("accent", s),
239
+ description: (s: string) => this.theme.fg("muted", s),
240
+ scrollInfo: (s: string) => this.theme.fg("dim", s),
241
+ noMatch: (s: string) => this.theme.fg("warning", s),
242
+ },
243
+ );
244
+ // esc → back to timeline. Leave onSelect unset so Enter (tui.select.confirm) is swallowed
245
+ // silently by SelectList.handleInput — a text line has nothing to drill into.
246
+ // Do NOT clear selectedEventIndex here: it survives to drive the timeline cursor restore.
247
+ body.onCancel = () => {
248
+ this.fullMessageEvent = null;
249
+ this.messageBodyList = null;
250
+ this.renderShell();
251
+ };
252
+ this.messageBodyList = body;
253
+ this.addChild(body);
254
+ this.addChild(new Text(this.theme.fg("dim", " esc:Back"), 0, 0));
211
255
  } else if (this.selectedRun) {
212
- // SPEC-5b-1: Runs tab — per-turn timeline replay (read-only); enter reserved for 5b-3 overlay.
256
+ // SPEC-5b-1/5b-3: Runs tab — per-turn timeline replay. SPEC-5b-3 makes the list interactive
257
+ // (arrows scroll, enter opens full-message overlay, esc back to Runs list) by forwarding input
258
+ // to the stored SelectList (see handleInput) — v0.6.0 swallowed all non-escape keys.
213
259
  this.addChild(new Text(this.theme.fg("dim", ` ── run ${this.selectedRun.runId} — timeline ──`), 0, 0));
214
- const events = (this.runTimeline ?? []).filter((e) => e.type === "message" || e.type === "tool") as Array<RunLogEvent>;
260
+ const events = (this.runTimeline ?? []).filter((e) => e.type === "message" || e.type === "tool") as Array<MessageEvent | ToolEvent>;
215
261
  if (events.length === 0) {
216
262
  this.addChild(new Text(this.theme.fg("dim", " (no conversation events)"), 0, 0));
217
263
  } else {
218
264
  const tl = new SelectList(
219
- events.map((e) => ({ value: "", label: runTimelineRow(e as never) })),
265
+ events.map((e, idx) => ({ value: String(idx), label: runTimelineRow(e as never) })),
220
266
  Math.min(events.length, 12),
221
267
  {
222
268
  selectedPrefix: (s: string) => this.theme.fg("accent", s),
@@ -226,10 +272,31 @@ export class FleetPanel extends Container {
226
272
  noMatch: (s: string) => this.theme.fg("warning", s),
227
273
  },
228
274
  );
229
- tl.onCancel = () => { this.selectedRun = null; this.runTimeline = null; this.renderShell(); };
275
+ // SPEC-5b-3: enter on a timeline row open the full-message overlay for that event.
276
+ tl.onSelect = (item) => {
277
+ const idx = Number(item.value);
278
+ const ev = events[idx];
279
+ if (!ev) { this.onNotify("event no longer available", "warning"); return; }
280
+ this.selectedEventIndex = idx;
281
+ this.fullMessageEvent = ev;
282
+ this.renderShell();
283
+ };
284
+ // SPEC-5b-3: restore the cursor to the row we were viewing (one-shot, then clear the token).
285
+ if (this.selectedEventIndex != null) {
286
+ tl.setSelectedIndex(this.selectedEventIndex);
287
+ this.selectedEventIndex = null;
288
+ }
289
+ // esc → back to Runs list (replaces the v0.6.0 panel-level escape catch).
290
+ tl.onCancel = () => { this.selectedRun = null; this.runTimeline = null; this.timelineList = null; this.renderShell(); };
291
+ this.timelineList = tl;
230
292
  this.addChild(tl);
231
293
  }
232
- this.addChild(new Text(this.theme.fg("dim", " enter: (5b-3 full message) esc:Back"), 0, 0));
294
+ this.addChild(new Text(this.theme.fg("dim", " enter:Full-message esc:Back"), 0, 0));
295
+ } else if (this.steerMode && this.steerInput) {
296
+ // SPEC-5b-4: Fleet tab — mid-run steer input.
297
+ this.addChild(new Text(this.theme.fg("accent", " steer> "), 0, 0));
298
+ this.addChild(this.steerInput);
299
+ this.addChild(new Text(this.theme.fg("dim", " enter submit • esc cancel"), 0, 0));
233
300
  } else if (this.resumeMode && this.resumeInput) {
234
301
  // SPEC-5b-1: Runs tab — resume follow-up input.
235
302
  this.addChild(new Text(this.theme.fg("accent", " follow-up> "), 0, 0));
@@ -260,14 +327,16 @@ export class FleetPanel extends Container {
260
327
 
261
328
  this.addChild(new Spacer(1));
262
329
  const hint =
263
- this.infoAgent || this.selectedBackend || this.selectedLifecycle || this.selectedSchedule || this.selectedRun
264
- ? (this.selectedRun ? " enter:(5b-3) esc:Back" : " esc:Back")
330
+ this.fullMessageEvent
331
+ ? " esc:Back"
332
+ : this.infoAgent || this.selectedBackend || this.selectedLifecycle || this.selectedSchedule || this.selectedRun
333
+ ? (this.selectedRun ? " enter:Full-message esc:Back" : " esc:Back")
265
334
  : this.pendingCheckpoint
266
335
  ? " c:Continue v:Revise a:Abort"
267
336
  : this.lcRevising
268
337
  ? " enter:Submit-feedback esc:Cancel"
269
338
  : this.view === "fleet"
270
- ? " r:Run-new s:Stop o:Open-todo tab:Lifecycle q:Quit"
339
+ ? " r:Run-new s:Steer x:Stop o:Open-todo tab:Lifecycle q:Quit"
271
340
  : this.view === "lifecycle"
272
341
  ? " r:Run-lifecycle i:Info tab:Runs q:Quit"
273
342
  : this.view === "runs"
@@ -353,6 +422,12 @@ export class FleetPanel extends Container {
353
422
  this.selectedSchedule = null;
354
423
  this.selectedRun = null;
355
424
  this.runTimeline = null;
425
+ this.selectedEventIndex = null;
426
+ this.fullMessageEvent = null;
427
+ this.timelineList = null;
428
+ this.messageBodyList = null;
429
+ this.steerMode = false; // SPEC-5b-4: drop any in-flight steer input on tab switch
430
+ this.steerInput = null;
356
431
  this.list = this.buildList();
357
432
  this.renderShell();
358
433
  }
@@ -374,9 +449,24 @@ export class FleetPanel extends Container {
374
449
  if (matchesKey(data, "escape")) { this.selectedSchedule = null; this.renderShell(); }
375
450
  return;
376
451
  }
452
+ if (this.fullMessageEvent) {
453
+ // SPEC-5b-3: full-message overlay — forward to the body SelectList (arrows scroll; esc via
454
+ // onCancel back to timeline; enter is a no-op). Replaces the v0.6.0 swallow-all pattern.
455
+ this.messageBodyList?.handleInput(data);
456
+ this.invalidate();
457
+ return;
458
+ }
377
459
  if (this.selectedRun) {
378
- // SPEC-5b-1: Runs timeline replay overlay esc back; enter is a 5b-3 placeholder.
379
- if (matchesKey(data, "escape")) { this.selectedRun = null; this.runTimeline = null; this.renderShell(); }
460
+ // SPEC-5b-3: forward to the timeline SelectList (arrows scroll; enter via onSelect opens the
461
+ // overlay; esc via onCancel returns to the Runs list). v0.6.0 swallowed all non-escape keys.
462
+ this.timelineList?.handleInput(data);
463
+ this.invalidate();
464
+ return;
465
+ }
466
+ if (this.steerMode && this.steerInput) {
467
+ if (matchesKey(data, "escape")) { this.cancelSteer(); return; }
468
+ this.steerInput.handleInput(data);
469
+ this.invalidate();
380
470
  return;
381
471
  }
382
472
  if (this.resumeMode && this.resumeInput) {
@@ -435,6 +525,11 @@ export class FleetPanel extends Container {
435
525
  this.renderShell();
436
526
  return;
437
527
  }
528
+ // SPEC-5b-4: Fleet view — s:Steer (pi-only) + x:Stop (any backend) on the selected running row.
529
+ if (this.view === "fleet") {
530
+ if (matchesKey(data, "s")) { this.startSteer(); return; }
531
+ if (matchesKey(data, "x")) { this.executeStop(); return; }
532
+ }
438
533
  // SPEC-5b-1: Runs view — enter/i:Replay R:Resume F:Fork
439
534
  if (this.view === "runs" && this.deps.runLog) {
440
535
  if (matchesKey(data, "enter") || matchesKey(data, "i")) {
@@ -587,6 +682,63 @@ export class FleetPanel extends Container {
587
682
  this.renderShell();
588
683
  }
589
684
 
685
+ /** SPEC-5b-4: Steer — open the inline "steer> " input for the selected running row (pi-only). */
686
+ private startSteer(): void {
687
+ const sel = this.list.getSelectedItem();
688
+ if (!sel) return;
689
+ const run = this.deps.runRegistry.get(sel.value);
690
+ if (!run || run.status !== "running") { this.onNotify("run already finished", "warning"); return; }
691
+ if (!run.session) { this.onNotify("run already finished", "warning"); return; }
692
+ if (!run.session.supportsSteer) { this.onNotify("steer not supported on claude backend", "warning"); return; }
693
+ this.steerInput = new Input();
694
+ this.steerInput.onSubmit = (text: string) => {
695
+ if (!text.trim()) { this.cancelSteer(); return; }
696
+ void this.executeSteer(run.runId, text.trim());
697
+ };
698
+ this.steerInput.onEscape = () => this.cancelSteer();
699
+ this.steerMode = true;
700
+ this.renderShell();
701
+ }
702
+
703
+ private cancelSteer(): void {
704
+ this.steerMode = false;
705
+ this.steerInput = null;
706
+ this.renderShell();
707
+ }
708
+
709
+ private async executeSteer(runId: string, text: string): Promise<void> {
710
+ // Re-check: the run may have finished between pressing s and submitting.
711
+ const run = this.deps.runRegistry.get(runId);
712
+ if (!run || !run.session) { this.onNotify("run already finished", "warning"); this.cancelSteer(); return; }
713
+ if (!run.session.supportsSteer) { this.onNotify("steer not supported on claude backend", "warning"); this.cancelSteer(); return; }
714
+ this.steerMode = false;
715
+ this.steerInput = null;
716
+ this.renderShell();
717
+ try {
718
+ await run.session.steer(text);
719
+ this.onNotify("steer queued; lands after current tool calls", "info");
720
+ } catch (e) {
721
+ this.onNotify(`steer failed: ${(e as Error).message}`, "error");
722
+ }
723
+ }
724
+
725
+ /** SPEC-5b-4: Stop — abort the selected running row (any backend). */
726
+ private executeStop(): void {
727
+ const sel = this.list.getSelectedItem();
728
+ if (!sel) return;
729
+ const run = this.deps.runRegistry.get(sel.value);
730
+ if (!run || run.status !== "running") { this.onNotify("run already finished", "warning"); return; }
731
+ if (!run.session) { this.onNotify("run already finished", "warning"); return; }
732
+ void (async () => {
733
+ try {
734
+ await run.session!.abort();
735
+ this.onNotify("run aborted", "info");
736
+ } catch (e) {
737
+ this.onNotify(`abort failed: ${(e as Error).message}`, "error");
738
+ }
739
+ })();
740
+ }
741
+
590
742
  private async executeResume(prior: RunMeta, followUp: string): Promise<void> {
591
743
  this.resumeMode = false;
592
744
  this.resumeInput = null;