@llm4ts/runner 2.40.1 → 2.42.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,18 @@ 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,
175
+ taskCoders: []
129
176
  };
130
177
  return {
131
178
  ...current,
132
- lanes: [...current.lanes.filter((open) => open.id !== story), fresh]
179
+ lanes: [...current.lanes.filter((open) => open.id !== story), fresh],
180
+ lastChanged: story
133
181
  };
134
182
  }
135
183
  if (lane !== undefined) {
@@ -138,7 +186,10 @@ const reduceEvent = (state, at, event) => {
138
186
  stages: [...open.stages, event.stage]
139
187
  }));
140
188
  }
141
- return { ...current, stages: [...current.stages, event.stage] };
189
+ // A lane that is not a story's (a task run aside, ADR 0034) is not the run's either.
190
+ return event.lane === undefined
191
+ ? { ...current, stages: [...current.stages, event.stage] }
192
+ : current;
142
193
  }
143
194
  case "StageCompleted":
144
195
  case "StageFailed": {
@@ -163,7 +214,9 @@ const reduceEvent = (state, at, event) => {
163
214
  stages: withoutLast(open.stages, event.stage)
164
215
  }));
165
216
  }
166
- return { ...current, stages: withoutLast(current.stages, event.stage) };
217
+ return event.lane === undefined
218
+ ? { ...current, stages: withoutLast(current.stages, event.stage) }
219
+ : current;
167
220
  }
168
221
  case "Info": {
169
222
  // A story's own milestones are `story <id>: …` (resuming, merged,
@@ -211,7 +264,15 @@ const reduceEvent = (state, at, event) => {
211
264
  : event.kind === "model" && event.label === "coder"
212
265
  ? { activity: undefined, turns: [...open.turns, event.ms] }
213
266
  : {}),
214
- gatesMs: open.gatesMs + (event.kind === "gate" ? event.ms : 0)
267
+ gatesMs: open.gatesMs + (event.kind === "gate" ? event.ms : 0),
268
+ // The delegate call ended: its sub-agent is done — unless it was
269
+ // only spawned, and runs on until a wait or close.
270
+ ...(event.kind === "tool" && event.category === "delegate" && endsChild(event.label)
271
+ ? { children: endNewestChild(open.children) }
272
+ : {}),
273
+ ...(event.kind === "wait" && open.pause?.label === event.label
274
+ ? { pause: undefined }
275
+ : {})
215
276
  }));
216
277
  }
217
278
  case "Began": {
@@ -220,7 +281,14 @@ const reduceEvent = (state, at, event) => {
220
281
  ? event.lane === undefined
221
282
  ? { ...current, running: [...current.running, work] }
222
283
  : current
223
- : updateLane(current, lane.id, (open) => ({ ...open, running: [...open.running, work] }));
284
+ : updateLane(current, lane.id, (open) => ({
285
+ ...open,
286
+ running: [...open.running, work],
287
+ // A harness pause (`pi retry`, `pi compaction`) is the lane's state while it lasts.
288
+ ...(event.kind === "wait" && isPause(event.label)
289
+ ? { pause: { label: event.label, since: at } }
290
+ : {})
291
+ }));
224
292
  }
225
293
  case "StoryJudged": {
226
294
  const verdict = {
@@ -246,11 +314,7 @@ const reduceEvent = (state, at, event) => {
246
314
  ]
247
315
  };
248
316
  case "ExecutorLeased":
249
- return reduceLease(current, at, {
250
- executor: event.executor,
251
- role: event.role,
252
- label: event.label
253
- });
317
+ return reduceLease(current, at, { executor: event.executor, role: event.role, label: event.label, clone: event.clone }, event.borrowed === true);
254
318
  case "ExecutorReleased": {
255
319
  const index = current.leases.findIndex((lease) => lease.executor === event.executor &&
256
320
  lease.role === event.role &&
@@ -277,15 +341,107 @@ const reduceEvent = (state, at, event) => {
277
341
  }, at, `${event.executor} back · ${event.why}`);
278
342
  case "ExecutorHandedOver":
279
343
  return rosterLog(current, at, `${event.label ?? "the run"}: ${event.role} leaves ${event.from} · ${event.reason}`);
280
- case "ToolUse":
344
+ case "ToolUse": {
345
+ if (lane === undefined) {
346
+ return current;
347
+ }
348
+ const text = `${event.tool} ${event.args}`;
349
+ return updateLane(current, lane.id, (open) => {
350
+ // A call inside a sub-agent lands on the newest open child; without
351
+ // one (an older harness, a lost parent) it is the lane's as before.
352
+ const child = event.parent === undefined ? -1 : newestOpenChild(open.children);
353
+ if (child >= 0) {
354
+ return {
355
+ ...open,
356
+ children: open.children.map((candidate, index) => index === child ? { ...candidate, lastTool: text } : candidate)
357
+ };
358
+ }
359
+ const delegated = toolCategory(event.tool, event.args) === "delegate" && spawningTools.test(event.tool)
360
+ ? [
361
+ ...open.children,
362
+ {
363
+ id: `${open.id}:${open.children.length + 1}`,
364
+ tool: event.tool,
365
+ args: event.args,
366
+ since: at,
367
+ lastTool: undefined,
368
+ ended: false
369
+ }
370
+ ]
371
+ : open.children;
372
+ return {
373
+ ...open,
374
+ activity: { text, since: at, tool: true },
375
+ lastTool: text,
376
+ tools: [...open.tools, text].slice(-expandedTools),
377
+ children: delegated
378
+ };
379
+ });
380
+ }
381
+ case "TasksPlanned":
382
+ return lane === undefined
383
+ ? current
384
+ : updateLane(current, lane.id, (open) => ({
385
+ ...open,
386
+ tasks: event.tasks.map((task, position) => ({
387
+ index: position + 1,
388
+ title: task.title,
389
+ done: task.completed,
390
+ ...(task.satisfies === undefined ? {} : { satisfies: task.satisfies })
391
+ }))
392
+ }));
393
+ case "TaskStarted": {
394
+ if (lane === undefined) {
395
+ return current;
396
+ }
397
+ const satisfies = event.satisfies === undefined ? {} : { satisfies: event.satisfies };
398
+ const listed = (open) => open.tasks.some((task) => task.index === event.index)
399
+ ? open.tasks
400
+ : [
401
+ ...open.tasks,
402
+ { index: event.index, title: event.title, done: false, ...satisfies }
403
+ ].sort((left, right) => left.index - right.index);
404
+ if (event.parallel === true) {
405
+ return updateLane(current, lane.id, (open) => ({
406
+ ...open,
407
+ tasks: listed(open),
408
+ taskCoders: [
409
+ ...open.taskCoders.filter((coder) => coder.index !== event.index),
410
+ {
411
+ index: event.index,
412
+ count: event.count,
413
+ title: event.title,
414
+ executor: event.executor,
415
+ clone: event.clone,
416
+ since: at
417
+ }
418
+ ]
419
+ }));
420
+ }
421
+ return updateLane(current, lane.id, (open) => ({
422
+ ...open,
423
+ task: { index: event.index, count: event.count, title: event.title, ...satisfies },
424
+ tasks: listed(open),
425
+ // Back with the story's coder: no longer beside it.
426
+ taskCoders: open.taskCoders.filter((coder) => coder.index !== event.index)
427
+ }));
428
+ }
429
+ case "TaskCompleted":
281
430
  return lane === undefined
282
431
  ? current
283
432
  : updateLane(current, lane.id, (open) => ({
284
433
  ...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)
434
+ task: open.task?.index === event.index ? undefined : open.task,
435
+ taskCoders: open.taskCoders.filter((coder) => coder.index !== event.index),
436
+ tasks: open.tasks.map((task) => task.index === event.index ? { ...task, done: true } : task)
288
437
  }));
438
+ case "StoryStatusChanged":
439
+ return {
440
+ ...current,
441
+ stories: current.stories.some((story) => story.id === event.id)
442
+ ? current.stories.map((story) => story.id === event.id ? { ...story, status: event.status } : story)
443
+ : [...current.stories, { id: event.id, status: event.status }]
444
+ };
289
445
  case "TokensUsed": {
290
446
  const tokens = event.usage.total;
291
447
  const costUsd = costOf(event.model, event.usage);
@@ -445,30 +601,48 @@ export const initialView = {
445
601
  fullLog: false,
446
602
  tail: false,
447
603
  tailRole: undefined,
448
- tailBack: 0
604
+ tailBack: 0,
605
+ scroll: 0
449
606
  };
450
607
  const runningLanes = (state) => state.lanes.filter((lane) => lane.status === "running");
608
+ /**
609
+ * The lane the detail box is about: the selection, else the lane that moved
610
+ * last, else the first running one — so an unattended screen still shows
611
+ * what is happening (ADR 0033).
612
+ */
613
+ export const selectedLane = (state, view) => {
614
+ const running = runningLanes(state);
615
+ return (running.find((lane) => lane.id === view.selected) ??
616
+ running.find((lane) => lane.id === state.lastChanged) ??
617
+ running[0]);
618
+ };
451
619
  const tailRoles = [undefined, "coder", "reviewer", "judge"];
452
620
  const tailPage = 10;
453
621
  /** Whose transcript the tail shows: the selected story, or the selected executor. */
454
622
  export const tailTargetOf = (view) => view.selected === undefined
455
623
  ? undefined
456
- : view.mode === "lanes"
457
- ? { lane: view.selected }
458
- : { executor: view.selected };
624
+ : view.mode === "executors"
625
+ ? { executor: view.selected }
626
+ : { lane: view.selected };
459
627
  /**
460
628
  * 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.
629
+ * the executors view), enter expands its detail, `t` tails its transcript,
630
+ * `r` cycles the tail's role, escape closes, `e` switches lanes and
631
+ * executors, `b` the boards, `l` the full log, `q` quits. Keys it does not
632
+ * know return the same view.
464
633
  */
465
634
  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);
635
+ const choices = view.mode === "executors" ? state.executors : runningLanes(state).map((lane) => lane.id);
636
+ const current = view.mode === "executors" ? view.selected : selectedLane(state, view)?.id;
637
+ const index = choices.findIndex((id) => id === current);
468
638
  const select = (next) => {
469
- const id = choices[Math.max(0, Math.min(choices.length - 1, next))];
470
- return id === undefined ? view : { ...view, selected: id };
639
+ const row = Math.max(0, Math.min(choices.length - 1, next));
640
+ const id = choices[row];
641
+ return id === undefined
642
+ ? view
643
+ : { ...view, selected: id, scroll: row < view.scroll ? row : view.scroll };
471
644
  };
645
+ const chosen = () => view.mode === "executors" ? view.selected : selectedLane(state, view)?.id;
472
646
  switch (key) {
473
647
  case "q":
474
648
  return "quit";
@@ -479,13 +653,15 @@ export const onTreeKey = (view, key, state) => {
479
653
  case "left":
480
654
  return select(index < 0 ? 0 : index - 1);
481
655
  case "enter":
482
- return view.selected === undefined || view.mode !== "lanes"
656
+ return view.mode === "executors" || chosen() === undefined
483
657
  ? view
484
658
  : { ...view, expanded: !view.expanded };
485
- case "t":
486
- return view.selected === undefined
659
+ case "t": {
660
+ const id = chosen();
661
+ return id === undefined
487
662
  ? view
488
- : { ...view, tail: !view.tail, expanded: false, tailBack: 0 };
663
+ : { ...view, selected: id, tail: !view.tail, expanded: false, tailBack: 0 };
664
+ }
489
665
  case "pageup":
490
666
  return view.tail ? { ...view, tailBack: view.tailBack + tailPage } : view;
491
667
  case "pagedown":
@@ -502,9 +678,17 @@ export const onTreeKey = (view, key, state) => {
502
678
  case "e":
503
679
  return {
504
680
  ...view,
505
- mode: view.mode === "lanes" ? "executors" : "lanes",
681
+ mode: view.mode === "executors" ? "lanes" : "executors",
506
682
  selected: undefined,
507
683
  expanded: false,
684
+ tail: false,
685
+ scroll: 0
686
+ };
687
+ case "b":
688
+ return {
689
+ ...view,
690
+ mode: view.mode === "boards" ? "lanes" : "boards",
691
+ expanded: false,
508
692
  tail: false
509
693
  };
510
694
  case "l":
@@ -514,7 +698,8 @@ export const onTreeKey = (view, key, state) => {
514
698
  }
515
699
  };
516
700
  const railWidth = 26;
517
- const maxLaneBoxes = 3;
701
+ /** The agent list never shrinks below this many rows for the log's sake. */
702
+ const minListRows = 3;
518
703
  const counter = (label, value) => [
519
704
  span(`${label.padEnd(15)}${String(value).padStart(5)}`)
520
705
  ];
@@ -530,26 +715,8 @@ const railOf = (state) => box(railWidth, "judge", [
530
715
  ],
531
716
  [],
532
717
  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)
718
+ counter("verdicts", state.judge.verdicts),
719
+ ...(state.judge.borrowed === 0 ? [] : [counter("borrowed", state.judge.borrowed)])
553
720
  ]);
554
721
  /** Work under way as a card names it: the command itself, the call, the wait. */
555
722
  export const workText = (work) => {
@@ -678,13 +845,244 @@ const columnsOf = (items, width, draw) => {
678
845
  const each = Math.floor((width - 2 * (items.length - 1)) / items.length);
679
846
  return beside(items.map((item) => draw(item, each)), items.map(() => each));
680
847
  };
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)"}`);
848
+ /** `◐`/`⏸`: the lane's status at a glance, as `statusLine` grades it. */
849
+ const statusMark = (lane, now, idleAfterMs) => {
850
+ const line = statusLine(lane, now, idleAfterMs);
851
+ const text = line[0]?.text ?? "";
852
+ return span(text.startsWith("◐") ? "◐" : "⏸", line[0]?.style);
853
+ };
854
+ /** `3/5 wire session cookie`, or `2/5 tasks` between tasks, or nothing before the plan. */
855
+ const taskText = (lane) => lane.task !== undefined
856
+ ? `${lane.task.index}/${lane.task.count} ${lane.task.title}`
857
+ : lane.tasks.length > 0
858
+ ? `${lane.tasks.filter((task) => task.done).length}/${lane.tasks.length} tasks`
859
+ : "";
860
+ /** `head` then `tail` in `width`: a long head is cut, the tail (a timer, a count) never is. */
861
+ const headThenTail = (head, tail, width) => {
862
+ const room = width - [...tail.text].length;
863
+ return lengthOf(head) <= room ? [...head, tail] : [...fit(head, Math.max(1, room)), tail];
864
+ };
865
+ /** The one line a running lane is in the agent list (ADR 0033). */
866
+ const laneLine = (lane, width, now, selected, idleAfterMs) => {
867
+ const task = taskText(lane);
868
+ const stage = lane.stages.at(-1) ?? "starting";
869
+ const head = [
870
+ span(selected ? "▸ " : " "),
871
+ statusMark(lane, now, idleAfterMs),
872
+ span(" "),
873
+ span(lane.id, "bold"),
874
+ span(` ${cloneName(lane.executor ?? "(leasing)", lane.clone)}`),
875
+ ...(task.length === 0 ? [] : [span(" · "), span(task)]),
876
+ // A task is a stage of its own name: say it once.
877
+ ...(stage === lane.task?.title ? [] : [span(" · "), span(stage, "dim")]),
878
+ ...(lane.pause === undefined
879
+ ? []
880
+ : [span(" · "), span(`⏸ ${lane.pause.label} ${elapsed(lane.pause.since, now)}`, "judge")])
881
+ ];
882
+ return headThenTail(head, span(` · ${elapsed(lane.startedAt, now)} · ${formatCount(lane.tokens)} tok`), width);
883
+ };
884
+ /** A sub-agent the lane's harness spawned, indented under it. */
885
+ /** A task running beside the lane's coder, indented under it (ADR 0034). */
886
+ const taskCoderLine = (coder, width, now) => headThenTail([
887
+ span(" └ "),
888
+ span("task coder ", "dim"),
889
+ span(`${cloneName(coder.executor ?? "(leasing)", coder.clone)} · `),
890
+ span(`${coder.index}/${coder.count} ${coder.title}`)
891
+ ], span(` · ${elapsed(coder.since, now)}`, "dim"), width);
892
+ const childLine = (child, width, now) => headThenTail([
893
+ span(" └ "),
894
+ span("sub-agent ", "dim"),
895
+ span(`${child.tool} ${child.args}`),
896
+ span(` · ${child.lastTool ?? "starting"}`, "dim")
897
+ ], span(` · ${elapsed(child.since, now)}`, "dim"), width);
898
+ /** The list's rows: a running lane, then each sub-agent still running under it. */
899
+ const listRowsOf = (state) => runningLanes(state).reduce((total, lane) => total + 1 + lane.taskCoders.length + lane.children.filter((child) => !child.ended).length, 0);
900
+ /**
901
+ * Every running agent, one row each, scrolled so the selected lane is in
902
+ * view and cut to `rows` with a count of what is out of view (ADR 0033).
903
+ */
904
+ const agentRows = (state, width, view, selected, rows) => {
905
+ const all = runningLanes(state).flatMap((lane) => [
906
+ {
907
+ lane: lane.id,
908
+ line: laneLine(lane, width, state.now, lane.id === selected?.id, state.idleAfterMs)
909
+ },
910
+ ...lane.taskCoders.map((coder) => ({
911
+ lane: lane.id,
912
+ line: taskCoderLine(coder, width, state.now)
913
+ })),
914
+ ...lane.children
915
+ .filter((child) => !child.ended)
916
+ .map((child) => ({ lane: lane.id, line: childLine(child, width, state.now) }))
917
+ ]);
918
+ if (rows === undefined || all.length <= rows) {
919
+ return all.map((row) => row.line);
920
+ }
921
+ const wanted = all.findIndex((row) => row.lane === selected?.id);
922
+ // The "… n above/more" notes take rows of the budget too.
923
+ const window = (room) => {
924
+ const start = Math.max(0, Math.min(view.scroll, all.length - room));
925
+ return wanted < 0
926
+ ? start
927
+ : wanted < start
928
+ ? wanted
929
+ : wanted >= start + room
930
+ ? wanted - room + 1
931
+ : start;
932
+ };
933
+ let room = Math.max(1, rows - 1);
934
+ let first = window(room);
935
+ if (first > 0 && first + room < all.length) {
936
+ room = Math.max(1, rows - 2);
937
+ first = window(room);
938
+ }
939
+ const shown = all.slice(first, first + room);
940
+ const above = first;
941
+ const below = all.length - first - shown.length;
942
+ return [
943
+ ...(above > 0 ? [[span(`… ${above} above`, "dim")]] : []),
944
+ ...shown.map((row) => row.line),
945
+ ...(below > 0 ? [[span(`… ${below} more`, "dim")]] : [])
946
+ ];
947
+ };
948
+ const tick = (task, running) => task.done ? "[x]" : running ? "[▶]" : "[ ]";
949
+ /**
950
+ * The selected lane in full: its task checklist with the criteria each task
951
+ * satisfies, its stage, what it is doing, its tools, a harness pause, and
952
+ * its figures (ADR 0033). Expanded, every stage and tool it ran.
953
+ */
954
+ const detailBox = (lane, width, now, idleAfterMs, expanded) => {
955
+ const inner = width - 4;
956
+ const checklist = lane.tasks.length === 0
957
+ ? [[span("no task plan yet", "dim")]]
958
+ : lane.tasks.map((task) => {
959
+ const beside = lane.taskCoders.find((coder) => coder.index === task.index);
960
+ const running = lane.task?.index === task.index || beside !== undefined;
961
+ const right = task.satisfies === undefined ? "" : `satisfies ${task.satisfies.join(",")}`;
962
+ const left = [
963
+ span(`${tick(task, running)} ${task.index} ${task.title}`, running ? "bold" : undefined),
964
+ ...(beside === undefined
965
+ ? []
966
+ : [span(` ← ${cloneName(beside.executor ?? "(leasing)", beside.clone)}`, "dim")])
967
+ ];
968
+ return right.length === 0
969
+ ? left
970
+ : [...fit(left, Math.max(1, inner - [...right].length - 1)), span(` ${right}`, "dim")];
971
+ });
972
+ const stages = expanded
973
+ ? lane.stages.map((stage) => [span(stage)])
974
+ : [[span("stage "), span(lane.stages.at(-1) ?? "starting")]];
975
+ const tools = expanded
976
+ ? [
977
+ [span(`tools (last ${lane.tools.length})`, "dim")],
978
+ ...lane.tools.map((tool) => [span(` ${tool}`, "dim")])
979
+ ]
980
+ : lane.tools.length === 0
981
+ ? []
982
+ : [[span("tools "), span(lane.tools.slice(-3).join(" · "), "dim")]];
983
+ const title = `${lane.id} · ${cloneName(lane.executor ?? "(leasing)", lane.clone)}${lane.task === undefined ? "" : ` · task ${lane.task.index}/${lane.task.count}`}`;
984
+ return box(width, "running", [
985
+ ...checklist,
986
+ ...stages,
987
+ doingLine(lane, now),
988
+ ...(lane.pause === undefined
989
+ ? []
990
+ : [[span(`⏸ ${lane.pause.label} ${elapsed(lane.pause.since, now)}`, "judge")]]),
991
+ ...tools,
992
+ [
993
+ span(`${elapsed(lane.startedAt, now)} · ${formatCount(lane.tokens)} tok${lane.costUsd > 0 ? ` · ${dollars(lane.costUsd)}` : ""}`)
994
+ ],
995
+ ...(lane.turns.length === 0 && lane.gatesMs === 0
996
+ ? []
997
+ : [
998
+ [
999
+ span(`turns ${lane.turns.length} · avg ${elapsedMs(lane.turns.length === 0
1000
+ ? 0
1001
+ : lane.turns.reduce((total, turn) => total + turn, 0) / lane.turns.length)} · gates ${elapsedMs(lane.gatesMs)}`)
1002
+ ]
1003
+ ]),
1004
+ statusLine(lane, now, idleAfterMs)
1005
+ ], title);
1006
+ };
1007
+ const bar = (done, total, width = 5) => {
1008
+ const filled = total === 0 ? 0 : Math.round((done / total) * width);
1009
+ return "█".repeat(filled) + "░".repeat(width - filled);
1010
+ };
1011
+ const boardColumns = [
1012
+ ["planned", "planned"],
1013
+ ["active", "running"],
1014
+ ["waiting", "waiting"],
1015
+ ["done", "done"],
1016
+ ["failed", "failed"]
1017
+ ];
1018
+ /**
1019
+ * The boards (ADR 0033): the epic's stories by column, with a running lane's
1020
+ * clone and progress on its card, then the selected lane's tasks by column.
1021
+ */
1022
+ const boardsOf = (state, width, selected, now) => {
1023
+ const chips = chipsOf(state);
1024
+ // Five columns need ~16 characters each to read; narrower, two rows of columns.
1025
+ const epic = chunked(boardColumns, width >= 5 * 16 + 8 ? 5 : 3).flatMap((group) => columnsOf(group, width, ([name, status], each) => {
1026
+ const members = chips.filter((chip) => chip.status === status);
1027
+ const cards = members.flatMap((chip) => {
1028
+ const lane = state.lanes.find((candidate) => candidate.id === chip.id);
1029
+ if (lane === undefined || lane.status !== "running") {
1030
+ return [[span(chip.id)]];
1031
+ }
1032
+ const done = lane.tasks.filter((task) => task.done).length;
1033
+ const progress = lane.tasks.length === 0
1034
+ ? ""
1035
+ : ` ${done}/${lane.tasks.length}${each >= 24 ? ` ${bar(done, lane.tasks.length)}` : ""}`;
1036
+ return [
1037
+ [span(chip.id, "bold")],
1038
+ [span(` ${cloneName(lane.executor ?? "(leasing)", lane.clone)}${progress}`, "dim")]
1039
+ ];
1040
+ });
1041
+ return box(each, "running", cards.length === 0 ? [[span("—", "dim")]] : cards, `${name} (${members.length})`);
1042
+ }));
1043
+ if (selected === undefined) {
1044
+ return [[span("epic board", "bold")], ...epic];
1045
+ }
1046
+ const taskColumns = [
1047
+ [
1048
+ "todo",
1049
+ (task) => !task.done &&
1050
+ task.index !== selected.task?.index &&
1051
+ !selected.taskCoders.some((coder) => coder.index === task.index)
1052
+ ],
1053
+ [
1054
+ "doing",
1055
+ (task) => !task.done &&
1056
+ (task.index === selected.task?.index ||
1057
+ selected.taskCoders.some((coder) => coder.index === task.index))
1058
+ ],
1059
+ ["review", () => false],
1060
+ ["done", (task) => task.done]
1061
+ ];
1062
+ const story = chunked(taskColumns, width >= 4 * 16 + 6 ? 4 : 2).flatMap((group) => columnsOf(group, width, ([name, keep], each) => {
1063
+ const members = selected.tasks.filter(keep);
1064
+ return box(each, "running", members.length === 0
1065
+ ? [[span("—", "dim")]]
1066
+ : members.map((task) => [span(`${task.index} ${task.title}`)]), `${name} (${members.length})`);
1067
+ }));
1068
+ return [
1069
+ [span("epic board", "bold")],
1070
+ ...epic,
1071
+ [],
1072
+ [
1073
+ span(`story board · ${selected.id} · ${cloneName(selected.executor ?? "(leasing)", selected.clone)}`, "bold"),
1074
+ span(` · ${elapsed(selected.startedAt, now)}`)
1075
+ ],
1076
+ ...story
1077
+ ];
1078
+ };
1079
+ /** `codex ×2 · claude ×1`: how many leases each executor holds now. */
1080
+ const leaseCounts = (state) => state.executors
1081
+ .map((executor) => [executor, state.leases.filter((lease) => lease.executor === executor).length])
1082
+ .filter(([, count]) => count > 0)
1083
+ .map(([executor, count]) => `${executor} ×${count}`)
1084
+ .join(" · ");
1085
+ const chunked = (items, size) => Array.from({ length: Math.ceil(items.length / size) }, (_, index) => items.slice(index * size, index * size + size));
688
1086
  const barWidth = 10;
689
1087
  const judgmentRows = 3;
690
1088
  const meter = (fraction) => {
@@ -728,8 +1126,8 @@ const tailOf = (view, tail, width) => box(width, "running", tail === undefined |
728
1126
  ]
729
1127
  ]
730
1128
  : 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");
1129
+ const mainOf = (state, width, view, tail, listRows) => {
1130
+ const running = runningLanes(state);
733
1131
  const orchestratorWidth = Math.min(46, width);
734
1132
  const stage = state.stages.at(-1) ?? (running.length > 0 ? "implement stories" : "idle");
735
1133
  // The run's own work under way (its gates, outside any story), innermost.
@@ -750,27 +1148,29 @@ const mainOf = (state, width, view, tail) => {
750
1148
  ]),
751
1149
  [span(`stories ${chipsOf(state).length} elapsed ${elapsed(state.startedAt, state.now)}`)]
752
1150
  ], 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
1151
+ const selected = selectedLane(state, view);
1152
+ const counts = leaseCounts(state);
1153
+ const agents = centre([span(`agents · ${running.length} running${counts.length === 0 ? "" : ` · ${counts}`}`)], width);
1154
+ const body = view.tail && view.selected !== undefined
757
1155
  ? 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
1156
+ : view.mode === "executors"
1157
+ ? state.executors.length === 0
1158
+ ? [centre([span("no executors yet", "dim")], width)]
1159
+ : chunked(state.executors, 4).flatMap((group) => columnsOf(group, width, (executor, each) => executorBox(state, executor, each, executor === view.selected)))
1160
+ : view.mode === "boards"
1161
+ ? boardsOf(state, width, selected, state.now)
1162
+ : running.length === 0 || selected === undefined
765
1163
  ? [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));
1164
+ : [
1165
+ ...agentRows(state, width, view, selected, listRows),
1166
+ ...detailBox(selected, width, state.now, state.idleAfterMs, view.expanded)
1167
+ ];
767
1168
  return [
768
1169
  ...orchestrator.map((line) => centre(line, width)),
769
1170
  centre([span("•", "orchestrator")], width),
770
1171
  ...judgmentOf(state, width),
771
- centre([span(`delegate to roster · ${running.length} running`)], width),
772
- centre([span("▼", "dim")], width),
773
- ...lanes,
1172
+ agents,
1173
+ ...body,
774
1174
  [],
775
1175
  ...wrapChips(chipsOf(state).map(chipOf), width)
776
1176
  ];
@@ -795,7 +1195,7 @@ const timeSplitOf = (time) => {
795
1195
  ];
796
1196
  };
797
1197
  /** A frame with the session log at `logCount` lines, or without it. */
798
- const frameOf = (state, options, logCount) => {
1198
+ const frameOf = (state, options, logCount, listRows) => {
799
1199
  const { width } = options;
800
1200
  const mainWidth = width - railWidth - 2;
801
1201
  const chips = chipsOf(state);
@@ -805,7 +1205,7 @@ const frameOf = (state, options, logCount) => {
805
1205
  centre([span("LLM4TS AGENT TREE", "bold"), span(" · "), span(state.title, "orchestrator")], width),
806
1206
  [span("═".repeat(width), "dim")],
807
1207
  [],
808
- ...beside([railOf(state), mainOf(state, mainWidth, options.view, options.tail)], [railWidth, mainWidth]),
1208
+ ...beside([railOf(state), mainOf(state, mainWidth, options.view, options.tail, listRows)], [railWidth, mainWidth]),
809
1209
  ...(logCount === "none"
810
1210
  ? []
811
1211
  : [[], ...box(width, "log", logOf(state, logCount), "session log")]),
@@ -822,24 +1222,37 @@ const frameOf = (state, options, logCount) => {
822
1222
  return lines.map((line) => paint(fit(line, width), options.colour));
823
1223
  };
824
1224
  /**
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.
1225
+ * The tree at a width and, when `height` is given, no taller: the agent
1226
+ * list gives way first (down to `minListRows`, with a count of what is out
1227
+ * of view), then the session log — fewer lines, down to one, then none —
1228
+ * and only then is the frame cut, keeping its status lines, so a redraw
1229
+ * never scrolls.
828
1230
  */
829
1231
  export const renderTree = (state, options) => {
830
1232
  const wanted = options.view.fullLog ? fullLogLines : logLines;
831
- const full = frameOf(state, options, wanted);
1233
+ const full = frameOf(state, options, wanted, undefined);
832
1234
  const { height } = options;
833
1235
  if (height === undefined || full.length <= height) {
834
1236
  return full;
835
1237
  }
1238
+ const total = listRowsOf(state);
1239
+ const floor = Math.min(total, minListRows);
1240
+ const listRows = options.view.mode === "lanes" && total > floor
1241
+ ? Math.max(floor, total - (full.length - height))
1242
+ : undefined;
1243
+ if (listRows !== undefined) {
1244
+ const shorter = frameOf(state, options, wanted, listRows);
1245
+ if (shorter.length <= height) {
1246
+ return shorter;
1247
+ }
1248
+ }
836
1249
  for (let count = wanted - 1; count >= 1; count -= 1) {
837
- const shorter = frameOf(state, options, count);
1250
+ const shorter = frameOf(state, options, count, listRows);
838
1251
  if (shorter.length <= height) {
839
1252
  return shorter;
840
1253
  }
841
1254
  }
842
- return fitToRows(frameOf(state, options, "none"), height);
1255
+ return fitToRows(frameOf(state, options, "none", listRows), height);
843
1256
  };
844
1257
  /**
845
1258
  * A frame cut to a terminal's height: the top of the tree and its two status