@llm4ts/runner 2.40.0 → 2.41.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/dist/AgentTree.d.ts +59 -8
- package/dist/AgentTree.d.ts.map +1 -1
- package/dist/AgentTree.js +462 -101
- package/dist/AgentTree.js.map +1 -1
- package/dist/FlowRunner.d.ts.map +1 -1
- package/dist/FlowRunner.js +7 -3
- package/dist/FlowRunner.js.map +1 -1
- package/dist/Terminal.d.ts.map +1 -1
- package/dist/Terminal.js +13 -0
- package/dist/Terminal.js.map +1 -1
- package/package.json +3 -3
- package/src/AgentTree.ts +630 -124
- package/src/FlowRunner.ts +11 -3
- package/src/Terminal.ts +13 -0
package/dist/AgentTree.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as Option from "effect/Option";
|
|
2
2
|
import * as Schema from "effect/Schema";
|
|
3
|
-
import {
|
|
3
|
+
import { toolCategory } from "@llm4ts/flow/Activity";
|
|
4
|
+
import { FlowEvent, cloneName } from "@llm4ts/flow/FlowEvents";
|
|
4
5
|
import { estimateCostUsd } from "@llm4ts/flow/PriceList";
|
|
5
6
|
import { formatCount } from "./Terminal.js";
|
|
6
7
|
export const defaultIdleAfterMs = 120_000;
|
|
@@ -24,7 +25,7 @@ export const emptyTree = (options = {}) => ({
|
|
|
24
25
|
executors: [],
|
|
25
26
|
leases: [],
|
|
26
27
|
exclusions: [],
|
|
27
|
-
judge: { executor: undefined, reviews: 0, verdicts: 0 },
|
|
28
|
+
judge: { executor: undefined, reviews: 0, verdicts: 0, borrowed: 0 },
|
|
28
29
|
verdict: undefined,
|
|
29
30
|
judgments: [],
|
|
30
31
|
log: [],
|
|
@@ -33,7 +34,8 @@ export const emptyTree = (options = {}) => ({
|
|
|
33
34
|
ended: undefined,
|
|
34
35
|
time: { model: 0, tools: 0, gates: 0, merge: 0, wait: 0 },
|
|
35
36
|
running: [],
|
|
36
|
-
idleAfterMs: options.idleAfterMs ?? defaultIdleAfterMs
|
|
37
|
+
idleAfterMs: options.idleAfterMs ?? defaultIdleAfterMs,
|
|
38
|
+
lastChanged: undefined
|
|
37
39
|
});
|
|
38
40
|
const costOf = (model, usage) => usage.costUsd ?? (model === undefined ? undefined : estimateCostUsd(model, usage)) ?? 0;
|
|
39
41
|
const logged = (state, entry) => ({
|
|
@@ -46,6 +48,34 @@ const updateLane = (state, id, update) => ({
|
|
|
46
48
|
...state,
|
|
47
49
|
lanes: state.lanes.map((lane) => (lane.id === id ? update(lane) : lane))
|
|
48
50
|
});
|
|
51
|
+
/** A wait label that is a harness pausing itself, not the roster or a gate. */
|
|
52
|
+
const isPause = (label) => /^pi (retry|compaction)$/u.test(label);
|
|
53
|
+
/**
|
|
54
|
+
* A delegate tool that starts a sub-agent: Claude's Agent (Task before
|
|
55
|
+
* 2.1.63), Codex's spawn_agent, Gemini's agents. Codex's wait, send_input,
|
|
56
|
+
* resume_agent and close_agent act on one that exists.
|
|
57
|
+
*/
|
|
58
|
+
const spawningTools = /^(agent|task|spawn_agent|codebase_investigator|generalist|cli_help|browser_agent)$/iu;
|
|
59
|
+
/**
|
|
60
|
+
* A delegate call whose end ends the sub-agent: a synchronous one (Agent,
|
|
61
|
+
* a Gemini agent) or Codex's wait and close; spawn_agent returns at once
|
|
62
|
+
* with the sub-agent still running (ADR 0033).
|
|
63
|
+
*/
|
|
64
|
+
const endsChild = (tool) => !/^(spawn_agent|send_input|resume_agent)$/iu.test(tool);
|
|
65
|
+
const newestOpenChild = (children) => {
|
|
66
|
+
for (let index = children.length - 1; index >= 0; index -= 1) {
|
|
67
|
+
if (children[index]?.ended === false) {
|
|
68
|
+
return index;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return -1;
|
|
72
|
+
};
|
|
73
|
+
const endNewestChild = (children) => {
|
|
74
|
+
const index = newestOpenChild(children);
|
|
75
|
+
return index < 0
|
|
76
|
+
? children
|
|
77
|
+
: children.map((child, position) => (position === index ? { ...child, ended: true } : child));
|
|
78
|
+
};
|
|
49
79
|
const withoutLast = (items, item) => {
|
|
50
80
|
const index = items.lastIndexOf(item);
|
|
51
81
|
return index < 0 ? items : [...items.slice(0, index), ...items.slice(index + 1)];
|
|
@@ -62,30 +92,38 @@ const withExecutor = (state, executor) => state.executors.includes(executor)
|
|
|
62
92
|
? state
|
|
63
93
|
: { ...state, executors: [...state.executors, executor] };
|
|
64
94
|
const rosterLog = (state, at, what) => logged(state, { at, source: "roster", who: "roster", what });
|
|
65
|
-
const reduceLease = (state, at, lease) => {
|
|
95
|
+
const reduceLease = (state, at, lease, borrowed) => {
|
|
66
96
|
const held = { ...withExecutor(state, lease.executor), leases: [...state.leases, lease] };
|
|
97
|
+
const who = cloneName(lease.executor, lease.clone);
|
|
98
|
+
const forLabel = lease.label === undefined ? "" : ` · ${lease.label}`;
|
|
67
99
|
switch (lease.role) {
|
|
68
100
|
case "coder": {
|
|
69
101
|
const lane = held.lanes.find((candidate) => candidate.id === lease.label);
|
|
70
102
|
const placed = lane === undefined
|
|
71
103
|
? held
|
|
72
|
-
: updateLane(held, lane.id, (open) => ({
|
|
73
|
-
|
|
104
|
+
: updateLane(held, lane.id, (open) => ({
|
|
105
|
+
...open,
|
|
106
|
+
executor: lease.executor,
|
|
107
|
+
clone: lease.clone
|
|
108
|
+
}));
|
|
109
|
+
return rosterLog(placed, at, `${who} → coder${forLabel}`);
|
|
74
110
|
}
|
|
75
111
|
case "reviewer":
|
|
76
|
-
|
|
112
|
+
case "judge": {
|
|
113
|
+
const seat = {
|
|
77
114
|
...held,
|
|
78
115
|
judge: {
|
|
79
116
|
...held.judge,
|
|
80
|
-
executor: held.judge.executor ?? lease.executor,
|
|
81
|
-
reviews: held.judge.reviews + 1
|
|
117
|
+
executor: lease.role === "judge" ? lease.executor : (held.judge.executor ?? lease.executor),
|
|
118
|
+
reviews: held.judge.reviews + (lease.role === "reviewer" ? 1 : 0),
|
|
119
|
+
verdicts: held.judge.verdicts + (lease.role === "judge" ? 1 : 0),
|
|
120
|
+
borrowed: held.judge.borrowed + (borrowed ? 1 : 0)
|
|
82
121
|
}
|
|
83
122
|
};
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
};
|
|
123
|
+
return borrowed
|
|
124
|
+
? rosterLog(seat, at, `${lease.role} borrowed from own executor ${who}${forLabel}`)
|
|
125
|
+
: seat;
|
|
126
|
+
}
|
|
89
127
|
default:
|
|
90
128
|
return held;
|
|
91
129
|
}
|
|
@@ -102,11 +140,14 @@ const reduceEvent = (state, at, event) => {
|
|
|
102
140
|
: { ...state, executors: [...state.executors, executor] };
|
|
103
141
|
const current = lane === undefined
|
|
104
142
|
? named
|
|
105
|
-
:
|
|
106
|
-
...open
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
143
|
+
: {
|
|
144
|
+
...updateLane(named, lane.id, (open) => ({
|
|
145
|
+
...open,
|
|
146
|
+
lastEventAt: at,
|
|
147
|
+
...(executor !== undefined && open.executor !== executor ? { executor } : {})
|
|
148
|
+
})),
|
|
149
|
+
lastChanged: lane.id
|
|
150
|
+
};
|
|
110
151
|
switch (event._tag) {
|
|
111
152
|
case "StageStarted": {
|
|
112
153
|
const story = storyStage.exec(event.stage)?.[1];
|
|
@@ -125,11 +166,17 @@ const reduceEvent = (state, at, event) => {
|
|
|
125
166
|
activity: undefined,
|
|
126
167
|
turns: [],
|
|
127
168
|
gatesMs: 0,
|
|
128
|
-
running: []
|
|
169
|
+
running: [],
|
|
170
|
+
clone: undefined,
|
|
171
|
+
tasks: [],
|
|
172
|
+
task: undefined,
|
|
173
|
+
children: [],
|
|
174
|
+
pause: undefined
|
|
129
175
|
};
|
|
130
176
|
return {
|
|
131
177
|
...current,
|
|
132
|
-
lanes: [...current.lanes.filter((open) => open.id !== story), fresh]
|
|
178
|
+
lanes: [...current.lanes.filter((open) => open.id !== story), fresh],
|
|
179
|
+
lastChanged: story
|
|
133
180
|
};
|
|
134
181
|
}
|
|
135
182
|
if (lane !== undefined) {
|
|
@@ -211,7 +258,15 @@ const reduceEvent = (state, at, event) => {
|
|
|
211
258
|
: event.kind === "model" && event.label === "coder"
|
|
212
259
|
? { activity: undefined, turns: [...open.turns, event.ms] }
|
|
213
260
|
: {}),
|
|
214
|
-
gatesMs: open.gatesMs + (event.kind === "gate" ? event.ms : 0)
|
|
261
|
+
gatesMs: open.gatesMs + (event.kind === "gate" ? event.ms : 0),
|
|
262
|
+
// The delegate call ended: its sub-agent is done — unless it was
|
|
263
|
+
// only spawned, and runs on until a wait or close.
|
|
264
|
+
...(event.kind === "tool" && event.category === "delegate" && endsChild(event.label)
|
|
265
|
+
? { children: endNewestChild(open.children) }
|
|
266
|
+
: {}),
|
|
267
|
+
...(event.kind === "wait" && open.pause?.label === event.label
|
|
268
|
+
? { pause: undefined }
|
|
269
|
+
: {})
|
|
215
270
|
}));
|
|
216
271
|
}
|
|
217
272
|
case "Began": {
|
|
@@ -220,7 +275,14 @@ const reduceEvent = (state, at, event) => {
|
|
|
220
275
|
? event.lane === undefined
|
|
221
276
|
? { ...current, running: [...current.running, work] }
|
|
222
277
|
: current
|
|
223
|
-
: updateLane(current, lane.id, (open) => ({
|
|
278
|
+
: updateLane(current, lane.id, (open) => ({
|
|
279
|
+
...open,
|
|
280
|
+
running: [...open.running, work],
|
|
281
|
+
// A harness pause (`pi retry`, `pi compaction`) is the lane's state while it lasts.
|
|
282
|
+
...(event.kind === "wait" && isPause(event.label)
|
|
283
|
+
? { pause: { label: event.label, since: at } }
|
|
284
|
+
: {})
|
|
285
|
+
}));
|
|
224
286
|
}
|
|
225
287
|
case "StoryJudged": {
|
|
226
288
|
const verdict = {
|
|
@@ -246,11 +308,7 @@ const reduceEvent = (state, at, event) => {
|
|
|
246
308
|
]
|
|
247
309
|
};
|
|
248
310
|
case "ExecutorLeased":
|
|
249
|
-
return reduceLease(current, at, {
|
|
250
|
-
executor: event.executor,
|
|
251
|
-
role: event.role,
|
|
252
|
-
label: event.label
|
|
253
|
-
});
|
|
311
|
+
return reduceLease(current, at, { executor: event.executor, role: event.role, label: event.label, clone: event.clone }, event.borrowed === true);
|
|
254
312
|
case "ExecutorReleased": {
|
|
255
313
|
const index = current.leases.findIndex((lease) => lease.executor === event.executor &&
|
|
256
314
|
lease.role === event.role &&
|
|
@@ -277,15 +335,86 @@ const reduceEvent = (state, at, event) => {
|
|
|
277
335
|
}, at, `${event.executor} back · ${event.why}`);
|
|
278
336
|
case "ExecutorHandedOver":
|
|
279
337
|
return rosterLog(current, at, `${event.label ?? "the run"}: ${event.role} leaves ${event.from} · ${event.reason}`);
|
|
280
|
-
case "ToolUse":
|
|
338
|
+
case "ToolUse": {
|
|
339
|
+
if (lane === undefined) {
|
|
340
|
+
return current;
|
|
341
|
+
}
|
|
342
|
+
const text = `${event.tool} ${event.args}`;
|
|
343
|
+
return updateLane(current, lane.id, (open) => {
|
|
344
|
+
// A call inside a sub-agent lands on the newest open child; without
|
|
345
|
+
// one (an older harness, a lost parent) it is the lane's as before.
|
|
346
|
+
const child = event.parent === undefined ? -1 : newestOpenChild(open.children);
|
|
347
|
+
if (child >= 0) {
|
|
348
|
+
return {
|
|
349
|
+
...open,
|
|
350
|
+
children: open.children.map((candidate, index) => index === child ? { ...candidate, lastTool: text } : candidate)
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
const delegated = toolCategory(event.tool, event.args) === "delegate" && spawningTools.test(event.tool)
|
|
354
|
+
? [
|
|
355
|
+
...open.children,
|
|
356
|
+
{
|
|
357
|
+
id: `${open.id}:${open.children.length + 1}`,
|
|
358
|
+
tool: event.tool,
|
|
359
|
+
args: event.args,
|
|
360
|
+
since: at,
|
|
361
|
+
lastTool: undefined,
|
|
362
|
+
ended: false
|
|
363
|
+
}
|
|
364
|
+
]
|
|
365
|
+
: open.children;
|
|
366
|
+
return {
|
|
367
|
+
...open,
|
|
368
|
+
activity: { text, since: at, tool: true },
|
|
369
|
+
lastTool: text,
|
|
370
|
+
tools: [...open.tools, text].slice(-expandedTools),
|
|
371
|
+
children: delegated
|
|
372
|
+
};
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
case "TasksPlanned":
|
|
376
|
+
return lane === undefined
|
|
377
|
+
? current
|
|
378
|
+
: updateLane(current, lane.id, (open) => ({
|
|
379
|
+
...open,
|
|
380
|
+
tasks: event.tasks.map((task, position) => ({
|
|
381
|
+
index: position + 1,
|
|
382
|
+
title: task.title,
|
|
383
|
+
done: task.completed,
|
|
384
|
+
...(task.satisfies === undefined ? {} : { satisfies: task.satisfies })
|
|
385
|
+
}))
|
|
386
|
+
}));
|
|
387
|
+
case "TaskStarted": {
|
|
388
|
+
if (lane === undefined) {
|
|
389
|
+
return current;
|
|
390
|
+
}
|
|
391
|
+
const satisfies = event.satisfies === undefined ? {} : { satisfies: event.satisfies };
|
|
392
|
+
return updateLane(current, lane.id, (open) => ({
|
|
393
|
+
...open,
|
|
394
|
+
task: { index: event.index, count: event.count, title: event.title, ...satisfies },
|
|
395
|
+
tasks: open.tasks.some((task) => task.index === event.index)
|
|
396
|
+
? open.tasks
|
|
397
|
+
: [
|
|
398
|
+
...open.tasks,
|
|
399
|
+
{ index: event.index, title: event.title, done: false, ...satisfies }
|
|
400
|
+
].sort((left, right) => left.index - right.index)
|
|
401
|
+
}));
|
|
402
|
+
}
|
|
403
|
+
case "TaskCompleted":
|
|
281
404
|
return lane === undefined
|
|
282
405
|
? current
|
|
283
406
|
: updateLane(current, lane.id, (open) => ({
|
|
284
407
|
...open,
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
tools: [...open.tools, `${event.tool} ${event.args}`].slice(-expandedTools)
|
|
408
|
+
task: undefined,
|
|
409
|
+
tasks: open.tasks.map((task) => task.index === event.index ? { ...task, done: true } : task)
|
|
288
410
|
}));
|
|
411
|
+
case "StoryStatusChanged":
|
|
412
|
+
return {
|
|
413
|
+
...current,
|
|
414
|
+
stories: current.stories.some((story) => story.id === event.id)
|
|
415
|
+
? current.stories.map((story) => story.id === event.id ? { ...story, status: event.status } : story)
|
|
416
|
+
: [...current.stories, { id: event.id, status: event.status }]
|
|
417
|
+
};
|
|
289
418
|
case "TokensUsed": {
|
|
290
419
|
const tokens = event.usage.total;
|
|
291
420
|
const costUsd = costOf(event.model, event.usage);
|
|
@@ -445,30 +574,48 @@ export const initialView = {
|
|
|
445
574
|
fullLog: false,
|
|
446
575
|
tail: false,
|
|
447
576
|
tailRole: undefined,
|
|
448
|
-
tailBack: 0
|
|
577
|
+
tailBack: 0,
|
|
578
|
+
scroll: 0
|
|
449
579
|
};
|
|
450
580
|
const runningLanes = (state) => state.lanes.filter((lane) => lane.status === "running");
|
|
581
|
+
/**
|
|
582
|
+
* The lane the detail box is about: the selection, else the lane that moved
|
|
583
|
+
* last, else the first running one — so an unattended screen still shows
|
|
584
|
+
* what is happening (ADR 0033).
|
|
585
|
+
*/
|
|
586
|
+
export const selectedLane = (state, view) => {
|
|
587
|
+
const running = runningLanes(state);
|
|
588
|
+
return (running.find((lane) => lane.id === view.selected) ??
|
|
589
|
+
running.find((lane) => lane.id === state.lastChanged) ??
|
|
590
|
+
running[0]);
|
|
591
|
+
};
|
|
451
592
|
const tailRoles = [undefined, "coder", "reviewer", "judge"];
|
|
452
593
|
const tailPage = 10;
|
|
453
594
|
/** Whose transcript the tail shows: the selected story, or the selected executor. */
|
|
454
595
|
export const tailTargetOf = (view) => view.selected === undefined
|
|
455
596
|
? undefined
|
|
456
|
-
: view.mode === "
|
|
457
|
-
? {
|
|
458
|
-
: {
|
|
597
|
+
: view.mode === "executors"
|
|
598
|
+
? { executor: view.selected }
|
|
599
|
+
: { lane: view.selected };
|
|
459
600
|
/**
|
|
460
601
|
* The view after a key: arrows or 1–9 select a running lane (an executor in
|
|
461
|
-
* the executors view), enter expands
|
|
462
|
-
* cycles the tail's role, escape closes, `e` switches lanes and
|
|
463
|
-
* `l` the full log, `q` quits. Keys it does not
|
|
602
|
+
* the executors view), enter expands its detail, `t` tails its transcript,
|
|
603
|
+
* `r` cycles the tail's role, escape closes, `e` switches lanes and
|
|
604
|
+
* executors, `b` the boards, `l` the full log, `q` quits. Keys it does not
|
|
605
|
+
* know return the same view.
|
|
464
606
|
*/
|
|
465
607
|
export const onTreeKey = (view, key, state) => {
|
|
466
|
-
const choices = view.mode === "
|
|
467
|
-
const
|
|
608
|
+
const choices = view.mode === "executors" ? state.executors : runningLanes(state).map((lane) => lane.id);
|
|
609
|
+
const current = view.mode === "executors" ? view.selected : selectedLane(state, view)?.id;
|
|
610
|
+
const index = choices.findIndex((id) => id === current);
|
|
468
611
|
const select = (next) => {
|
|
469
|
-
const
|
|
470
|
-
|
|
612
|
+
const row = Math.max(0, Math.min(choices.length - 1, next));
|
|
613
|
+
const id = choices[row];
|
|
614
|
+
return id === undefined
|
|
615
|
+
? view
|
|
616
|
+
: { ...view, selected: id, scroll: row < view.scroll ? row : view.scroll };
|
|
471
617
|
};
|
|
618
|
+
const chosen = () => view.mode === "executors" ? view.selected : selectedLane(state, view)?.id;
|
|
472
619
|
switch (key) {
|
|
473
620
|
case "q":
|
|
474
621
|
return "quit";
|
|
@@ -479,13 +626,15 @@ export const onTreeKey = (view, key, state) => {
|
|
|
479
626
|
case "left":
|
|
480
627
|
return select(index < 0 ? 0 : index - 1);
|
|
481
628
|
case "enter":
|
|
482
|
-
return view.
|
|
629
|
+
return view.mode === "executors" || chosen() === undefined
|
|
483
630
|
? view
|
|
484
631
|
: { ...view, expanded: !view.expanded };
|
|
485
|
-
case "t":
|
|
486
|
-
|
|
632
|
+
case "t": {
|
|
633
|
+
const id = chosen();
|
|
634
|
+
return id === undefined
|
|
487
635
|
? view
|
|
488
|
-
: { ...view, tail: !view.tail, expanded: false, tailBack: 0 };
|
|
636
|
+
: { ...view, selected: id, tail: !view.tail, expanded: false, tailBack: 0 };
|
|
637
|
+
}
|
|
489
638
|
case "pageup":
|
|
490
639
|
return view.tail ? { ...view, tailBack: view.tailBack + tailPage } : view;
|
|
491
640
|
case "pagedown":
|
|
@@ -502,9 +651,17 @@ export const onTreeKey = (view, key, state) => {
|
|
|
502
651
|
case "e":
|
|
503
652
|
return {
|
|
504
653
|
...view,
|
|
505
|
-
mode: view.mode === "
|
|
654
|
+
mode: view.mode === "executors" ? "lanes" : "executors",
|
|
506
655
|
selected: undefined,
|
|
507
656
|
expanded: false,
|
|
657
|
+
tail: false,
|
|
658
|
+
scroll: 0
|
|
659
|
+
};
|
|
660
|
+
case "b":
|
|
661
|
+
return {
|
|
662
|
+
...view,
|
|
663
|
+
mode: view.mode === "boards" ? "lanes" : "boards",
|
|
664
|
+
expanded: false,
|
|
508
665
|
tail: false
|
|
509
666
|
};
|
|
510
667
|
case "l":
|
|
@@ -514,7 +671,8 @@ export const onTreeKey = (view, key, state) => {
|
|
|
514
671
|
}
|
|
515
672
|
};
|
|
516
673
|
const railWidth = 26;
|
|
517
|
-
|
|
674
|
+
/** The agent list never shrinks below this many rows for the log's sake. */
|
|
675
|
+
const minListRows = 3;
|
|
518
676
|
const counter = (label, value) => [
|
|
519
677
|
span(`${label.padEnd(15)}${String(value).padStart(5)}`)
|
|
520
678
|
];
|
|
@@ -530,26 +688,8 @@ const railOf = (state) => box(railWidth, "judge", [
|
|
|
530
688
|
],
|
|
531
689
|
[],
|
|
532
690
|
counter("reviews", state.judge.reviews),
|
|
533
|
-
counter("verdicts", state.judge.verdicts)
|
|
534
|
-
])
|
|
535
|
-
const laneBox = (lane, width, now, selected, idleAfterMs) => box(width, "running", [
|
|
536
|
-
[span(selected ? `▸ ${lane.id}` : lane.id, "bold")],
|
|
537
|
-
[span(lane.executor ?? "(leasing)")],
|
|
538
|
-
[span(lane.stages.at(-1) ?? "starting")],
|
|
539
|
-
doingLine(lane, now),
|
|
540
|
-
[
|
|
541
|
-
span(`${elapsed(lane.startedAt, now)} · ${formatCount(lane.tokens)} tok${lane.costUsd > 0 ? ` · ${dollars(lane.costUsd)}` : ""}`)
|
|
542
|
-
],
|
|
543
|
-
...(lane.turns.length === 0 && lane.gatesMs === 0
|
|
544
|
-
? []
|
|
545
|
-
: [
|
|
546
|
-
[
|
|
547
|
-
span(`turns ${lane.turns.length} · avg ${elapsedMs(lane.turns.length === 0
|
|
548
|
-
? 0
|
|
549
|
-
: lane.turns.reduce((total, turn) => total + turn, 0) / lane.turns.length)} · gates ${elapsedMs(lane.gatesMs)}`)
|
|
550
|
-
]
|
|
551
|
-
]),
|
|
552
|
-
statusLine(lane, now, idleAfterMs)
|
|
691
|
+
counter("verdicts", state.judge.verdicts),
|
|
692
|
+
...(state.judge.borrowed === 0 ? [] : [counter("borrowed", state.judge.borrowed)])
|
|
553
693
|
]);
|
|
554
694
|
/** Work under way as a card names it: the command itself, the call, the wait. */
|
|
555
695
|
export const workText = (work) => {
|
|
@@ -678,13 +818,219 @@ const columnsOf = (items, width, draw) => {
|
|
|
678
818
|
const each = Math.floor((width - 2 * (items.length - 1)) / items.length);
|
|
679
819
|
return beside(items.map((item) => draw(item, each)), items.map(() => each));
|
|
680
820
|
};
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
[
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
821
|
+
/** `◐`/`⏸`: the lane's status at a glance, as `statusLine` grades it. */
|
|
822
|
+
const statusMark = (lane, now, idleAfterMs) => {
|
|
823
|
+
const line = statusLine(lane, now, idleAfterMs);
|
|
824
|
+
const text = line[0]?.text ?? "";
|
|
825
|
+
return span(text.startsWith("◐") ? "◐" : "⏸", line[0]?.style);
|
|
826
|
+
};
|
|
827
|
+
/** `3/5 wire session cookie`, or `2/5 tasks` between tasks, or nothing before the plan. */
|
|
828
|
+
const taskText = (lane) => lane.task !== undefined
|
|
829
|
+
? `${lane.task.index}/${lane.task.count} ${lane.task.title}`
|
|
830
|
+
: lane.tasks.length > 0
|
|
831
|
+
? `${lane.tasks.filter((task) => task.done).length}/${lane.tasks.length} tasks`
|
|
832
|
+
: "";
|
|
833
|
+
/** `head` then `tail` in `width`: a long head is cut, the tail (a timer, a count) never is. */
|
|
834
|
+
const headThenTail = (head, tail, width) => {
|
|
835
|
+
const room = width - [...tail.text].length;
|
|
836
|
+
return lengthOf(head) <= room ? [...head, tail] : [...fit(head, Math.max(1, room)), tail];
|
|
837
|
+
};
|
|
838
|
+
/** The one line a running lane is in the agent list (ADR 0033). */
|
|
839
|
+
const laneLine = (lane, width, now, selected, idleAfterMs) => {
|
|
840
|
+
const task = taskText(lane);
|
|
841
|
+
const stage = lane.stages.at(-1) ?? "starting";
|
|
842
|
+
const head = [
|
|
843
|
+
span(selected ? "▸ " : " "),
|
|
844
|
+
statusMark(lane, now, idleAfterMs),
|
|
845
|
+
span(" "),
|
|
846
|
+
span(lane.id, "bold"),
|
|
847
|
+
span(` ${cloneName(lane.executor ?? "(leasing)", lane.clone)}`),
|
|
848
|
+
...(task.length === 0 ? [] : [span(" · "), span(task)]),
|
|
849
|
+
// A task is a stage of its own name: say it once.
|
|
850
|
+
...(stage === lane.task?.title ? [] : [span(" · "), span(stage, "dim")]),
|
|
851
|
+
...(lane.pause === undefined
|
|
852
|
+
? []
|
|
853
|
+
: [span(" · "), span(`⏸ ${lane.pause.label} ${elapsed(lane.pause.since, now)}`, "judge")])
|
|
854
|
+
];
|
|
855
|
+
return headThenTail(head, span(` · ${elapsed(lane.startedAt, now)} · ${formatCount(lane.tokens)} tok`), width);
|
|
856
|
+
};
|
|
857
|
+
/** A sub-agent the lane's harness spawned, indented under it. */
|
|
858
|
+
const childLine = (child, width, now) => headThenTail([
|
|
859
|
+
span(" └ "),
|
|
860
|
+
span("sub-agent ", "dim"),
|
|
861
|
+
span(`${child.tool} ${child.args}`),
|
|
862
|
+
span(` · ${child.lastTool ?? "starting"}`, "dim")
|
|
863
|
+
], span(` · ${elapsed(child.since, now)}`, "dim"), width);
|
|
864
|
+
/** The list's rows: a running lane, then each sub-agent still running under it. */
|
|
865
|
+
const listRowsOf = (state) => runningLanes(state).reduce((total, lane) => total + 1 + lane.children.filter((child) => !child.ended).length, 0);
|
|
866
|
+
/**
|
|
867
|
+
* Every running agent, one row each, scrolled so the selected lane is in
|
|
868
|
+
* view and cut to `rows` with a count of what is out of view (ADR 0033).
|
|
869
|
+
*/
|
|
870
|
+
const agentRows = (state, width, view, selected, rows) => {
|
|
871
|
+
const all = runningLanes(state).flatMap((lane) => [
|
|
872
|
+
{
|
|
873
|
+
lane: lane.id,
|
|
874
|
+
line: laneLine(lane, width, state.now, lane.id === selected?.id, state.idleAfterMs)
|
|
875
|
+
},
|
|
876
|
+
...lane.children
|
|
877
|
+
.filter((child) => !child.ended)
|
|
878
|
+
.map((child) => ({ lane: lane.id, line: childLine(child, width, state.now) }))
|
|
879
|
+
]);
|
|
880
|
+
if (rows === undefined || all.length <= rows) {
|
|
881
|
+
return all.map((row) => row.line);
|
|
882
|
+
}
|
|
883
|
+
const wanted = all.findIndex((row) => row.lane === selected?.id);
|
|
884
|
+
// The "… n above/more" notes take rows of the budget too.
|
|
885
|
+
const window = (room) => {
|
|
886
|
+
const start = Math.max(0, Math.min(view.scroll, all.length - room));
|
|
887
|
+
return wanted < 0
|
|
888
|
+
? start
|
|
889
|
+
: wanted < start
|
|
890
|
+
? wanted
|
|
891
|
+
: wanted >= start + room
|
|
892
|
+
? wanted - room + 1
|
|
893
|
+
: start;
|
|
894
|
+
};
|
|
895
|
+
let room = Math.max(1, rows - 1);
|
|
896
|
+
let first = window(room);
|
|
897
|
+
if (first > 0 && first + room < all.length) {
|
|
898
|
+
room = Math.max(1, rows - 2);
|
|
899
|
+
first = window(room);
|
|
900
|
+
}
|
|
901
|
+
const shown = all.slice(first, first + room);
|
|
902
|
+
const above = first;
|
|
903
|
+
const below = all.length - first - shown.length;
|
|
904
|
+
return [
|
|
905
|
+
...(above > 0 ? [[span(`… ${above} above`, "dim")]] : []),
|
|
906
|
+
...shown.map((row) => row.line),
|
|
907
|
+
...(below > 0 ? [[span(`… ${below} more`, "dim")]] : [])
|
|
908
|
+
];
|
|
909
|
+
};
|
|
910
|
+
const tick = (task, running) => task.done ? "[x]" : running ? "[▶]" : "[ ]";
|
|
911
|
+
/**
|
|
912
|
+
* The selected lane in full: its task checklist with the criteria each task
|
|
913
|
+
* satisfies, its stage, what it is doing, its tools, a harness pause, and
|
|
914
|
+
* its figures (ADR 0033). Expanded, every stage and tool it ran.
|
|
915
|
+
*/
|
|
916
|
+
const detailBox = (lane, width, now, idleAfterMs, expanded) => {
|
|
917
|
+
const inner = width - 4;
|
|
918
|
+
const checklist = lane.tasks.length === 0
|
|
919
|
+
? [[span("no task plan yet", "dim")]]
|
|
920
|
+
: lane.tasks.map((task) => {
|
|
921
|
+
const running = lane.task?.index === task.index;
|
|
922
|
+
const right = task.satisfies === undefined ? "" : `satisfies ${task.satisfies.join(",")}`;
|
|
923
|
+
const left = [
|
|
924
|
+
span(`${tick(task, running)} ${task.index} ${task.title}`, running ? "bold" : undefined)
|
|
925
|
+
];
|
|
926
|
+
return right.length === 0
|
|
927
|
+
? left
|
|
928
|
+
: [...fit(left, Math.max(1, inner - [...right].length - 1)), span(` ${right}`, "dim")];
|
|
929
|
+
});
|
|
930
|
+
const stages = expanded
|
|
931
|
+
? lane.stages.map((stage) => [span(stage)])
|
|
932
|
+
: [[span("stage "), span(lane.stages.at(-1) ?? "starting")]];
|
|
933
|
+
const tools = expanded
|
|
934
|
+
? [
|
|
935
|
+
[span(`tools (last ${lane.tools.length})`, "dim")],
|
|
936
|
+
...lane.tools.map((tool) => [span(` ${tool}`, "dim")])
|
|
937
|
+
]
|
|
938
|
+
: lane.tools.length === 0
|
|
939
|
+
? []
|
|
940
|
+
: [[span("tools "), span(lane.tools.slice(-3).join(" · "), "dim")]];
|
|
941
|
+
const title = `${lane.id} · ${cloneName(lane.executor ?? "(leasing)", lane.clone)}${lane.task === undefined ? "" : ` · task ${lane.task.index}/${lane.task.count}`}`;
|
|
942
|
+
return box(width, "running", [
|
|
943
|
+
...checklist,
|
|
944
|
+
...stages,
|
|
945
|
+
doingLine(lane, now),
|
|
946
|
+
...(lane.pause === undefined
|
|
947
|
+
? []
|
|
948
|
+
: [[span(`⏸ ${lane.pause.label} ${elapsed(lane.pause.since, now)}`, "judge")]]),
|
|
949
|
+
...tools,
|
|
950
|
+
[
|
|
951
|
+
span(`${elapsed(lane.startedAt, now)} · ${formatCount(lane.tokens)} tok${lane.costUsd > 0 ? ` · ${dollars(lane.costUsd)}` : ""}`)
|
|
952
|
+
],
|
|
953
|
+
...(lane.turns.length === 0 && lane.gatesMs === 0
|
|
954
|
+
? []
|
|
955
|
+
: [
|
|
956
|
+
[
|
|
957
|
+
span(`turns ${lane.turns.length} · avg ${elapsedMs(lane.turns.length === 0
|
|
958
|
+
? 0
|
|
959
|
+
: lane.turns.reduce((total, turn) => total + turn, 0) / lane.turns.length)} · gates ${elapsedMs(lane.gatesMs)}`)
|
|
960
|
+
]
|
|
961
|
+
]),
|
|
962
|
+
statusLine(lane, now, idleAfterMs)
|
|
963
|
+
], title);
|
|
964
|
+
};
|
|
965
|
+
const bar = (done, total, width = 5) => {
|
|
966
|
+
const filled = total === 0 ? 0 : Math.round((done / total) * width);
|
|
967
|
+
return "█".repeat(filled) + "░".repeat(width - filled);
|
|
968
|
+
};
|
|
969
|
+
const boardColumns = [
|
|
970
|
+
["planned", "planned"],
|
|
971
|
+
["active", "running"],
|
|
972
|
+
["waiting", "waiting"],
|
|
973
|
+
["done", "done"],
|
|
974
|
+
["failed", "failed"]
|
|
975
|
+
];
|
|
976
|
+
/**
|
|
977
|
+
* The boards (ADR 0033): the epic's stories by column, with a running lane's
|
|
978
|
+
* clone and progress on its card, then the selected lane's tasks by column.
|
|
979
|
+
*/
|
|
980
|
+
const boardsOf = (state, width, selected, now) => {
|
|
981
|
+
const chips = chipsOf(state);
|
|
982
|
+
// Five columns need ~16 characters each to read; narrower, two rows of columns.
|
|
983
|
+
const epic = chunked(boardColumns, width >= 5 * 16 + 8 ? 5 : 3).flatMap((group) => columnsOf(group, width, ([name, status], each) => {
|
|
984
|
+
const members = chips.filter((chip) => chip.status === status);
|
|
985
|
+
const cards = members.flatMap((chip) => {
|
|
986
|
+
const lane = state.lanes.find((candidate) => candidate.id === chip.id);
|
|
987
|
+
if (lane === undefined || lane.status !== "running") {
|
|
988
|
+
return [[span(chip.id)]];
|
|
989
|
+
}
|
|
990
|
+
const done = lane.tasks.filter((task) => task.done).length;
|
|
991
|
+
const progress = lane.tasks.length === 0
|
|
992
|
+
? ""
|
|
993
|
+
: ` ${done}/${lane.tasks.length}${each >= 24 ? ` ${bar(done, lane.tasks.length)}` : ""}`;
|
|
994
|
+
return [
|
|
995
|
+
[span(chip.id, "bold")],
|
|
996
|
+
[span(` ${cloneName(lane.executor ?? "(leasing)", lane.clone)}${progress}`, "dim")]
|
|
997
|
+
];
|
|
998
|
+
});
|
|
999
|
+
return box(each, "running", cards.length === 0 ? [[span("—", "dim")]] : cards, `${name} (${members.length})`);
|
|
1000
|
+
}));
|
|
1001
|
+
if (selected === undefined) {
|
|
1002
|
+
return [[span("epic board", "bold")], ...epic];
|
|
1003
|
+
}
|
|
1004
|
+
const taskColumns = [
|
|
1005
|
+
["todo", (task) => !task.done && task.index !== selected.task?.index],
|
|
1006
|
+
["doing", (task) => !task.done && task.index === selected.task?.index],
|
|
1007
|
+
["review", () => false],
|
|
1008
|
+
["done", (task) => task.done]
|
|
1009
|
+
];
|
|
1010
|
+
const story = chunked(taskColumns, width >= 4 * 16 + 6 ? 4 : 2).flatMap((group) => columnsOf(group, width, ([name, keep], each) => {
|
|
1011
|
+
const members = selected.tasks.filter(keep);
|
|
1012
|
+
return box(each, "running", members.length === 0
|
|
1013
|
+
? [[span("—", "dim")]]
|
|
1014
|
+
: members.map((task) => [span(`${task.index} ${task.title}`)]), `${name} (${members.length})`);
|
|
1015
|
+
}));
|
|
1016
|
+
return [
|
|
1017
|
+
[span("epic board", "bold")],
|
|
1018
|
+
...epic,
|
|
1019
|
+
[],
|
|
1020
|
+
[
|
|
1021
|
+
span(`story board · ${selected.id} · ${cloneName(selected.executor ?? "(leasing)", selected.clone)}`, "bold"),
|
|
1022
|
+
span(` · ${elapsed(selected.startedAt, now)}`)
|
|
1023
|
+
],
|
|
1024
|
+
...story
|
|
1025
|
+
];
|
|
1026
|
+
};
|
|
1027
|
+
/** `codex ×2 · claude ×1`: how many leases each executor holds now. */
|
|
1028
|
+
const leaseCounts = (state) => state.executors
|
|
1029
|
+
.map((executor) => [executor, state.leases.filter((lease) => lease.executor === executor).length])
|
|
1030
|
+
.filter(([, count]) => count > 0)
|
|
1031
|
+
.map(([executor, count]) => `${executor} ×${count}`)
|
|
1032
|
+
.join(" · ");
|
|
1033
|
+
const chunked = (items, size) => Array.from({ length: Math.ceil(items.length / size) }, (_, index) => items.slice(index * size, index * size + size));
|
|
688
1034
|
const barWidth = 10;
|
|
689
1035
|
const judgmentRows = 3;
|
|
690
1036
|
const meter = (fraction) => {
|
|
@@ -728,8 +1074,8 @@ const tailOf = (view, tail, width) => box(width, "running", tail === undefined |
|
|
|
728
1074
|
]
|
|
729
1075
|
]
|
|
730
1076
|
: tail.map((line) => [span(line)]), `tail · ${view.selected ?? ""} · ${view.tailRole ?? "all"}`);
|
|
731
|
-
const mainOf = (state, width, view, tail) => {
|
|
732
|
-
const running = state
|
|
1077
|
+
const mainOf = (state, width, view, tail, listRows) => {
|
|
1078
|
+
const running = runningLanes(state);
|
|
733
1079
|
const orchestratorWidth = Math.min(46, width);
|
|
734
1080
|
const stage = state.stages.at(-1) ?? (running.length > 0 ? "implement stories" : "idle");
|
|
735
1081
|
// The run's own work under way (its gates, outside any story), innermost.
|
|
@@ -750,27 +1096,29 @@ const mainOf = (state, width, view, tail) => {
|
|
|
750
1096
|
]),
|
|
751
1097
|
[span(`stories ${chipsOf(state).length} elapsed ${elapsed(state.startedAt, state.now)}`)]
|
|
752
1098
|
], state.title);
|
|
753
|
-
const selected =
|
|
754
|
-
const
|
|
755
|
-
const
|
|
756
|
-
const
|
|
1099
|
+
const selected = selectedLane(state, view);
|
|
1100
|
+
const counts = leaseCounts(state);
|
|
1101
|
+
const agents = centre([span(`agents · ${running.length} running${counts.length === 0 ? "" : ` · ${counts}`}`)], width);
|
|
1102
|
+
const body = view.tail && view.selected !== undefined
|
|
757
1103
|
? tailOf(view, tail, width)
|
|
758
|
-
: view.
|
|
759
|
-
?
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
:
|
|
1104
|
+
: view.mode === "executors"
|
|
1105
|
+
? state.executors.length === 0
|
|
1106
|
+
? [centre([span("no executors yet", "dim")], width)]
|
|
1107
|
+
: chunked(state.executors, 4).flatMap((group) => columnsOf(group, width, (executor, each) => executorBox(state, executor, each, executor === view.selected)))
|
|
1108
|
+
: view.mode === "boards"
|
|
1109
|
+
? boardsOf(state, width, selected, state.now)
|
|
1110
|
+
: running.length === 0 || selected === undefined
|
|
765
1111
|
? [centre([span("no stories in flight", "dim")], width)]
|
|
766
|
-
:
|
|
1112
|
+
: [
|
|
1113
|
+
...agentRows(state, width, view, selected, listRows),
|
|
1114
|
+
...detailBox(selected, width, state.now, state.idleAfterMs, view.expanded)
|
|
1115
|
+
];
|
|
767
1116
|
return [
|
|
768
1117
|
...orchestrator.map((line) => centre(line, width)),
|
|
769
1118
|
centre([span("•", "orchestrator")], width),
|
|
770
1119
|
...judgmentOf(state, width),
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
...lanes,
|
|
1120
|
+
agents,
|
|
1121
|
+
...body,
|
|
774
1122
|
[],
|
|
775
1123
|
...wrapChips(chipsOf(state).map(chipOf), width)
|
|
776
1124
|
];
|
|
@@ -795,7 +1143,7 @@ const timeSplitOf = (time) => {
|
|
|
795
1143
|
];
|
|
796
1144
|
};
|
|
797
1145
|
/** A frame with the session log at `logCount` lines, or without it. */
|
|
798
|
-
const frameOf = (state, options, logCount) => {
|
|
1146
|
+
const frameOf = (state, options, logCount, listRows) => {
|
|
799
1147
|
const { width } = options;
|
|
800
1148
|
const mainWidth = width - railWidth - 2;
|
|
801
1149
|
const chips = chipsOf(state);
|
|
@@ -805,7 +1153,7 @@ const frameOf = (state, options, logCount) => {
|
|
|
805
1153
|
centre([span("LLM4TS AGENT TREE", "bold"), span(" · "), span(state.title, "orchestrator")], width),
|
|
806
1154
|
[span("═".repeat(width), "dim")],
|
|
807
1155
|
[],
|
|
808
|
-
...beside([railOf(state), mainOf(state, mainWidth, options.view, options.tail)], [railWidth, mainWidth]),
|
|
1156
|
+
...beside([railOf(state), mainOf(state, mainWidth, options.view, options.tail, listRows)], [railWidth, mainWidth]),
|
|
809
1157
|
...(logCount === "none"
|
|
810
1158
|
? []
|
|
811
1159
|
: [[], ...box(width, "log", logOf(state, logCount), "session log")]),
|
|
@@ -822,24 +1170,37 @@ const frameOf = (state, options, logCount) => {
|
|
|
822
1170
|
return lines.map((line) => paint(fit(line, width), options.colour));
|
|
823
1171
|
};
|
|
824
1172
|
/**
|
|
825
|
-
* The tree at a width and, when `height` is given, no taller: the
|
|
826
|
-
*
|
|
827
|
-
*
|
|
1173
|
+
* The tree at a width and, when `height` is given, no taller: the agent
|
|
1174
|
+
* list gives way first (down to `minListRows`, with a count of what is out
|
|
1175
|
+
* of view), then the session log — fewer lines, down to one, then none —
|
|
1176
|
+
* and only then is the frame cut, keeping its status lines, so a redraw
|
|
1177
|
+
* never scrolls.
|
|
828
1178
|
*/
|
|
829
1179
|
export const renderTree = (state, options) => {
|
|
830
1180
|
const wanted = options.view.fullLog ? fullLogLines : logLines;
|
|
831
|
-
const full = frameOf(state, options, wanted);
|
|
1181
|
+
const full = frameOf(state, options, wanted, undefined);
|
|
832
1182
|
const { height } = options;
|
|
833
1183
|
if (height === undefined || full.length <= height) {
|
|
834
1184
|
return full;
|
|
835
1185
|
}
|
|
1186
|
+
const total = listRowsOf(state);
|
|
1187
|
+
const floor = Math.min(total, minListRows);
|
|
1188
|
+
const listRows = options.view.mode === "lanes" && total > floor
|
|
1189
|
+
? Math.max(floor, total - (full.length - height))
|
|
1190
|
+
: undefined;
|
|
1191
|
+
if (listRows !== undefined) {
|
|
1192
|
+
const shorter = frameOf(state, options, wanted, listRows);
|
|
1193
|
+
if (shorter.length <= height) {
|
|
1194
|
+
return shorter;
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
836
1197
|
for (let count = wanted - 1; count >= 1; count -= 1) {
|
|
837
|
-
const shorter = frameOf(state, options, count);
|
|
1198
|
+
const shorter = frameOf(state, options, count, listRows);
|
|
838
1199
|
if (shorter.length <= height) {
|
|
839
1200
|
return shorter;
|
|
840
1201
|
}
|
|
841
1202
|
}
|
|
842
|
-
return fitToRows(frameOf(state, options, "none"), height);
|
|
1203
|
+
return fitToRows(frameOf(state, options, "none", listRows), height);
|
|
843
1204
|
};
|
|
844
1205
|
/**
|
|
845
1206
|
* A frame cut to a terminal's height: the top of the tree and its two status
|