@getpipher/armory-fleet 0.6.0 → 0.7.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.6.0",
3
+ "version": "0.7.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",
@@ -20,6 +20,8 @@ export interface RunRecord {
20
20
  resumedFrom?: string;
21
21
  /** SPEC-5b-1: runId this run forked from (fresh re-run with same agent+task). */
22
22
  forkedFrom?: string;
23
+ /** SPEC-5b-2: cumulative real tokens (input+output+cacheRead+cacheWrite) — live, updated on each message_end. */
24
+ tokenTotal?: number;
23
25
  }
24
26
 
25
27
  /** runId format: fl-<base36 ms>-<6 random> (SPEC-1 §5.1). */
@@ -26,7 +26,13 @@ export interface ChildSessionEvent {
26
26
  message?: {
27
27
  role?: string;
28
28
  content?: Array<{ type: string; text?: string }>;
29
- usage?: { cost?: { total?: number } };
29
+ usage?: {
30
+ input?: number;
31
+ output?: number;
32
+ cacheRead?: number;
33
+ cacheWrite?: number;
34
+ cost?: { total?: number };
35
+ };
30
36
  };
31
37
  /** Emitted by a backend on session init (SPEC-3). Drives runRecord.backendSessionId. */
32
38
  backendSessionId?: string;
@@ -209,10 +215,15 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
209
215
  } else if (e.type === "message_end" && e.message?.role === "assistant") {
210
216
  const text = e.message.content?.map((c) => (c.type === "text" ? c.text ?? "" : "")).join("") ?? "";
211
217
  if (text) finalText = text;
212
- const total = e.message.usage?.cost?.total;
213
- if (typeof total === "number") tokenTotal += total;
218
+ // SPEC-5b-2 (Q9): accumulate REAL tokens (input+output+cacheRead+cacheWrite), not cost.total (dollars).
219
+ const u = e.message.usage;
220
+ const turnTokens = (u?.input ?? 0) + (u?.output ?? 0) + (u?.cacheRead ?? 0) + (u?.cacheWrite ?? 0);
221
+ if (turnTokens > 0) {
222
+ tokenTotal += turnTokens;
223
+ opts.runRegistry.update(runId, { tokenTotal });
224
+ }
214
225
  try {
215
- opts.runLog?.append(runId, { type: "message", role: "assistant", text, usage: { total }, turnIndex: turnIdx });
226
+ opts.runLog?.append(runId, { type: "message", role: "assistant", text, usage: { total: turnTokens, input: u?.input, output: u?.output, cacheRead: u?.cacheRead, cacheWrite: u?.cacheWrite }, turnIndex: turnIdx });
216
227
  } catch { /* best-effort */ }
217
228
  } else if (e.type === "tool_execution_end") {
218
229
  try {
package/src/index.ts CHANGED
@@ -42,6 +42,7 @@ import { reconcileRuns } from "./runtime/reconcile.ts";
42
42
  import { Scheduler } from "./scheduling/scheduler.ts";
43
43
  import { createFleetResultsTool } from "./tools/fleet-results.ts";
44
44
  import { BgRunsStore } from "./panel/bg-runs-store.ts";
45
+ import { FleetWidgetController } from "./panel/fleet-widget.ts";
45
46
 
46
47
  /** The package builtin agents/ dir, resolved relative to this module. */
47
48
  function builtinAgentsDir(): string {
@@ -167,6 +168,9 @@ export default async function (pi: ExtensionAPI): Promise<void> {
167
168
  const fleetDir = (cwd: string) => join(cwd, ".pi", "fleet");
168
169
  const bgRuns = new BgRunsStore();
169
170
  const resultsInbox = new ResultsInbox();
171
+ // SPEC-5b-2: the live widget (above editor) + FleetView (below editor) controller.
172
+ // Display-only, independent of the /fleet panel; constructed per-session in session_start.
173
+ let fleetWidget: FleetWidgetController | null = null;
170
174
  // The async runner's runLifecycle adapter: call the real runLifecycle with the worktree as the
171
175
  // spawn cwd + override genRunId so the lifecycle runId IS the async runner's runId (Q1=B seam).
172
176
  const asyncRunLifecycle: AsyncRunnerDeps["runLifecycle"] = async (task, lifecycleName, opts) => {
@@ -268,6 +272,19 @@ export default async function (pi: ExtensionAPI): Promise<void> {
268
272
  if (cands.length > 0) {
269
273
  ctx.ui.notify(`${cands.length} interrupted fleet run${cands.length > 1 ? "s" : ""} — open /fleet to resume`, "info");
270
274
  }
275
+ // SPEC-5b-2: live widget (above editor) + FleetView (below editor). Display-only, independent
276
+ // of the /fleet panel. getTheme is a live getter (EditorTheme gotcha). Disposed on session end.
277
+ fleetWidget = new FleetWidgetController({
278
+ runRegistry: deps.runRegistry,
279
+ bgRuns,
280
+ ui: ctx.ui as never,
281
+ getTheme: () => ctx.ui.theme,
282
+ });
283
+ fleetWidget.start();
284
+ });
285
+
286
+ pi.on("session_shutdown", () => {
287
+ if (fleetWidget) { fleetWidget.dispose(); fleetWidget = null; }
271
288
  });
272
289
 
273
290
  pi.on("resources_discover", (event, ctx) => {
@@ -0,0 +1,114 @@
1
+ // src/panel/fleet-widget.ts
2
+ // SPEC-5b-2 — the live widget (above editor) + FleetView (below editor) controller.
3
+ //
4
+ // Display-only (Q1=A): pi widgets render into a layout container; the editor keeps keyboard
5
+ // focus. No input is routed here — /fleet is the action surface.
6
+ //
7
+ // Lifecycle (Q5/Q6/Q7=A): visible only while ≥1 active run exists; hidden when idle (editor
8
+ // reclaims both slots). A 1s setInterval re-renders for the live duration clock; it starts
9
+ // lazily on the first active render and clears when the fleet goes idle + on dispose.
10
+ //
11
+ // Independent of the /fleet panel: constructed at session_start in index.ts, persists whether
12
+ // the panel is open or closed.
13
+ import type { Theme } from "@earendil-works/pi-coding-agent";
14
+ import type { RunRegistry } from "../engine/run-registry.ts";
15
+ import type { BgRunsStore } from "./bg-runs-store.ts";
16
+ import {
17
+ toWidgetRun, toWidgetRunFromBg, renderWidgetLines, renderFleetViewLines,
18
+ } from "./widget-rows.ts";
19
+
20
+ const WIDGET_KEY = "fleet-active";
21
+ const VIEW_KEY = "fleet-view";
22
+
23
+ export interface FleetWidgetDeps {
24
+ runRegistry: RunRegistry;
25
+ bgRuns?: BgRunsStore;
26
+ ui: {
27
+ setWidget: (
28
+ key: string,
29
+ content: string[] | undefined,
30
+ opts?: { placement?: "aboveEditor" | "belowEditor" },
31
+ ) => void;
32
+ };
33
+ /** Live theme getter (EditorTheme gotcha: never capture a factory theme arg). */
34
+ getTheme: () => Theme;
35
+ /** Injectable clock + timers for testability. Default to globals. */
36
+ now?: () => number;
37
+ setInterval?: (fn: () => void, ms: number) => unknown;
38
+ clearInterval?: (id: unknown) => void;
39
+ }
40
+
41
+ export class FleetWidgetController {
42
+ private readonly deps: FleetWidgetDeps;
43
+ private readonly now: () => number;
44
+ private readonly setIntervalFn: (fn: () => void, ms: number) => unknown;
45
+ private readonly clearIntervalFn: (id: unknown) => void;
46
+ private readonly unsubs: (() => void)[] = [];
47
+ private timerId: unknown | null = null;
48
+ private disposed = false;
49
+
50
+ constructor(deps: FleetWidgetDeps) {
51
+ this.deps = deps;
52
+ this.now = deps.now ?? (() => Date.now());
53
+ this.setIntervalFn = deps.setInterval ?? ((fn, ms) => globalThis.setInterval(fn, ms));
54
+ this.clearIntervalFn = deps.clearInterval ?? ((id) => globalThis.clearInterval(id as any));
55
+ }
56
+
57
+ start(): void {
58
+ this.unsubs.push(this.deps.runRegistry.subscribe(() => this.render()));
59
+ if (this.deps.bgRuns) this.unsubs.push(this.deps.bgRuns.subscribe(() => this.render()));
60
+ this.render(); // initial — shows any runs already active on session_start (e.g. a survived bg run)
61
+ }
62
+
63
+ private activeRuns() {
64
+ const fg = this.deps.runRegistry.list().map(toWidgetRun);
65
+ const bg = this.deps.bgRuns ? [...this.deps.bgRuns.values()].map(toWidgetRunFromBg) : [];
66
+ return [...fg, ...bg];
67
+ }
68
+
69
+ render(): void {
70
+ if (this.disposed) return;
71
+ const active = this.activeRuns();
72
+ const hasActive = active.some((r) => r.status === "running" || r.status === "queued" || r.status === "paused");
73
+ if (!hasActive) {
74
+ this.clearTimer();
75
+ this.setBoth(undefined);
76
+ return;
77
+ }
78
+ this.ensureTimer();
79
+ const now = this.now();
80
+ try {
81
+ this.deps.ui.setWidget(WIDGET_KEY, renderWidgetLines(active, now));
82
+ } catch { /* best-effort: a render failure never affects runs */ }
83
+ try {
84
+ this.deps.ui.setWidget(VIEW_KEY, renderFleetViewLines(active, now), { placement: "belowEditor" });
85
+ } catch { /* best-effort */ }
86
+ }
87
+
88
+ private setBoth(content: string[] | undefined): void {
89
+ try { this.deps.ui.setWidget(WIDGET_KEY, content); } catch { /* best-effort */ }
90
+ try { this.deps.ui.setWidget(VIEW_KEY, content, { placement: "belowEditor" }); } catch { /* best-effort */ }
91
+ }
92
+
93
+ private ensureTimer(): void {
94
+ if (this.timerId !== null) return;
95
+ this.timerId = this.setIntervalFn(() => this.render(), 1000);
96
+ }
97
+
98
+ private clearTimer(): void {
99
+ if (this.timerId !== null) {
100
+ this.clearIntervalFn(this.timerId);
101
+ this.timerId = null;
102
+ }
103
+ }
104
+
105
+ /** Unsubscribe + clear timer + clear both widgets. Idempotent. */
106
+ dispose(): void {
107
+ if (this.disposed) return;
108
+ this.disposed = true;
109
+ this.clearTimer();
110
+ for (const u of this.unsubs) u();
111
+ this.unsubs.length = 0;
112
+ this.setBoth(undefined);
113
+ }
114
+ }
@@ -0,0 +1,79 @@
1
+ // src/panel/widget-rows.ts
2
+ // SPEC-5b-2 — pure render functions for the live widget (above editor) + FleetView (below editor).
3
+ // Both surfaces are display-only; plain strings (no theme) — theming, if wanted, is applied at the
4
+ // setWidget boundary. Mirrors the runs-rows/fleet-items pure-renderer convention (unit-tested, no TUI).
5
+ import { fmtDuration } from "./rows.ts";
6
+ import type { RunRecord } from "../engine/run-registry.ts";
7
+ import type { BgRunStatus } from "./rows.ts";
8
+
9
+ export interface WidgetRun {
10
+ runId: string;
11
+ agent: string;
12
+ status: "running" | "queued" | "paused" | "completed" | "failed" | "aborted";
13
+ /** fg runs have startedAt (live duration); bg runs do not (show phase instead). */
14
+ startedAt?: number;
15
+ endedAt?: number;
16
+ tokenTotal?: number;
17
+ phase?: string;
18
+ phaseIndex?: number;
19
+ phaseTotal?: number;
20
+ kind: "fg" | "bg";
21
+ backend?: string;
22
+ }
23
+
24
+ export function toWidgetRun(r: RunRecord): WidgetRun {
25
+ return {
26
+ runId: r.runId, agent: r.agent, status: r.status,
27
+ startedAt: r.startedAt, endedAt: r.endedAt, tokenTotal: r.tokenTotal,
28
+ kind: "fg",
29
+ };
30
+ }
31
+
32
+ export function toWidgetRunFromBg(b: BgRunStatus): WidgetRun {
33
+ return {
34
+ runId: b.runId, agent: b.lifecycle, status: b.status,
35
+ phase: b.phase, phaseIndex: b.phaseIndex, phaseTotal: b.phaseTotal,
36
+ kind: "bg", backend: b.backend,
37
+ };
38
+ }
39
+
40
+ /** Active = not-done = {running, queued, paused}. Newest-first by startedAt desc;
41
+ * runs without startedAt (bg) keep stable trailing order. */
42
+ export function filterActive(runs: WidgetRun[]): WidgetRun[] {
43
+ const active = runs.filter((r) => r.status === "running" || r.status === "queued" || r.status === "paused");
44
+ return active.sort((a, b) => {
45
+ const ai = typeof a.startedAt === "number" ? a.startedAt : Number.MIN_SAFE_INTEGER;
46
+ const bi = typeof b.startedAt === "number" ? b.startedAt : Number.MIN_SAFE_INTEGER;
47
+ return bi - ai; // newest-first
48
+ });
49
+ }
50
+
51
+ const STATUS_GLYPH: Record<WidgetRun["status"], string> = {
52
+ running: "▶", queued: "⏳", paused: "⏸", completed: "✓", failed: "✗", aborted: "✗",
53
+ };
54
+
55
+ /** One compact line per active run. */
56
+ function widgetLine(r: WidgetRun, now: number): string {
57
+ const glyph = STATUS_GLYPH[r.status];
58
+ const dur = typeof r.startedAt === "number" ? ` ${fmtDuration(now - r.startedAt)}` : "";
59
+ const tok = r.tokenTotal ? ` ${r.tokenTotal} tok` : "";
60
+ const phase = r.phase ? ` ●${r.phase} ${r.phaseIndex ?? 0}/${r.phaseTotal ?? 0}` : "";
61
+ const be = r.backend ? ` ${r.backend}` : "";
62
+ return `${glyph} ${r.runId} ${r.agent}${dur}${tok}${phase}${be}`;
63
+ }
64
+
65
+ /** Above-editor widget: one line per active run, cap 5, overflow → "+N more in /fleet". */
66
+ export function renderWidgetLines(runs: WidgetRun[], now: number = Date.now()): string[] {
67
+ const active = filterActive(runs);
68
+ const cap = 5;
69
+ if (active.length <= cap) return active.map((r) => widgetLine(r, now));
70
+ const shown = active.slice(0, cap).map((r) => widgetLine(r, now));
71
+ shown.push(`+${active.length - cap} more in /fleet`);
72
+ return shown;
73
+ }
74
+
75
+ /** Below-editor FleetView: active-only list, cap 8, no overflow line (list form). */
76
+ export function renderFleetViewLines(runs: WidgetRun[], now: number = Date.now()): string[] {
77
+ const active = filterActive(runs);
78
+ return active.slice(0, 8).map((r) => widgetLine(r, now));
79
+ }
@@ -14,7 +14,8 @@ export interface RunMetaEvent {
14
14
  }
15
15
  export interface MessageEvent {
16
16
  type: "message"; role: string; text: string;
17
- usage?: { total?: number }; turnIndex: number;
17
+ usage?: { total?: number; input?: number; output?: number; cacheRead?: number; cacheWrite?: number };
18
+ turnIndex: number;
18
19
  }
19
20
  export interface ToolEvent {
20
21
  type: "tool"; toolName: string; args: string; result: string; isError: boolean; turnIndex: number;