@getpipher/armory-fleet 0.8.0 → 0.9.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.8.0",
3
+ "version": "0.9.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",
@@ -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,10 +220,18 @@ 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. Wrap abort so the local `aborted` flag is set when the panel
225
+ // calls handle.abort() (not just the signal path) — otherwise finishRun reports
226
+ // "completed" instead of "aborted" on a user-initiated Stop. Cleared by finishRun.
227
+ let aborted = false;
228
+ const handle = toLiveHandle(session);
229
+ handle.abort = async () => { aborted = true; await session.abort(); };
230
+ opts.runRegistry.update(runId, { session: handle });
231
+
196
232
  const budget = createTurnBudget(maxTurns);
197
233
  let finalText = "";
198
234
  let tokenTotal = 0;
199
- let aborted = false;
200
235
  let turnIdx = -1;
201
236
 
202
237
  const onSignalAbort = (): void => { aborted = true; void session.abort(); };
@@ -281,6 +316,7 @@ async function finishRun(
281
316
  opts.runRegistry.update(runId, {
282
317
  status, endedAt, resultSummary: finalText.slice(0, 120),
283
318
  resumedFrom: opts.resumeLink, forkedFrom: opts.forkLink,
319
+ session: undefined, // SPEC-5b-4: clear the live handle (invariant: session ⟺ running)
284
320
  });
285
321
  try {
286
322
  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
 
@@ -91,6 +91,9 @@ export class FleetPanel extends Container {
91
91
  private runTimeline: RunLogEvent[] | null = null;
92
92
  private resumeInput: Input | null = null;
93
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;
94
97
  // SPEC-5b-3: full-message overlay (second level over the 5b-1 timeline) + stored SelectList refs
95
98
  // so handleInput can forward keys to the active overlay (Container/TUI routes input only to the
96
99
  // focused component = this panel; children receive keys only if we forward them).
@@ -289,6 +292,11 @@ export class FleetPanel extends Container {
289
292
  this.addChild(tl);
290
293
  }
291
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));
292
300
  } else if (this.resumeMode && this.resumeInput) {
293
301
  // SPEC-5b-1: Runs tab — resume follow-up input.
294
302
  this.addChild(new Text(this.theme.fg("accent", " follow-up> "), 0, 0));
@@ -328,7 +336,7 @@ export class FleetPanel extends Container {
328
336
  : this.lcRevising
329
337
  ? " enter:Submit-feedback esc:Cancel"
330
338
  : this.view === "fleet"
331
- ? " 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"
332
340
  : this.view === "lifecycle"
333
341
  ? " r:Run-lifecycle i:Info tab:Runs q:Quit"
334
342
  : this.view === "runs"
@@ -418,6 +426,8 @@ export class FleetPanel extends Container {
418
426
  this.fullMessageEvent = null;
419
427
  this.timelineList = null;
420
428
  this.messageBodyList = null;
429
+ this.steerMode = false; // SPEC-5b-4: drop any in-flight steer input on tab switch
430
+ this.steerInput = null;
421
431
  this.list = this.buildList();
422
432
  this.renderShell();
423
433
  }
@@ -453,6 +463,12 @@ export class FleetPanel extends Container {
453
463
  this.invalidate();
454
464
  return;
455
465
  }
466
+ if (this.steerMode && this.steerInput) {
467
+ if (matchesKey(data, "escape")) { this.cancelSteer(); return; }
468
+ this.steerInput.handleInput(data);
469
+ this.invalidate();
470
+ return;
471
+ }
456
472
  if (this.resumeMode && this.resumeInput) {
457
473
  if (matchesKey(data, "escape")) { this.cancelResume(); return; }
458
474
  this.resumeInput.handleInput(data);
@@ -509,6 +525,11 @@ export class FleetPanel extends Container {
509
525
  this.renderShell();
510
526
  return;
511
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
+ }
512
533
  // SPEC-5b-1: Runs view — enter/i:Replay R:Resume F:Fork
513
534
  if (this.view === "runs" && this.deps.runLog) {
514
535
  if (matchesKey(data, "enter") || matchesKey(data, "i")) {
@@ -661,6 +682,63 @@ export class FleetPanel extends Container {
661
682
  this.renderShell();
662
683
  }
663
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
+
664
742
  private async executeResume(prior: RunMeta, followUp: string): Promise<void> {
665
743
  this.resumeMode = false;
666
744
  this.resumeInput = null;