@getpipher/armory-fleet 0.6.0 → 0.8.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 +1 -1
- package/src/engine/run-registry.ts +2 -0
- package/src/engine/spawnSubagent.ts +15 -4
- package/src/index.ts +17 -0
- package/src/panel/conversation-rows.ts +76 -0
- package/src/panel/fleet-panel.ts +84 -10
- package/src/panel/fleet-widget.ts +114 -0
- package/src/panel/widget-rows.ts +79 -0
- package/src/runtime/run-log.ts +2 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@getpipher/armory-fleet",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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?: {
|
|
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
|
-
|
|
213
|
-
|
|
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,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
|
+
}
|
package/src/panel/fleet-panel.ts
CHANGED
|
@@ -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,13 @@ 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-3: full-message overlay (second level over the 5b-1 timeline) + stored SelectList refs
|
|
95
|
+
// so handleInput can forward keys to the active overlay (Container/TUI routes input only to the
|
|
96
|
+
// focused component = this panel; children receive keys only if we forward them).
|
|
97
|
+
private selectedEventIndex: number | null = null;
|
|
98
|
+
private fullMessageEvent: MessageEvent | ToolEvent | null = null;
|
|
99
|
+
private timelineList: SelectList | null = null;
|
|
100
|
+
private messageBodyList: SelectList | null = null;
|
|
93
101
|
/** SPEC-5a proper-fix: store-change subscriptions — fired by RunRegistry + BgRunsStore
|
|
94
102
|
* so the panel re-renders the moment a (fore- or back-ground) run mutates, without a keypress. */
|
|
95
103
|
private readonly unsubs: (() => void)[] = [];
|
|
@@ -208,15 +216,50 @@ export class FleetPanel extends Container {
|
|
|
208
216
|
`nextFire: ${s.nextFire?.toLocaleString() ?? "(none)"}`,
|
|
209
217
|
]) this.addChild(new Text(this.theme.fg("text", line), 0, 0));
|
|
210
218
|
this.addChild(new Text(this.theme.fg("dim", " esc:Back"), 0, 0));
|
|
219
|
+
} else if (this.fullMessageEvent) {
|
|
220
|
+
// SPEC-5b-3: full-message overlay (top of the stack). Header + scrollable wrapped body.
|
|
221
|
+
const e = this.fullMessageEvent;
|
|
222
|
+
const isMsg = e.type === "message";
|
|
223
|
+
const header = isMsg ? messageHeader(e) : toolHeader(e);
|
|
224
|
+
this.addChild(new Text(this.theme.fg("dim", ` ${header}`), 0, 0));
|
|
225
|
+
// Width: the panel renders at the terminal width pi gives ctx.ui.custom. Rows are pre-baked
|
|
226
|
+
// into SelectItem.label, so wrap now. Fall back to 80 if the live width isn't reachable here
|
|
227
|
+
// — the list still scrolls; a resize re-wraps on the next renderShell().
|
|
228
|
+
const width = 80;
|
|
229
|
+
const bodyLines = isMsg ? messageBody(e, width) : toolBody(e, width);
|
|
230
|
+
const body = new SelectList(
|
|
231
|
+
bodyLines.map((line) => ({ value: "", label: line })),
|
|
232
|
+
Math.min(bodyLines.length, 12),
|
|
233
|
+
{
|
|
234
|
+
selectedPrefix: (s: string) => this.theme.fg("accent", s),
|
|
235
|
+
selectedText: (s: string) => this.theme.fg("accent", s),
|
|
236
|
+
description: (s: string) => this.theme.fg("muted", s),
|
|
237
|
+
scrollInfo: (s: string) => this.theme.fg("dim", s),
|
|
238
|
+
noMatch: (s: string) => this.theme.fg("warning", s),
|
|
239
|
+
},
|
|
240
|
+
);
|
|
241
|
+
// esc → back to timeline. Leave onSelect unset so Enter (tui.select.confirm) is swallowed
|
|
242
|
+
// silently by SelectList.handleInput — a text line has nothing to drill into.
|
|
243
|
+
// Do NOT clear selectedEventIndex here: it survives to drive the timeline cursor restore.
|
|
244
|
+
body.onCancel = () => {
|
|
245
|
+
this.fullMessageEvent = null;
|
|
246
|
+
this.messageBodyList = null;
|
|
247
|
+
this.renderShell();
|
|
248
|
+
};
|
|
249
|
+
this.messageBodyList = body;
|
|
250
|
+
this.addChild(body);
|
|
251
|
+
this.addChild(new Text(this.theme.fg("dim", " esc:Back"), 0, 0));
|
|
211
252
|
} else if (this.selectedRun) {
|
|
212
|
-
// SPEC-5b-1: Runs tab — per-turn timeline replay
|
|
253
|
+
// SPEC-5b-1/5b-3: Runs tab — per-turn timeline replay. SPEC-5b-3 makes the list interactive
|
|
254
|
+
// (arrows scroll, enter opens full-message overlay, esc back to Runs list) by forwarding input
|
|
255
|
+
// to the stored SelectList (see handleInput) — v0.6.0 swallowed all non-escape keys.
|
|
213
256
|
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<
|
|
257
|
+
const events = (this.runTimeline ?? []).filter((e) => e.type === "message" || e.type === "tool") as Array<MessageEvent | ToolEvent>;
|
|
215
258
|
if (events.length === 0) {
|
|
216
259
|
this.addChild(new Text(this.theme.fg("dim", " (no conversation events)"), 0, 0));
|
|
217
260
|
} else {
|
|
218
261
|
const tl = new SelectList(
|
|
219
|
-
events.map((e) => ({ value:
|
|
262
|
+
events.map((e, idx) => ({ value: String(idx), label: runTimelineRow(e as never) })),
|
|
220
263
|
Math.min(events.length, 12),
|
|
221
264
|
{
|
|
222
265
|
selectedPrefix: (s: string) => this.theme.fg("accent", s),
|
|
@@ -226,10 +269,26 @@ export class FleetPanel extends Container {
|
|
|
226
269
|
noMatch: (s: string) => this.theme.fg("warning", s),
|
|
227
270
|
},
|
|
228
271
|
);
|
|
229
|
-
|
|
272
|
+
// SPEC-5b-3: enter on a timeline row → open the full-message overlay for that event.
|
|
273
|
+
tl.onSelect = (item) => {
|
|
274
|
+
const idx = Number(item.value);
|
|
275
|
+
const ev = events[idx];
|
|
276
|
+
if (!ev) { this.onNotify("event no longer available", "warning"); return; }
|
|
277
|
+
this.selectedEventIndex = idx;
|
|
278
|
+
this.fullMessageEvent = ev;
|
|
279
|
+
this.renderShell();
|
|
280
|
+
};
|
|
281
|
+
// SPEC-5b-3: restore the cursor to the row we were viewing (one-shot, then clear the token).
|
|
282
|
+
if (this.selectedEventIndex != null) {
|
|
283
|
+
tl.setSelectedIndex(this.selectedEventIndex);
|
|
284
|
+
this.selectedEventIndex = null;
|
|
285
|
+
}
|
|
286
|
+
// esc → back to Runs list (replaces the v0.6.0 panel-level escape catch).
|
|
287
|
+
tl.onCancel = () => { this.selectedRun = null; this.runTimeline = null; this.timelineList = null; this.renderShell(); };
|
|
288
|
+
this.timelineList = tl;
|
|
230
289
|
this.addChild(tl);
|
|
231
290
|
}
|
|
232
|
-
this.addChild(new Text(this.theme.fg("dim", " enter:
|
|
291
|
+
this.addChild(new Text(this.theme.fg("dim", " enter:Full-message esc:Back"), 0, 0));
|
|
233
292
|
} else if (this.resumeMode && this.resumeInput) {
|
|
234
293
|
// SPEC-5b-1: Runs tab — resume follow-up input.
|
|
235
294
|
this.addChild(new Text(this.theme.fg("accent", " follow-up> "), 0, 0));
|
|
@@ -260,8 +319,10 @@ export class FleetPanel extends Container {
|
|
|
260
319
|
|
|
261
320
|
this.addChild(new Spacer(1));
|
|
262
321
|
const hint =
|
|
263
|
-
this.
|
|
264
|
-
?
|
|
322
|
+
this.fullMessageEvent
|
|
323
|
+
? " esc:Back"
|
|
324
|
+
: this.infoAgent || this.selectedBackend || this.selectedLifecycle || this.selectedSchedule || this.selectedRun
|
|
325
|
+
? (this.selectedRun ? " enter:Full-message esc:Back" : " esc:Back")
|
|
265
326
|
: this.pendingCheckpoint
|
|
266
327
|
? " c:Continue v:Revise a:Abort"
|
|
267
328
|
: this.lcRevising
|
|
@@ -353,6 +414,10 @@ export class FleetPanel extends Container {
|
|
|
353
414
|
this.selectedSchedule = null;
|
|
354
415
|
this.selectedRun = null;
|
|
355
416
|
this.runTimeline = null;
|
|
417
|
+
this.selectedEventIndex = null;
|
|
418
|
+
this.fullMessageEvent = null;
|
|
419
|
+
this.timelineList = null;
|
|
420
|
+
this.messageBodyList = null;
|
|
356
421
|
this.list = this.buildList();
|
|
357
422
|
this.renderShell();
|
|
358
423
|
}
|
|
@@ -374,9 +439,18 @@ export class FleetPanel extends Container {
|
|
|
374
439
|
if (matchesKey(data, "escape")) { this.selectedSchedule = null; this.renderShell(); }
|
|
375
440
|
return;
|
|
376
441
|
}
|
|
442
|
+
if (this.fullMessageEvent) {
|
|
443
|
+
// SPEC-5b-3: full-message overlay — forward to the body SelectList (arrows scroll; esc via
|
|
444
|
+
// onCancel back to timeline; enter is a no-op). Replaces the v0.6.0 swallow-all pattern.
|
|
445
|
+
this.messageBodyList?.handleInput(data);
|
|
446
|
+
this.invalidate();
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
377
449
|
if (this.selectedRun) {
|
|
378
|
-
// SPEC-5b-
|
|
379
|
-
|
|
450
|
+
// SPEC-5b-3: forward to the timeline SelectList (arrows scroll; enter via onSelect opens the
|
|
451
|
+
// overlay; esc via onCancel returns to the Runs list). v0.6.0 swallowed all non-escape keys.
|
|
452
|
+
this.timelineList?.handleInput(data);
|
|
453
|
+
this.invalidate();
|
|
380
454
|
return;
|
|
381
455
|
}
|
|
382
456
|
if (this.resumeMode && this.resumeInput) {
|
|
@@ -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
|
+
}
|
package/src/runtime/run-log.ts
CHANGED
|
@@ -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
|
|
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;
|