@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.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import * as Option from "effect/Option";
2
2
  import * as Schema from "effect/Schema";
3
- import { FlowEvent } from "@llm4ts/flow/FlowEvents";
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) => ({ ...open, executor: lease.executor }));
73
- return rosterLog(placed, at, `${lease.executor} → coder${lease.label === undefined ? "" : ` · ${lease.label}`}`);
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
- return {
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
- case "judge":
85
- return {
86
- ...held,
87
- judge: { ...held.judge, executor: lease.executor, verdicts: held.judge.verdicts + 1 }
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
- : updateLane(named, lane.id, (open) => ({
106
- ...open,
107
- lastEventAt: at,
108
- ...(executor !== undefined && open.executor !== executor ? { executor } : {})
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) => ({ ...open, running: [...open.running, work] }));
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
- activity: { text: `${event.tool} ${event.args}`, since: at, tool: true },
286
- lastTool: `${event.tool} ${event.args}`,
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 === "lanes"
457
- ? { lane: view.selected }
458
- : { executor: view.selected };
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 it, `t` tails its transcript, `r`
462
- * cycles the tail's role, escape closes, `e` switches lanes and executors,
463
- * `l` the full log, `q` quits. Keys it does not know return the same view.
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 === "lanes" ? runningLanes(state).map((lane) => lane.id) : state.executors;
467
- const index = choices.findIndex((id) => id === view.selected);
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 id = choices[Math.max(0, Math.min(choices.length - 1, next))];
470
- return id === undefined ? view : { ...view, selected: id };
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.selected === undefined || view.mode !== "lanes"
629
+ return view.mode === "executors" || chosen() === undefined
483
630
  ? view
484
631
  : { ...view, expanded: !view.expanded };
485
- case "t":
486
- return view.selected === undefined
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 === "lanes" ? "executors" : "lanes",
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
- const maxLaneBoxes = 3;
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
- const expandedLane = (lane, width, now) => box(width, "running", [
682
- [span(`story ${lane.id}`, "bold"), span(` ${elapsed(lane.startedAt, now)}`)],
683
- ...lane.stages.map((stage) => [span(stage)]),
684
- [span(`tools (last ${lane.tools.length})`, "dim")],
685
- ...lane.tools.map((tool) => [span(` ${tool}`, "dim")]),
686
- [span(`${formatCount(lane.tokens)} tok · ${dollars(lane.costUsd)}`)]
687
- ], `${lane.id} · ${lane.executor ?? "(leasing)"}`);
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.lanes.filter((lane) => lane.status === "running");
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 = running.find((lane) => lane.id === view.selected);
754
- const shown = running.slice(0, maxLaneBoxes);
755
- const executors = state.executors.slice(0, maxLaneBoxes + 1);
756
- const lanes = view.tail && view.selected !== undefined
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.expanded && selected !== undefined
759
- ? expandedLane(selected, width, state.now)
760
- : view.mode === "executors"
761
- ? executors.length === 0
762
- ? [centre([span("no executors yet", "dim")], width)]
763
- : columnsOf(executors, width, (executor, each) => executorBox(state, executor, each, executor === view.selected))
764
- : shown.length === 0
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
- : columnsOf(shown, width, (lane, each) => laneBox(lane, each, state.now, lane.id === view.selected, state.idleAfterMs));
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
- centre([span(`delegate to roster · ${running.length} running`)], width),
772
- centre([span("▼", "dim")], width),
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 session
826
- * log gives way first — fewer lines, down to one, then none — and only then
827
- * is the frame cut, keeping its status lines, so a redraw never scrolls.
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