pi-legion 2.0.4 → 3.0.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/CHANGELOG.md +71 -1
- package/CONTRIBUTING.md +27 -24
- package/README.md +103 -19
- package/docs/contracts/osc-busy-plugin.md +72 -0
- package/docs/contracts/spawn-default-inprocess-v3.md +322 -0
- package/docs/contracts/wait-not-stop.md +30 -0
- package/docs/design.md +2 -2
- package/docs/headless-child-contract.md +83 -0
- package/docs/inprocess-supervisor.md +140 -0
- package/docs/qa-hunting-round.md +150 -0
- package/docs/subagents-teams.md +55 -18
- package/docs/tui-qa-playbook.md +284 -0
- package/index.ts +315 -49
- package/package.json +15 -8
- package/skills/pi-legion/SKILL.md +5 -0
- package/src/agents/agents.ts +34 -6
- package/src/agents/context.ts +83 -1
- package/src/agents/overrides.ts +1 -0
- package/src/agents/settings.ts +122 -1
- package/src/api.ts +23 -0
- package/src/extension/child-guard.ts +42 -3
- package/src/extension/legion-commands.ts +9 -2
- package/src/extension/legion-doctor.ts +54 -0
- package/src/extension/legion-stop.ts +6 -2
- package/src/extension/playbook.ts +3 -3
- package/src/extension/runtime.ts +57 -8
- package/src/extension/scout-guard.ts +39 -1
- package/src/extension/session-errors.ts +191 -0
- package/src/runs/args.ts +8 -0
- package/src/runs/orchestrator.ts +125 -16
- package/src/shared/output-cap.ts +2 -2
- package/src/shared/session.ts +2 -1
- package/src/shared/types.ts +74 -0
- package/src/supervisor/child-context.ts +90 -0
- package/src/supervisor/child-memory.ts +27 -3
- package/src/supervisor/inprocess-depth.ts +71 -0
- package/src/supervisor/inprocess-supervisor.ts +1011 -0
- package/src/supervisor/routing-supervisor.ts +179 -0
- package/src/supervisor/rpc-supervisor.ts +14 -0
- package/src/supervisor/spawn-mode.ts +35 -0
- package/src/supervisor/types.ts +13 -0
- package/src/supervisor/ui-proxy.ts +78 -0
- package/src/tui/live-overlay.ts +147 -55
- package/src/tui/status.ts +71 -35
- package/src/tui/viz/model.ts +6 -0
- package/src/tui/viz/panels.ts +8 -9
- package/src/tui/viz/railway.ts +154 -21
- package/src/tui/viz/rate.ts +15 -14
package/src/tui/viz/panels.ts
CHANGED
|
@@ -13,7 +13,7 @@ export interface ViewData {
|
|
|
13
13
|
nodes: readonly VizNode[];
|
|
14
14
|
sampler: RateSampler;
|
|
15
15
|
now: number;
|
|
16
|
-
/** Header
|
|
16
|
+
/** Header tok/s (live agents only; 0 when none running). Chart avg/buckets keep full-series windows. */
|
|
17
17
|
rate: number;
|
|
18
18
|
}
|
|
19
19
|
|
|
@@ -303,7 +303,7 @@ export function computeStats(data: ViewData, buckets: number): ChartStats {
|
|
|
303
303
|
peakIndex = i;
|
|
304
304
|
}
|
|
305
305
|
});
|
|
306
|
-
//
|
|
306
|
+
// The header tok/s is the shared definition, never the last bucket.
|
|
307
307
|
peak = Math.max(peak, data.rate);
|
|
308
308
|
const peakAt = peakIndex >= 0 && peak > data.rate ? series.times[peakIndex]! : to;
|
|
309
309
|
const roleTotals = new Map<string, number>();
|
|
@@ -315,7 +315,7 @@ export function computeStats(data: ViewData, buckets: number): ChartStats {
|
|
|
315
315
|
}
|
|
316
316
|
return {
|
|
317
317
|
now: data.rate,
|
|
318
|
-
// Full-series window average (includes settled); differs from header
|
|
318
|
+
// Full-series window average (includes settled); differs from the header tok/s on purpose.
|
|
319
319
|
avg: data.sampler.totalRate(to, Math.max(1, to - from)),
|
|
320
320
|
peak,
|
|
321
321
|
peakAt,
|
|
@@ -333,7 +333,7 @@ export function drawChart(cv: Canvas, r: Rect, data: ViewData, selected: VizNode
|
|
|
333
333
|
const statsBelow = statsW === 0 ? Math.min(selected ? 4 : 3, Math.max(0, r.h - 6)) : 0;
|
|
334
334
|
const axisW = 6;
|
|
335
335
|
const plotX = r.x + axisW;
|
|
336
|
-
// With side stats, keep room for the "◂
|
|
336
|
+
// With side stats, keep room for the "◂ N" label so it never runs into the stats column.
|
|
337
337
|
const plotW = Math.max(4, r.w - axisW - statsW - (statsW ? 9 : 0) - 1);
|
|
338
338
|
const plotTop = r.y + 1;
|
|
339
339
|
const plotH = Math.max(1, r.h - 2 - statsBelow);
|
|
@@ -394,14 +394,13 @@ export function drawChart(cv: Canvas, r: Rect, data: ViewData, selected: VizNode
|
|
|
394
394
|
if (stats.peak > 0) yLabel(max, plotTop);
|
|
395
395
|
if (plotH >= 5 && max >= 20) yLabel(max / 2, axisY - 1 - Math.floor(plotH / 2));
|
|
396
396
|
yLabel(0, axisY - 1);
|
|
397
|
-
// X axis
|
|
397
|
+
// X axis: elapsed time since the window start; the right edge is the present.
|
|
398
398
|
cv.text(plotX - 1, axisY, "└" + "─".repeat(plotW), "borderMuted", { maxX: plotX + plotW });
|
|
399
399
|
cv.text(plotX, axisY, "0m", "dim");
|
|
400
400
|
const mid = formatDuration((to - from) / 2);
|
|
401
401
|
if (plotW > 24) cv.text(plotX + Math.floor(plotW / 2) - Math.floor(mid.length / 2), axisY, mid, "dim");
|
|
402
|
-
cv.text(plotX + plotW - 3, axisY, "now", "text", { bold: true });
|
|
403
402
|
const nowLabel = `◂ ${formatCount(stats.now)}`;
|
|
404
|
-
if (statsW === 0) cv.text(r.x, r.y,
|
|
403
|
+
if (statsW === 0) cv.text(r.x, r.y, `${formatCount(stats.now)} tok/s`, "accent", { bold: true, maxX: Math.max(r.x, plotX + plotW - 22) });
|
|
405
404
|
else cv.text(plotX + plotW + 1, axisY - 1 - Math.min(plotH - 1, Math.floor(rowsFor(stats.now))), nowLabel, "text", { maxX: r.x + r.w - statsW - 1 });
|
|
406
405
|
|
|
407
406
|
const selLines = (): Array<[string, string]> => {
|
|
@@ -410,14 +409,14 @@ export function drawChart(cv: Canvas, r: Rect, data: ViewData, selected: VizNode
|
|
|
410
409
|
const share = stats.total > 0 ? (selected.tokens / stats.total) * 100 : 0;
|
|
411
410
|
return [
|
|
412
411
|
[`▸ ${selected.name}`, roleColor(selected.role)],
|
|
413
|
-
[`${formatCount(rate)} tok/s
|
|
412
|
+
[`${formatCount(rate)} tok/s · ${formatCount(selected.tokens)} tok`, "text"],
|
|
414
413
|
[`${share.toFixed(1)}% of run · ━ bright line`, "dim"],
|
|
415
414
|
];
|
|
416
415
|
};
|
|
417
416
|
if (statsW) {
|
|
418
417
|
const sx = r.x + r.w - statsW;
|
|
419
418
|
const rows: Array<[string, string, string]> = [
|
|
420
|
-
["
|
|
419
|
+
["rate", `${formatCount(stats.now)} tok/s`, "text"],
|
|
421
420
|
["avg", `${formatCount(stats.avg)} tok/s`, "text"],
|
|
422
421
|
["peak", stats.peak > 0 ? `${formatCount(stats.peak)} @ ${formatDuration(stats.peakAt - from)}` : "—", "text"],
|
|
423
422
|
["total", `${formatCount(stats.total)} tok`, "text"],
|
package/src/tui/viz/railway.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* vertical remains traceable.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { formatAgentElapsed } from "../status.ts";
|
|
12
|
+
import { formatAgentElapsed, formatActivityAgo } from "../status.ts";
|
|
13
13
|
import { Canvas, ellipsize } from "./canvas.ts";
|
|
14
14
|
import { formatCount, isAgent, type VizNode } from "./model.ts";
|
|
15
15
|
import { roleColor, type VizTheme } from "./palette.ts";
|
|
@@ -18,6 +18,19 @@ export const RAIL_MAX_LANES = 4;
|
|
|
18
18
|
export const RAIL_MAX_LANES_NARROW = 3;
|
|
19
19
|
export const RAIL_NARROW_COLS = 80;
|
|
20
20
|
export const RAIL_WINDOW_MS = 6 * 60 * 1000;
|
|
21
|
+
/**
|
|
22
|
+
* Floor for the time axis. The right-hand column is measured from what the
|
|
23
|
+
* lanes actually need and the axis takes whatever is left, but never less
|
|
24
|
+
* than this — otherwise a long detail would push the graph off the line.
|
|
25
|
+
*/
|
|
26
|
+
export const RAIL_AXIS_MIN_COLS = 10;
|
|
27
|
+
/** Widest a lane name column may grow (it is content-driven, not reserved). */
|
|
28
|
+
export const RAIL_NAME_MAX_COLS = 16;
|
|
29
|
+
/**
|
|
30
|
+
* Narrowest the lane-name column may shrink. Short names would otherwise
|
|
31
|
+
* collapse the column to 1 and leave the label cramped against the ▶ marker.
|
|
32
|
+
*/
|
|
33
|
+
export const RAIL_NAME_MIN_COLS = 6;
|
|
21
34
|
|
|
22
35
|
/**
|
|
23
36
|
* Map a timestamp onto the shared railway axis.
|
|
@@ -39,7 +52,7 @@ export interface RailInput {
|
|
|
39
52
|
nodes: readonly VizNode[];
|
|
40
53
|
now: number;
|
|
41
54
|
width: number;
|
|
42
|
-
/** Header
|
|
55
|
+
/** Header tok/s (live agents only; 0 when none running). */
|
|
43
56
|
rate: number;
|
|
44
57
|
windowMs?: number;
|
|
45
58
|
/** Lane scroll position when more agents are live than lanes fit. */
|
|
@@ -141,6 +154,128 @@ export function formatRate(rate: number): string {
|
|
|
141
154
|
return `${formatCount(rate)} tok/s`;
|
|
142
155
|
}
|
|
143
156
|
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Wide-lane detail after the name: role, turn/lifetime clocks, and
|
|
160
|
+
* last-activity age.
|
|
161
|
+
*
|
|
162
|
+
* The leading clock is "time since the last task started": it keeps ticking
|
|
163
|
+
* through the quiet gap after a turn settles (the old behavior froze at that
|
|
164
|
+
* turn's end) and stops when the agent itself ends.
|
|
165
|
+
*
|
|
166
|
+
* Clocks show from round 1. #159 gated them behind `turns >= 2`, but a child
|
|
167
|
+
* is usually dispatched with a single user message, so in practice that gate
|
|
168
|
+
* hid the clocks almost always and left the lane with a bare `role · 1s ago`
|
|
169
|
+
* (~13 cols) — the axis then absorbed the rest of the line. A round-1 agent
|
|
170
|
+
* has a real start time and a real lifetime; show both.
|
|
171
|
+
*/
|
|
172
|
+
export function formatRailLaneDetail(node: VizNode, now: number): string {
|
|
173
|
+
const parts: string[] = [node.role];
|
|
174
|
+
parts.push(formatAgentElapsed({
|
|
175
|
+
state: node.status,
|
|
176
|
+
startedAt: node.startedAt,
|
|
177
|
+
...(node.endedAt !== undefined ? { finishedAt: node.endedAt } : {}),
|
|
178
|
+
...(node.currentTurnStartedAt !== undefined ? { currentTurnStartedAt: node.currentTurnStartedAt } : {}),
|
|
179
|
+
}, now));
|
|
180
|
+
parts.push(formatActivityAgo(node.lastActivityAt, now));
|
|
181
|
+
return parts.join(" · ");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Lane detail candidates, richest first (role → clocks → ago). */
|
|
185
|
+
export function laneDetailCandidates(node: VizNode, now: number): string[] {
|
|
186
|
+
const ago = formatActivityAgo(node.lastActivityAt, now);
|
|
187
|
+
const clocks = formatAgentElapsed({
|
|
188
|
+
state: node.status,
|
|
189
|
+
startedAt: node.startedAt,
|
|
190
|
+
...(node.endedAt !== undefined ? { finishedAt: node.endedAt } : {}),
|
|
191
|
+
...(node.currentTurnStartedAt !== undefined ? { currentTurnStartedAt: node.currentTurnStartedAt } : {}),
|
|
192
|
+
}, now);
|
|
193
|
+
return [
|
|
194
|
+
`${node.role} · ${clocks} · ${ago}`,
|
|
195
|
+
`${clocks} · ${ago}`,
|
|
196
|
+
clocks,
|
|
197
|
+
ago,
|
|
198
|
+
];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Width of the lane-name column: as wide as the longest name, clamped to
|
|
203
|
+
* `RAIL_NAME_MAX_COLS` and never below `RAIL_NAME_MIN_COLS`.
|
|
204
|
+
*
|
|
205
|
+
* Planning and painting must agree on this, otherwise the axis is sized for
|
|
206
|
+
* one width while the row is drawn at another and the tail gets clipped.
|
|
207
|
+
*/
|
|
208
|
+
export function railNameWidth(shown: readonly VizNode[]): number {
|
|
209
|
+
const longest = shown.length ? Math.max(...shown.map((n) => Array.from(n.name).length)) : 0;
|
|
210
|
+
return Math.max(RAIL_NAME_MIN_COLS, Math.min(RAIL_NAME_MAX_COLS, longest));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Pick the richest lane detail that fits `room` (role → clocks → ago).
|
|
215
|
+
*
|
|
216
|
+
* Callers pass the room shared by every lane, so a field that does not fit
|
|
217
|
+
* one lane is dropped from all of them instead of stripping the role from a
|
|
218
|
+
* single long-named row.
|
|
219
|
+
*/
|
|
220
|
+
export function fitRailLaneDetail(node: VizNode, now: number, room: number): string {
|
|
221
|
+
if (room <= 0) return "";
|
|
222
|
+
const candidates = laneDetailCandidates(node, now);
|
|
223
|
+
for (const candidate of candidates) {
|
|
224
|
+
if (Array.from(candidate).length <= room) return candidate;
|
|
225
|
+
}
|
|
226
|
+
return ellipsize(candidates[candidates.length - 1]!, room);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export interface RailColumnPlan {
|
|
230
|
+
/** Width reserved for the lane name. */
|
|
231
|
+
nameW: number;
|
|
232
|
+
/** Index into each lane's candidate list (0 = richest). */
|
|
233
|
+
grade: number;
|
|
234
|
+
/** Columns the right-hand block needs, including the ▶ marker. */
|
|
235
|
+
needed: number;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Measure the right-hand column from the lanes themselves.
|
|
240
|
+
*
|
|
241
|
+
* The name/detail block is content-driven: the axis takes whatever the
|
|
242
|
+
* terminal has left, down to `RAIL_AXIS_MIN_COLS`. Short labels hand the slack
|
|
243
|
+
* back to the graph; long ones shrink it — so a row always fills the line
|
|
244
|
+
* instead of trailing blank space after the detail text.
|
|
245
|
+
*/
|
|
246
|
+
export function railColumnPlan(
|
|
247
|
+
shown: readonly VizNode[],
|
|
248
|
+
now: number,
|
|
249
|
+
portrait: boolean,
|
|
250
|
+
width: number,
|
|
251
|
+
reserve = 0,
|
|
252
|
+
): RailColumnPlan {
|
|
253
|
+
if (shown.length === 0) return { nameW: 0, grade: 0, needed: 0 };
|
|
254
|
+
const nameW = railNameWidth(shown);
|
|
255
|
+
if (portrait) {
|
|
256
|
+
const label = Math.max(...shown.map((n) => {
|
|
257
|
+
const ago = formatActivityAgo(n.lastActivityAt, now);
|
|
258
|
+
return Array.from(ago === "—" ? n.name : `${n.name} · ${ago}`).length;
|
|
259
|
+
}));
|
|
260
|
+
return { nameW, grade: 0, needed: label + 2 + reserve };
|
|
261
|
+
}
|
|
262
|
+
const candidates = shown.map((n) => laneDetailCandidates(n, now));
|
|
263
|
+
const grades = Array.from(
|
|
264
|
+
{ length: Math.max(...candidates.map((c) => c.length)) },
|
|
265
|
+
(_, g) => Math.max(...candidates.map((c) => Array.from(c[Math.min(g, c.length - 1)]!).length)),
|
|
266
|
+
);
|
|
267
|
+
// Richest grade that still leaves the axis its floor.
|
|
268
|
+
const budget = Math.max(0, width - 1 - reserve - RAIL_AXIS_MIN_COLS - nameW - 3);
|
|
269
|
+
let grade = grades.length - 1;
|
|
270
|
+
for (let g = 0; g < grades.length; g++) {
|
|
271
|
+
if (grades[g]! <= budget) {
|
|
272
|
+
grade = g;
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return { nameW, grade, needed: nameW + grades[grade]! + 3 + reserve };
|
|
277
|
+
}
|
|
278
|
+
|
|
144
279
|
export function renderRailway(input: RailInput): RailRender | undefined {
|
|
145
280
|
const width = Math.max(0, Math.floor(input.width));
|
|
146
281
|
if (width < 20) return undefined;
|
|
@@ -151,9 +286,14 @@ export function renderRailway(input: RailInput): RailRender | undefined {
|
|
|
151
286
|
if (rows === 0) return undefined;
|
|
152
287
|
const portrait = width < RAIL_NARROW_COLS;
|
|
153
288
|
const windowMs = input.windowMs ?? RAIL_WINDOW_MS;
|
|
154
|
-
const
|
|
289
|
+
const live = current.total;
|
|
290
|
+
const above = current.offset > 0;
|
|
291
|
+
const below = current.offset + rows < live;
|
|
292
|
+
// Column widths are measured before the canvas exists: the axis gets the
|
|
293
|
+
// slack, the lanes get what they need.
|
|
294
|
+
const plan = railColumnPlan(current.shown, now, portrait, width, above || below ? 2 : 0);
|
|
155
295
|
const LX = 1;
|
|
156
|
-
const AW = Math.max(
|
|
296
|
+
const AW = Math.max(RAIL_AXIS_MIN_COLS, Math.min(width - LX - 1, width - LX - plan.needed));
|
|
157
297
|
const canvas = new Canvas(width, rows + 1);
|
|
158
298
|
const hit: Array<Array<string | undefined>> = Array.from({ length: rows + 1 }, () => new Array<string | undefined>(width).fill(undefined));
|
|
159
299
|
const byId = new Map(nodes.map((node) => [node.id, node]));
|
|
@@ -227,9 +367,6 @@ export function renderRailway(input: RailInput): RailRender | undefined {
|
|
|
227
367
|
hit[y0 + r]![LX + c] = cell.owner;
|
|
228
368
|
}
|
|
229
369
|
|
|
230
|
-
const live = current.total;
|
|
231
|
-
const above = current.offset > 0;
|
|
232
|
-
const below = current.offset + rows < live;
|
|
233
370
|
const rateText = formatRate(input.rate);
|
|
234
371
|
const winLabel = ` −${Math.max(1, Math.round(windowMs / 60000))}m `;
|
|
235
372
|
canvas.text(LX + 1, 0, winLabel, "dim");
|
|
@@ -285,22 +422,18 @@ export function renderRailway(input: RailInput): RailRender | undefined {
|
|
|
285
422
|
const overflow = i === 0 && above ? " ▲" : i === rows - 1 && below ? " ▼" : "";
|
|
286
423
|
const avail = width - (nameX + 2) - overflow.length;
|
|
287
424
|
if (portrait) {
|
|
288
|
-
|
|
425
|
+
const ago = formatActivityAgo(node.lastActivityAt, now);
|
|
426
|
+
const label = ago === "—" ? node.name : `${node.name} · ${ago}`;
|
|
427
|
+
canvas.text(nameX + 2, y, ellipsize(label, avail), col, { bold: true });
|
|
289
428
|
} else {
|
|
290
|
-
|
|
291
|
-
|
|
429
|
+
// Paint with the same width the plan measured (clamped to what is
|
|
430
|
+
// actually available): mismatched widths sized the axis for one layout
|
|
431
|
+
// and drew another, clipping the tail of the detail text.
|
|
432
|
+
const nameW = Math.min(plan.nameW, Math.max(1, avail));
|
|
292
433
|
canvas.text(nameX + 2, y, ellipsize(node.name, nameW), col, { bold: true });
|
|
293
|
-
const
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
startedAt: node.startedAt,
|
|
297
|
-
...(node.endedAt !== undefined ? { finishedAt: node.endedAt } : {}),
|
|
298
|
-
...(node.currentTurnStartedAt !== undefined ? { currentTurnStartedAt: node.currentTurnStartedAt } : {}),
|
|
299
|
-
...(node.currentTurnEndedAt !== undefined ? { currentTurnEndedAt: node.currentTurnEndedAt } : {}),
|
|
300
|
-
}, now);
|
|
301
|
-
const full = `${node.role} · ${elapsed}`;
|
|
302
|
-
const detail = ellipsize(Array.from(full).length <= room ? full : elapsed, room);
|
|
303
|
-
canvas.text(nameX + 3 + nameW, y, detail, "dim");
|
|
434
|
+
const candidates = laneDetailCandidates(node, now);
|
|
435
|
+
const detail = candidates[Math.min(plan.grade, candidates.length - 1)]!;
|
|
436
|
+
canvas.text(nameX + 3 + nameW, y, detail, "dim", { maxX: width - overflow.length });
|
|
304
437
|
}
|
|
305
438
|
if (overflow) canvas.text(width - overflow.length, y, overflow, "text", { bold: true });
|
|
306
439
|
});
|
package/src/tui/viz/rate.ts
CHANGED
|
@@ -1,23 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Minimal in-memory token-rate sampling.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* tree detail t/s) uses ONE definition:
|
|
4
|
+
* Two UI surfaces show throughput with different definitions:
|
|
6
5
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
6
|
+
* - Header / snap tok/s (`nowRate`): 60s trailing window + live (running)
|
|
7
|
+
* agents only. Numerator = Δ cumulative output tokens; denominator =
|
|
8
|
+
* Δ cumulative generationMs over the same window (falls back to wall-clock
|
|
9
|
+
* window when generationMs is unavailable / zero, or when in-window
|
|
10
|
+
* ΔgenerationMs is below MIN_RATE_GENERATION_MS — same 1s floor as the
|
|
11
|
+
* tree-row gate). Once nothing is live, the header rate is 0 even if the
|
|
12
|
+
* window still holds Δtokens.
|
|
13
|
+
* - Tree-row label (`avg t/s` in status.ts): lifetime cumulative average
|
|
14
|
+
* `tokensOut / (generationMs/1000)` — no window, no live filter.
|
|
12
15
|
*
|
|
13
16
|
* generationMs comes from session JSONL: sum of (assistant_ts − prior
|
|
14
17
|
* user/toolResult_ts). Idle and tool gaps are excluded. Approximate —
|
|
15
18
|
* usage lands when each assistant message completes, not mid-stream.
|
|
16
19
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* chart buckets/avg so the history window stays accurate; once nothing is live,
|
|
20
|
-
* "now" is forced to 0 even if the 60s window still holds Δtokens.
|
|
20
|
+
* Settled/retired series keep contributing to chart buckets/avg so the
|
|
21
|
+
* history window stays accurate.
|
|
21
22
|
*/
|
|
22
23
|
|
|
23
24
|
import type { VizNode } from "./model.ts";
|
|
@@ -172,7 +173,7 @@ export class RateSampler {
|
|
|
172
173
|
|
|
173
174
|
/**
|
|
174
175
|
* Sum of node rates. When `onlyNodeIds` is set (including empty), only those
|
|
175
|
-
* series contribute — used for header
|
|
176
|
+
* series contribute — used for the header tok/s. Omit it for chart buckets/avg
|
|
176
177
|
* so settled agents still paint their trailing-window history.
|
|
177
178
|
*/
|
|
178
179
|
totalRate(now: number, windowMs = RATE_WINDOW_MS, onlyNodeIds?: Iterable<string>): number {
|
|
@@ -185,7 +186,7 @@ export class RateSampler {
|
|
|
185
186
|
return sum;
|
|
186
187
|
}
|
|
187
188
|
|
|
188
|
-
/** Header / snap
|
|
189
|
+
/** Header / snap tok/s: live (running) agents only; none live → 0. */
|
|
189
190
|
nowRate(now: number, nodes: readonly VizNode[], windowMs = RATE_WINDOW_MS): number {
|
|
190
191
|
return this.totalRate(now, windowMs, liveRateNodeIds(nodes));
|
|
191
192
|
}
|
|
@@ -239,7 +240,7 @@ export class RateSampler {
|
|
|
239
240
|
}
|
|
240
241
|
}
|
|
241
242
|
|
|
242
|
-
/** Node ids that contribute to header
|
|
243
|
+
/** Node ids that contribute to the header tok/s (running agents; root excluded). */
|
|
243
244
|
export function liveRateNodeIds(nodes: readonly VizNode[]): string[] {
|
|
244
245
|
return nodes.filter((n) => n.id !== "root" && n.status === "running").map((n) => n.id);
|
|
245
246
|
}
|