@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/src/AgentTree.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import type { TokenUsage } from "@llm4ts/core/Models"
2
2
  import * as Option from "effect/Option"
3
3
  import * as Schema from "effect/Schema"
4
- import { FlowEvent } from "@llm4ts/flow/FlowEvents"
4
+ import { toolCategory } from "@llm4ts/flow/Activity"
5
+ import { FlowEvent, cloneName } from "@llm4ts/flow/FlowEvents"
5
6
  import type { TraceLine } from "@llm4ts/flow/FlowRecorder"
6
7
  import { estimateCostUsd } from "@llm4ts/flow/PriceList"
7
8
  import { formatCount } from "./Terminal.ts"
@@ -48,6 +49,41 @@ export interface TreeLane {
48
49
  readonly gatesMs: number
49
50
  /** Work it said `Began` and no `Timed` has ended yet, innermost last. */
50
51
  readonly running: ReadonlyArray<TreeWork>
52
+ /** The coder's clone number, when its lease carried one (ADR 0033). */
53
+ readonly clone: number | undefined
54
+ /** The plan's tasks in order, as `TasksPlanned` and the task events reported them. */
55
+ readonly tasks: ReadonlyArray<TreeTask>
56
+ /** The running task: its index from 1 and the plan's count. */
57
+ readonly task:
58
+ | {
59
+ readonly index: number
60
+ readonly count: number
61
+ readonly title: string
62
+ readonly satisfies?: ReadonlyArray<number>
63
+ }
64
+ | undefined
65
+ /** Sub-agents the harness spawned on this lane (a delegate tool call), oldest first. */
66
+ readonly children: ReadonlyArray<TreeChild>
67
+ /** A harness pause under way: `pi retry`, `pi compaction`. */
68
+ readonly pause: { readonly label: string; readonly since: number } | undefined
69
+ }
70
+
71
+ /** One task of a lane's plan, as the checklist shows it. */
72
+ export interface TreeTask {
73
+ readonly index: number
74
+ readonly title: string
75
+ readonly done: boolean
76
+ readonly satisfies?: ReadonlyArray<number>
77
+ }
78
+
79
+ /** A sub-agent a harness spawned inside a lane, observed through its tool calls (ADR 0033). */
80
+ export interface TreeChild {
81
+ readonly id: string
82
+ readonly tool: string
83
+ readonly args: string
84
+ readonly since: number
85
+ readonly lastTool: string | undefined
86
+ readonly ended: boolean
51
87
  }
52
88
 
53
89
  /** Work under way: a model call, a gate or setup command, git, a merge, a wait. */
@@ -87,6 +123,8 @@ export interface TreeLease {
87
123
  readonly executor: string
88
124
  readonly role: string
89
125
  readonly label: string | undefined
126
+ /** Which clone took the slot, when the lease carried one (ADR 0033). */
127
+ readonly clone: number | undefined
90
128
  }
91
129
 
92
130
  /** The judge seat: who sits in it, and how often it was asked. */
@@ -94,6 +132,8 @@ export interface TreeJudge {
94
132
  readonly executor: string | undefined
95
133
  readonly reviews: number
96
134
  readonly verdicts: number
135
+ /** Reviews and verdicts that ran on the story's own executor: not independent (ADR 0033). */
136
+ readonly borrowed: number
97
137
  }
98
138
 
99
139
  /** The latest story verdict (`StoryJudged`). */
@@ -146,6 +186,8 @@ export interface TreeState {
146
186
  readonly running: ReadonlyArray<TreeWork>
147
187
  /** A lane with no event for this long, and no tool running, is marked idle. */
148
188
  readonly idleAfterMs: number
189
+ /** The lane that published last, which the detail box follows by default (ADR 0033). */
190
+ readonly lastChanged: string | undefined
149
191
  }
150
192
 
151
193
  export interface TreeOptions {
@@ -182,7 +224,7 @@ export const emptyTree = (options: TreeOptions = {}): TreeState => ({
182
224
  executors: [],
183
225
  leases: [],
184
226
  exclusions: [],
185
- judge: { executor: undefined, reviews: 0, verdicts: 0 },
227
+ judge: { executor: undefined, reviews: 0, verdicts: 0, borrowed: 0 },
186
228
  verdict: undefined,
187
229
  judgments: [],
188
230
  log: [],
@@ -191,7 +233,8 @@ export const emptyTree = (options: TreeOptions = {}): TreeState => ({
191
233
  ended: undefined,
192
234
  time: { model: 0, tools: 0, gates: 0, merge: 0, wait: 0 },
193
235
  running: [],
194
- idleAfterMs: options.idleAfterMs ?? defaultIdleAfterMs
236
+ idleAfterMs: options.idleAfterMs ?? defaultIdleAfterMs,
237
+ lastChanged: undefined
195
238
  })
196
239
 
197
240
  const costOf = (model: string | undefined, usage: TokenUsage): number =>
@@ -214,6 +257,40 @@ const updateLane = (
214
257
  lanes: state.lanes.map((lane) => (lane.id === id ? update(lane) : lane))
215
258
  })
216
259
 
260
+ /** A wait label that is a harness pausing itself, not the roster or a gate. */
261
+ const isPause = (label: string): boolean => /^pi (retry|compaction)$/u.test(label)
262
+
263
+ /**
264
+ * A delegate tool that starts a sub-agent: Claude's Agent (Task before
265
+ * 2.1.63), Codex's spawn_agent, Gemini's agents. Codex's wait, send_input,
266
+ * resume_agent and close_agent act on one that exists.
267
+ */
268
+ const spawningTools =
269
+ /^(agent|task|spawn_agent|codebase_investigator|generalist|cli_help|browser_agent)$/iu
270
+
271
+ /**
272
+ * A delegate call whose end ends the sub-agent: a synchronous one (Agent,
273
+ * a Gemini agent) or Codex's wait and close; spawn_agent returns at once
274
+ * with the sub-agent still running (ADR 0033).
275
+ */
276
+ const endsChild = (tool: string): boolean => !/^(spawn_agent|send_input|resume_agent)$/iu.test(tool)
277
+
278
+ const newestOpenChild = (children: ReadonlyArray<TreeChild>): number => {
279
+ for (let index = children.length - 1; index >= 0; index -= 1) {
280
+ if (children[index]?.ended === false) {
281
+ return index
282
+ }
283
+ }
284
+ return -1
285
+ }
286
+
287
+ const endNewestChild = (children: ReadonlyArray<TreeChild>): ReadonlyArray<TreeChild> => {
288
+ const index = newestOpenChild(children)
289
+ return index < 0
290
+ ? children
291
+ : children.map((child, position) => (position === index ? { ...child, ended: true } : child))
292
+ }
293
+
217
294
  const withoutLast = <A>(items: ReadonlyArray<A>, item: A): ReadonlyArray<A> => {
218
295
  const index = items.lastIndexOf(item)
219
296
  return index < 0 ? items : [...items.slice(0, index), ...items.slice(index + 1)]
@@ -240,35 +317,45 @@ const withExecutor = (state: TreeState, executor: string): TreeState =>
240
317
  const rosterLog = (state: TreeState, at: number, what: string): TreeState =>
241
318
  logged(state, { at, source: "roster", who: "roster", what })
242
319
 
243
- const reduceLease = (state: TreeState, at: number, lease: TreeLease): TreeState => {
320
+ const reduceLease = (
321
+ state: TreeState,
322
+ at: number,
323
+ lease: TreeLease,
324
+ borrowed: boolean
325
+ ): TreeState => {
244
326
  const held = { ...withExecutor(state, lease.executor), leases: [...state.leases, lease] }
327
+ const who = cloneName(lease.executor, lease.clone)
328
+ const forLabel = lease.label === undefined ? "" : ` · ${lease.label}`
245
329
  switch (lease.role) {
246
330
  case "coder": {
247
331
  const lane = held.lanes.find((candidate) => candidate.id === lease.label)
248
332
  const placed =
249
333
  lane === undefined
250
334
  ? held
251
- : updateLane(held, lane.id, (open) => ({ ...open, executor: lease.executor }))
252
- return rosterLog(
253
- placed,
254
- at,
255
- `${lease.executor} → coder${lease.label === undefined ? "" : ` · ${lease.label}`}`
256
- )
335
+ : updateLane(held, lane.id, (open) => ({
336
+ ...open,
337
+ executor: lease.executor,
338
+ clone: lease.clone
339
+ }))
340
+ return rosterLog(placed, at, `${who} → coder${forLabel}`)
257
341
  }
258
342
  case "reviewer":
259
- return {
343
+ case "judge": {
344
+ const seat = {
260
345
  ...held,
261
346
  judge: {
262
347
  ...held.judge,
263
- executor: held.judge.executor ?? lease.executor,
264
- reviews: held.judge.reviews + 1
348
+ executor:
349
+ lease.role === "judge" ? lease.executor : (held.judge.executor ?? lease.executor),
350
+ reviews: held.judge.reviews + (lease.role === "reviewer" ? 1 : 0),
351
+ verdicts: held.judge.verdicts + (lease.role === "judge" ? 1 : 0),
352
+ borrowed: held.judge.borrowed + (borrowed ? 1 : 0)
265
353
  }
266
354
  }
267
- case "judge":
268
- return {
269
- ...held,
270
- judge: { ...held.judge, executor: lease.executor, verdicts: held.judge.verdicts + 1 }
271
- }
355
+ return borrowed
356
+ ? rosterLog(seat, at, `${lease.role} borrowed from own executor ${who}${forLabel}`)
357
+ : seat
358
+ }
272
359
  default:
273
360
  return held
274
361
  }
@@ -292,11 +379,14 @@ const reduceEvent = (state: TreeState, at: number, event: FlowEvent): TreeState
292
379
  const current =
293
380
  lane === undefined
294
381
  ? named
295
- : updateLane(named, lane.id, (open) => ({
296
- ...open,
297
- lastEventAt: at,
298
- ...(executor !== undefined && open.executor !== executor ? { executor } : {})
299
- }))
382
+ : {
383
+ ...updateLane(named, lane.id, (open) => ({
384
+ ...open,
385
+ lastEventAt: at,
386
+ ...(executor !== undefined && open.executor !== executor ? { executor } : {})
387
+ })),
388
+ lastChanged: lane.id
389
+ }
300
390
  switch (event._tag) {
301
391
  case "StageStarted": {
302
392
  const story = storyStage.exec(event.stage)?.[1]
@@ -315,11 +405,17 @@ const reduceEvent = (state: TreeState, at: number, event: FlowEvent): TreeState
315
405
  activity: undefined,
316
406
  turns: [],
317
407
  gatesMs: 0,
318
- running: []
408
+ running: [],
409
+ clone: undefined,
410
+ tasks: [],
411
+ task: undefined,
412
+ children: [],
413
+ pause: undefined
319
414
  }
320
415
  return {
321
416
  ...current,
322
- lanes: [...current.lanes.filter((open) => open.id !== story), fresh]
417
+ lanes: [...current.lanes.filter((open) => open.id !== story), fresh],
418
+ lastChanged: story
323
419
  }
324
420
  }
325
421
  if (lane !== undefined) {
@@ -402,7 +498,15 @@ const reduceEvent = (state: TreeState, at: number, event: FlowEvent): TreeState
402
498
  : event.kind === "model" && event.label === "coder"
403
499
  ? { activity: undefined, turns: [...open.turns, event.ms] }
404
500
  : {}),
405
- gatesMs: open.gatesMs + (event.kind === "gate" ? event.ms : 0)
501
+ gatesMs: open.gatesMs + (event.kind === "gate" ? event.ms : 0),
502
+ // The delegate call ended: its sub-agent is done — unless it was
503
+ // only spawned, and runs on until a wait or close.
504
+ ...(event.kind === "tool" && event.category === "delegate" && endsChild(event.label)
505
+ ? { children: endNewestChild(open.children) }
506
+ : {}),
507
+ ...(event.kind === "wait" && open.pause?.label === event.label
508
+ ? { pause: undefined }
509
+ : {})
406
510
  }))
407
511
  }
408
512
  case "Began": {
@@ -411,7 +515,14 @@ const reduceEvent = (state: TreeState, at: number, event: FlowEvent): TreeState
411
515
  ? event.lane === undefined
412
516
  ? { ...current, running: [...current.running, work] }
413
517
  : current
414
- : updateLane(current, lane.id, (open) => ({ ...open, running: [...open.running, work] }))
518
+ : updateLane(current, lane.id, (open) => ({
519
+ ...open,
520
+ running: [...open.running, work],
521
+ // A harness pause (`pi retry`, `pi compaction`) is the lane's state while it lasts.
522
+ ...(event.kind === "wait" && isPause(event.label)
523
+ ? { pause: { label: event.label, since: at } }
524
+ : {})
525
+ }))
415
526
  }
416
527
  case "StoryJudged": {
417
528
  const verdict: TreeVerdict = {
@@ -440,11 +551,12 @@ const reduceEvent = (state: TreeState, at: number, event: FlowEvent): TreeState
440
551
  ]
441
552
  }
442
553
  case "ExecutorLeased":
443
- return reduceLease(current, at, {
444
- executor: event.executor,
445
- role: event.role,
446
- label: event.label
447
- })
554
+ return reduceLease(
555
+ current,
556
+ at,
557
+ { executor: event.executor, role: event.role, label: event.label, clone: event.clone },
558
+ event.borrowed === true
559
+ )
448
560
  case "ExecutorReleased": {
449
561
  const index = current.leases.findIndex(
450
562
  (lease) =>
@@ -486,15 +598,93 @@ const reduceEvent = (state: TreeState, at: number, event: FlowEvent): TreeState
486
598
  at,
487
599
  `${event.label ?? "the run"}: ${event.role} leaves ${event.from} · ${event.reason}`
488
600
  )
489
- case "ToolUse":
601
+ case "ToolUse": {
602
+ if (lane === undefined) {
603
+ return current
604
+ }
605
+ const text = `${event.tool} ${event.args}`
606
+ return updateLane(current, lane.id, (open) => {
607
+ // A call inside a sub-agent lands on the newest open child; without
608
+ // one (an older harness, a lost parent) it is the lane's as before.
609
+ const child = event.parent === undefined ? -1 : newestOpenChild(open.children)
610
+ if (child >= 0) {
611
+ return {
612
+ ...open,
613
+ children: open.children.map((candidate, index) =>
614
+ index === child ? { ...candidate, lastTool: text } : candidate
615
+ )
616
+ }
617
+ }
618
+ const delegated =
619
+ toolCategory(event.tool, event.args) === "delegate" && spawningTools.test(event.tool)
620
+ ? [
621
+ ...open.children,
622
+ {
623
+ id: `${open.id}:${open.children.length + 1}`,
624
+ tool: event.tool,
625
+ args: event.args,
626
+ since: at,
627
+ lastTool: undefined,
628
+ ended: false
629
+ }
630
+ ]
631
+ : open.children
632
+ return {
633
+ ...open,
634
+ activity: { text, since: at, tool: true },
635
+ lastTool: text,
636
+ tools: [...open.tools, text].slice(-expandedTools),
637
+ children: delegated
638
+ }
639
+ })
640
+ }
641
+ case "TasksPlanned":
490
642
  return lane === undefined
491
643
  ? current
492
644
  : updateLane(current, lane.id, (open) => ({
493
645
  ...open,
494
- activity: { text: `${event.tool} ${event.args}`, since: at, tool: true },
495
- lastTool: `${event.tool} ${event.args}`,
496
- tools: [...open.tools, `${event.tool} ${event.args}`].slice(-expandedTools)
646
+ tasks: event.tasks.map((task, position) => ({
647
+ index: position + 1,
648
+ title: task.title,
649
+ done: task.completed,
650
+ ...(task.satisfies === undefined ? {} : { satisfies: task.satisfies })
651
+ }))
652
+ }))
653
+ case "TaskStarted": {
654
+ if (lane === undefined) {
655
+ return current
656
+ }
657
+ const satisfies = event.satisfies === undefined ? {} : { satisfies: event.satisfies }
658
+ return updateLane(current, lane.id, (open) => ({
659
+ ...open,
660
+ task: { index: event.index, count: event.count, title: event.title, ...satisfies },
661
+ tasks: open.tasks.some((task) => task.index === event.index)
662
+ ? open.tasks
663
+ : [
664
+ ...open.tasks,
665
+ { index: event.index, title: event.title, done: false, ...satisfies }
666
+ ].sort((left, right) => left.index - right.index)
667
+ }))
668
+ }
669
+ case "TaskCompleted":
670
+ return lane === undefined
671
+ ? current
672
+ : updateLane(current, lane.id, (open) => ({
673
+ ...open,
674
+ task: undefined,
675
+ tasks: open.tasks.map((task) =>
676
+ task.index === event.index ? { ...task, done: true } : task
677
+ )
497
678
  }))
679
+ case "StoryStatusChanged":
680
+ return {
681
+ ...current,
682
+ stories: current.stories.some((story) => story.id === event.id)
683
+ ? current.stories.map((story) =>
684
+ story.id === event.id ? { ...story, status: event.status } : story
685
+ )
686
+ : [...current.stories, { id: event.id, status: event.status }]
687
+ }
498
688
  case "TokensUsed": {
499
689
  const tokens = event.usage.total
500
690
  const costUsd = costOf(event.model, event.usage)
@@ -698,12 +888,12 @@ const paint = (line: Line, colour: boolean): string =>
698
888
  )
699
889
  .join("")
700
890
 
701
- export type TreeMode = "lanes" | "executors"
891
+ export type TreeMode = "lanes" | "executors" | "boards"
702
892
 
703
893
  /** What the reader chose to look at; the run's state is separate. */
704
894
  export interface TreeView {
705
895
  readonly mode: TreeMode
706
- /** The selected lane's story id, or in the executors view the executor. */
896
+ /** The selected lane's story id, or in the executors view the executor; unset follows the lane that moved last. */
707
897
  readonly selected: string | undefined
708
898
  readonly expanded: boolean
709
899
  readonly fullLog: boolean
@@ -713,6 +903,8 @@ export interface TreeView {
713
903
  readonly tailRole: string | undefined
714
904
  /** How many lines the tail is scrolled back from its end (page up/down). */
715
905
  readonly tailBack: number
906
+ /** The first row of the agent list shown, when the list is taller than its room. */
907
+ readonly scroll: number
716
908
  }
717
909
 
718
910
  export const initialView: TreeView = {
@@ -722,12 +914,27 @@ export const initialView: TreeView = {
722
914
  fullLog: false,
723
915
  tail: false,
724
916
  tailRole: undefined,
725
- tailBack: 0
917
+ tailBack: 0,
918
+ scroll: 0
726
919
  }
727
920
 
728
921
  const runningLanes = (state: TreeState): ReadonlyArray<TreeLane> =>
729
922
  state.lanes.filter((lane) => lane.status === "running")
730
923
 
924
+ /**
925
+ * The lane the detail box is about: the selection, else the lane that moved
926
+ * last, else the first running one — so an unattended screen still shows
927
+ * what is happening (ADR 0033).
928
+ */
929
+ export const selectedLane = (state: TreeState, view: TreeView): TreeLane | undefined => {
930
+ const running = runningLanes(state)
931
+ return (
932
+ running.find((lane) => lane.id === view.selected) ??
933
+ running.find((lane) => lane.id === state.lastChanged) ??
934
+ running[0]
935
+ )
936
+ }
937
+
731
938
  const tailRoles: ReadonlyArray<string | undefined> = [undefined, "coder", "reviewer", "judge"]
732
939
  const tailPage = 10
733
940
 
@@ -737,24 +944,31 @@ export const tailTargetOf = (
737
944
  ): { readonly lane: string } | { readonly executor: string } | undefined =>
738
945
  view.selected === undefined
739
946
  ? undefined
740
- : view.mode === "lanes"
741
- ? { lane: view.selected }
742
- : { executor: view.selected }
947
+ : view.mode === "executors"
948
+ ? { executor: view.selected }
949
+ : { lane: view.selected }
743
950
 
744
951
  /**
745
952
  * The view after a key: arrows or 1–9 select a running lane (an executor in
746
- * the executors view), enter expands it, `t` tails its transcript, `r`
747
- * cycles the tail's role, escape closes, `e` switches lanes and executors,
748
- * `l` the full log, `q` quits. Keys it does not know return the same view.
953
+ * the executors view), enter expands its detail, `t` tails its transcript,
954
+ * `r` cycles the tail's role, escape closes, `e` switches lanes and
955
+ * executors, `b` the boards, `l` the full log, `q` quits. Keys it does not
956
+ * know return the same view.
749
957
  */
750
958
  export const onTreeKey = (view: TreeView, key: string, state: TreeState): TreeView | "quit" => {
751
959
  const choices =
752
- view.mode === "lanes" ? runningLanes(state).map((lane) => lane.id) : state.executors
753
- const index = choices.findIndex((id) => id === view.selected)
960
+ view.mode === "executors" ? state.executors : runningLanes(state).map((lane) => lane.id)
961
+ const current = view.mode === "executors" ? view.selected : selectedLane(state, view)?.id
962
+ const index = choices.findIndex((id) => id === current)
754
963
  const select = (next: number): TreeView => {
755
- const id = choices[Math.max(0, Math.min(choices.length - 1, next))]
756
- return id === undefined ? view : { ...view, selected: id }
964
+ const row = Math.max(0, Math.min(choices.length - 1, next))
965
+ const id = choices[row]
966
+ return id === undefined
967
+ ? view
968
+ : { ...view, selected: id, scroll: row < view.scroll ? row : view.scroll }
757
969
  }
970
+ const chosen = (): string | undefined =>
971
+ view.mode === "executors" ? view.selected : selectedLane(state, view)?.id
758
972
  switch (key) {
759
973
  case "q":
760
974
  return "quit"
@@ -765,13 +979,15 @@ export const onTreeKey = (view: TreeView, key: string, state: TreeState): TreeVi
765
979
  case "left":
766
980
  return select(index < 0 ? 0 : index - 1)
767
981
  case "enter":
768
- return view.selected === undefined || view.mode !== "lanes"
982
+ return view.mode === "executors" || chosen() === undefined
769
983
  ? view
770
984
  : { ...view, expanded: !view.expanded }
771
- case "t":
772
- return view.selected === undefined
985
+ case "t": {
986
+ const id = chosen()
987
+ return id === undefined
773
988
  ? view
774
- : { ...view, tail: !view.tail, expanded: false, tailBack: 0 }
989
+ : { ...view, selected: id, tail: !view.tail, expanded: false, tailBack: 0 }
990
+ }
775
991
  case "pageup":
776
992
  return view.tail ? { ...view, tailBack: view.tailBack + tailPage } : view
777
993
  case "pagedown":
@@ -788,9 +1004,17 @@ export const onTreeKey = (view: TreeView, key: string, state: TreeState): TreeVi
788
1004
  case "e":
789
1005
  return {
790
1006
  ...view,
791
- mode: view.mode === "lanes" ? "executors" : "lanes",
1007
+ mode: view.mode === "executors" ? "lanes" : "executors",
792
1008
  selected: undefined,
793
1009
  expanded: false,
1010
+ tail: false,
1011
+ scroll: 0
1012
+ }
1013
+ case "b":
1014
+ return {
1015
+ ...view,
1016
+ mode: view.mode === "boards" ? "lanes" : "boards",
1017
+ expanded: false,
794
1018
  tail: false
795
1019
  }
796
1020
  case "l":
@@ -811,7 +1035,8 @@ export interface TreeRenderOptions {
811
1035
  }
812
1036
 
813
1037
  const railWidth = 26
814
- const maxLaneBoxes = 3
1038
+ /** The agent list never shrinks below this many rows for the log's sake. */
1039
+ const minListRows = 3
815
1040
 
816
1041
  const counter = (label: string, value: number): Line => [
817
1042
  span(`${label.padEnd(15)}${String(value).padStart(5)}`)
@@ -833,40 +1058,8 @@ const railOf = (state: TreeState): Array<Line> =>
833
1058
  ],
834
1059
  [],
835
1060
  counter("reviews", state.judge.reviews),
836
- counter("verdicts", state.judge.verdicts)
837
- ])
838
-
839
- const laneBox = (
840
- lane: TreeLane,
841
- width: number,
842
- now: number | undefined,
843
- selected: boolean,
844
- idleAfterMs: number
845
- ): Array<Line> =>
846
- box(width, "running", [
847
- [span(selected ? `▸ ${lane.id}` : lane.id, "bold")],
848
- [span(lane.executor ?? "(leasing)")],
849
- [span(lane.stages.at(-1) ?? "starting")],
850
- doingLine(lane, now),
851
- [
852
- span(
853
- `${elapsed(lane.startedAt, now)} · ${formatCount(lane.tokens)} tok${lane.costUsd > 0 ? ` · ${dollars(lane.costUsd)}` : ""}`
854
- )
855
- ],
856
- ...(lane.turns.length === 0 && lane.gatesMs === 0
857
- ? []
858
- : [
859
- [
860
- span(
861
- `turns ${lane.turns.length} · avg ${elapsedMs(
862
- lane.turns.length === 0
863
- ? 0
864
- : lane.turns.reduce((total, turn) => total + turn, 0) / lane.turns.length
865
- )} · gates ${elapsedMs(lane.gatesMs)}`
866
- )
867
- ]
868
- ]),
869
- statusLine(lane, now, idleAfterMs)
1061
+ counter("verdicts", state.judge.verdicts),
1062
+ ...(state.judge.borrowed === 0 ? [] : [counter("borrowed", state.judge.borrowed)])
870
1063
  ])
871
1064
 
872
1065
  /** Work under way as a card names it: the command itself, the call, the wait. */
@@ -1025,18 +1218,310 @@ const columnsOf = <A>(
1025
1218
  )
1026
1219
  }
1027
1220
 
1028
- const expandedLane = (lane: TreeLane, width: number, now: number | undefined): Array<Line> =>
1029
- box(
1221
+ /** `◐`/`⏸`: the lane's status at a glance, as `statusLine` grades it. */
1222
+ const statusMark = (lane: TreeLane, now: number | undefined, idleAfterMs: number): Span => {
1223
+ const line = statusLine(lane, now, idleAfterMs)
1224
+ const text = line[0]?.text ?? ""
1225
+ return span(text.startsWith("◐") ? "◐" : "⏸", line[0]?.style)
1226
+ }
1227
+
1228
+ /** `3/5 wire session cookie`, or `2/5 tasks` between tasks, or nothing before the plan. */
1229
+ const taskText = (lane: TreeLane): string =>
1230
+ lane.task !== undefined
1231
+ ? `${lane.task.index}/${lane.task.count} ${lane.task.title}`
1232
+ : lane.tasks.length > 0
1233
+ ? `${lane.tasks.filter((task) => task.done).length}/${lane.tasks.length} tasks`
1234
+ : ""
1235
+
1236
+ /** `head` then `tail` in `width`: a long head is cut, the tail (a timer, a count) never is. */
1237
+ const headThenTail = (head: Line, tail: Span, width: number): Line => {
1238
+ const room = width - [...tail.text].length
1239
+ return lengthOf(head) <= room ? [...head, tail] : [...fit(head, Math.max(1, room)), tail]
1240
+ }
1241
+
1242
+ /** The one line a running lane is in the agent list (ADR 0033). */
1243
+ const laneLine = (
1244
+ lane: TreeLane,
1245
+ width: number,
1246
+ now: number | undefined,
1247
+ selected: boolean,
1248
+ idleAfterMs: number
1249
+ ): Line => {
1250
+ const task = taskText(lane)
1251
+ const stage = lane.stages.at(-1) ?? "starting"
1252
+ const head: Line = [
1253
+ span(selected ? "▸ " : " "),
1254
+ statusMark(lane, now, idleAfterMs),
1255
+ span(" "),
1256
+ span(lane.id, "bold"),
1257
+ span(` ${cloneName(lane.executor ?? "(leasing)", lane.clone)}`),
1258
+ ...(task.length === 0 ? [] : [span(" · "), span(task)]),
1259
+ // A task is a stage of its own name: say it once.
1260
+ ...(stage === lane.task?.title ? [] : [span(" · "), span(stage, "dim")]),
1261
+ ...(lane.pause === undefined
1262
+ ? []
1263
+ : [span(" · "), span(`⏸ ${lane.pause.label} ${elapsed(lane.pause.since, now)}`, "judge")])
1264
+ ]
1265
+ return headThenTail(
1266
+ head,
1267
+ span(` · ${elapsed(lane.startedAt, now)} · ${formatCount(lane.tokens)} tok`),
1268
+ width
1269
+ )
1270
+ }
1271
+
1272
+ /** A sub-agent the lane's harness spawned, indented under it. */
1273
+ const childLine = (child: TreeChild, width: number, now: number | undefined): Line =>
1274
+ headThenTail(
1275
+ [
1276
+ span(" └ "),
1277
+ span("sub-agent ", "dim"),
1278
+ span(`${child.tool} ${child.args}`),
1279
+ span(` · ${child.lastTool ?? "starting"}`, "dim")
1280
+ ],
1281
+ span(` · ${elapsed(child.since, now)}`, "dim"),
1282
+ width
1283
+ )
1284
+
1285
+ /** The list's rows: a running lane, then each sub-agent still running under it. */
1286
+ const listRowsOf = (state: TreeState): number =>
1287
+ runningLanes(state).reduce(
1288
+ (total, lane) => total + 1 + lane.children.filter((child) => !child.ended).length,
1289
+ 0
1290
+ )
1291
+
1292
+ /**
1293
+ * Every running agent, one row each, scrolled so the selected lane is in
1294
+ * view and cut to `rows` with a count of what is out of view (ADR 0033).
1295
+ */
1296
+ const agentRows = (
1297
+ state: TreeState,
1298
+ width: number,
1299
+ view: TreeView,
1300
+ selected: TreeLane | undefined,
1301
+ rows: number | undefined
1302
+ ): Array<Line> => {
1303
+ const all: Array<{ readonly lane: string; readonly line: Line }> = runningLanes(state).flatMap(
1304
+ (lane) => [
1305
+ {
1306
+ lane: lane.id,
1307
+ line: laneLine(lane, width, state.now, lane.id === selected?.id, state.idleAfterMs)
1308
+ },
1309
+ ...lane.children
1310
+ .filter((child) => !child.ended)
1311
+ .map((child) => ({ lane: lane.id, line: childLine(child, width, state.now) }))
1312
+ ]
1313
+ )
1314
+ if (rows === undefined || all.length <= rows) {
1315
+ return all.map((row) => row.line)
1316
+ }
1317
+ const wanted = all.findIndex((row) => row.lane === selected?.id)
1318
+ // The "… n above/more" notes take rows of the budget too.
1319
+ const window = (room: number): number => {
1320
+ const start = Math.max(0, Math.min(view.scroll, all.length - room))
1321
+ return wanted < 0
1322
+ ? start
1323
+ : wanted < start
1324
+ ? wanted
1325
+ : wanted >= start + room
1326
+ ? wanted - room + 1
1327
+ : start
1328
+ }
1329
+ let room = Math.max(1, rows - 1)
1330
+ let first = window(room)
1331
+ if (first > 0 && first + room < all.length) {
1332
+ room = Math.max(1, rows - 2)
1333
+ first = window(room)
1334
+ }
1335
+ const shown = all.slice(first, first + room)
1336
+ const above = first
1337
+ const below = all.length - first - shown.length
1338
+ return [
1339
+ ...(above > 0 ? [[span(`… ${above} above`, "dim")]] : []),
1340
+ ...shown.map((row) => row.line),
1341
+ ...(below > 0 ? [[span(`… ${below} more`, "dim")]] : [])
1342
+ ]
1343
+ }
1344
+
1345
+ const tick = (task: TreeTask, running: boolean): string =>
1346
+ task.done ? "[x]" : running ? "[▶]" : "[ ]"
1347
+
1348
+ /**
1349
+ * The selected lane in full: its task checklist with the criteria each task
1350
+ * satisfies, its stage, what it is doing, its tools, a harness pause, and
1351
+ * its figures (ADR 0033). Expanded, every stage and tool it ran.
1352
+ */
1353
+ const detailBox = (
1354
+ lane: TreeLane,
1355
+ width: number,
1356
+ now: number | undefined,
1357
+ idleAfterMs: number,
1358
+ expanded: boolean
1359
+ ): Array<Line> => {
1360
+ const inner = width - 4
1361
+ const checklist: Array<Line> =
1362
+ lane.tasks.length === 0
1363
+ ? [[span("no task plan yet", "dim")]]
1364
+ : lane.tasks.map((task): Line => {
1365
+ const running = lane.task?.index === task.index
1366
+ const right = task.satisfies === undefined ? "" : `satisfies ${task.satisfies.join(",")}`
1367
+ const left: Line = [
1368
+ span(`${tick(task, running)} ${task.index} ${task.title}`, running ? "bold" : undefined)
1369
+ ]
1370
+ return right.length === 0
1371
+ ? left
1372
+ : [...fit(left, Math.max(1, inner - [...right].length - 1)), span(` ${right}`, "dim")]
1373
+ })
1374
+ const stages: Array<Line> = expanded
1375
+ ? lane.stages.map((stage): Line => [span(stage)])
1376
+ : [[span("stage "), span(lane.stages.at(-1) ?? "starting")]]
1377
+ const tools: Array<Line> = expanded
1378
+ ? [
1379
+ [span(`tools (last ${lane.tools.length})`, "dim")],
1380
+ ...lane.tools.map((tool): Line => [span(` ${tool}`, "dim")])
1381
+ ]
1382
+ : lane.tools.length === 0
1383
+ ? []
1384
+ : [[span("tools "), span(lane.tools.slice(-3).join(" · "), "dim")]]
1385
+ const title = `${lane.id} · ${cloneName(lane.executor ?? "(leasing)", lane.clone)}${
1386
+ lane.task === undefined ? "" : ` · task ${lane.task.index}/${lane.task.count}`
1387
+ }`
1388
+ return box(
1030
1389
  width,
1031
1390
  "running",
1032
1391
  [
1033
- [span(`story ${lane.id}`, "bold"), span(` ${elapsed(lane.startedAt, now)}`)],
1034
- ...lane.stages.map((stage): Line => [span(stage)]),
1035
- [span(`tools (last ${lane.tools.length})`, "dim")],
1036
- ...lane.tools.map((tool): Line => [span(` ${tool}`, "dim")]),
1037
- [span(`${formatCount(lane.tokens)} tok · ${dollars(lane.costUsd)}`)]
1392
+ ...checklist,
1393
+ ...stages,
1394
+ doingLine(lane, now),
1395
+ ...(lane.pause === undefined
1396
+ ? []
1397
+ : [[span(`⏸ ${lane.pause.label} ${elapsed(lane.pause.since, now)}`, "judge")]]),
1398
+ ...tools,
1399
+ [
1400
+ span(
1401
+ `${elapsed(lane.startedAt, now)} · ${formatCount(lane.tokens)} tok${lane.costUsd > 0 ? ` · ${dollars(lane.costUsd)}` : ""}`
1402
+ )
1403
+ ],
1404
+ ...(lane.turns.length === 0 && lane.gatesMs === 0
1405
+ ? []
1406
+ : [
1407
+ [
1408
+ span(
1409
+ `turns ${lane.turns.length} · avg ${elapsedMs(
1410
+ lane.turns.length === 0
1411
+ ? 0
1412
+ : lane.turns.reduce((total, turn) => total + turn, 0) / lane.turns.length
1413
+ )} · gates ${elapsedMs(lane.gatesMs)}`
1414
+ )
1415
+ ]
1416
+ ]),
1417
+ statusLine(lane, now, idleAfterMs)
1418
+ ],
1419
+ title
1420
+ )
1421
+ }
1422
+
1423
+ const bar = (done: number, total: number, width = 5): string => {
1424
+ const filled = total === 0 ? 0 : Math.round((done / total) * width)
1425
+ return "█".repeat(filled) + "░".repeat(width - filled)
1426
+ }
1427
+
1428
+ const boardColumns: ReadonlyArray<readonly [string, ChipStatus]> = [
1429
+ ["planned", "planned"],
1430
+ ["active", "running"],
1431
+ ["waiting", "waiting"],
1432
+ ["done", "done"],
1433
+ ["failed", "failed"]
1434
+ ]
1435
+
1436
+ /**
1437
+ * The boards (ADR 0033): the epic's stories by column, with a running lane's
1438
+ * clone and progress on its card, then the selected lane's tasks by column.
1439
+ */
1440
+ const boardsOf = (
1441
+ state: TreeState,
1442
+ width: number,
1443
+ selected: TreeLane | undefined,
1444
+ now: number | undefined
1445
+ ): Array<Line> => {
1446
+ const chips = chipsOf(state)
1447
+ // Five columns need ~16 characters each to read; narrower, two rows of columns.
1448
+ const epic = chunked(boardColumns, width >= 5 * 16 + 8 ? 5 : 3).flatMap((group) =>
1449
+ columnsOf(group, width, ([name, status], each) => {
1450
+ const members = chips.filter((chip) => chip.status === status)
1451
+ const cards = members.flatMap((chip): Array<Line> => {
1452
+ const lane = state.lanes.find((candidate) => candidate.id === chip.id)
1453
+ if (lane === undefined || lane.status !== "running") {
1454
+ return [[span(chip.id)]]
1455
+ }
1456
+ const done = lane.tasks.filter((task) => task.done).length
1457
+ const progress =
1458
+ lane.tasks.length === 0
1459
+ ? ""
1460
+ : ` ${done}/${lane.tasks.length}${each >= 24 ? ` ${bar(done, lane.tasks.length)}` : ""}`
1461
+ return [
1462
+ [span(chip.id, "bold")],
1463
+ [span(` ${cloneName(lane.executor ?? "(leasing)", lane.clone)}${progress}`, "dim")]
1464
+ ]
1465
+ })
1466
+ return box(
1467
+ each,
1468
+ "running",
1469
+ cards.length === 0 ? [[span("—", "dim")]] : cards,
1470
+ `${name} (${members.length})`
1471
+ )
1472
+ })
1473
+ )
1474
+ if (selected === undefined) {
1475
+ return [[span("epic board", "bold")], ...epic]
1476
+ }
1477
+ const taskColumns: ReadonlyArray<readonly [string, (task: TreeTask) => boolean]> = [
1478
+ ["todo", (task) => !task.done && task.index !== selected.task?.index],
1479
+ ["doing", (task) => !task.done && task.index === selected.task?.index],
1480
+ ["review", () => false],
1481
+ ["done", (task) => task.done]
1482
+ ]
1483
+ const story = chunked(taskColumns, width >= 4 * 16 + 6 ? 4 : 2).flatMap((group) =>
1484
+ columnsOf(group, width, ([name, keep], each) => {
1485
+ const members = selected.tasks.filter(keep)
1486
+ return box(
1487
+ each,
1488
+ "running",
1489
+ members.length === 0
1490
+ ? [[span("—", "dim")]]
1491
+ : members.map((task): Line => [span(`${task.index} ${task.title}`)]),
1492
+ `${name} (${members.length})`
1493
+ )
1494
+ })
1495
+ )
1496
+ return [
1497
+ [span("epic board", "bold")],
1498
+ ...epic,
1499
+ [],
1500
+ [
1501
+ span(
1502
+ `story board · ${selected.id} · ${cloneName(selected.executor ?? "(leasing)", selected.clone)}`,
1503
+ "bold"
1504
+ ),
1505
+ span(` · ${elapsed(selected.startedAt, now)}`)
1038
1506
  ],
1039
- `${lane.id} · ${lane.executor ?? "(leasing)"}`
1507
+ ...story
1508
+ ]
1509
+ }
1510
+
1511
+ /** `codex ×2 · claude ×1`: how many leases each executor holds now. */
1512
+ const leaseCounts = (state: TreeState): string =>
1513
+ state.executors
1514
+ .map(
1515
+ (executor) =>
1516
+ [executor, state.leases.filter((lease) => lease.executor === executor).length] as const
1517
+ )
1518
+ .filter(([, count]) => count > 0)
1519
+ .map(([executor, count]) => `${executor} ×${count}`)
1520
+ .join(" · ")
1521
+
1522
+ const chunked = <A>(items: ReadonlyArray<A>, size: number): Array<ReadonlyArray<A>> =>
1523
+ Array.from({ length: Math.ceil(items.length / size) }, (_, index) =>
1524
+ items.slice(index * size, index * size + size)
1040
1525
  )
1041
1526
 
1042
1527
  const barWidth = 10
@@ -1112,9 +1597,10 @@ const mainOf = (
1112
1597
  state: TreeState,
1113
1598
  width: number,
1114
1599
  view: TreeView,
1115
- tail: ReadonlyArray<string> | undefined
1600
+ tail: ReadonlyArray<string> | undefined,
1601
+ listRows: number | undefined
1116
1602
  ): Array<Line> => {
1117
- const running = state.lanes.filter((lane) => lane.status === "running")
1603
+ const running = runningLanes(state)
1118
1604
  const orchestratorWidth = Math.min(46, width)
1119
1605
  const stage = state.stages.at(-1) ?? (running.length > 0 ? "implement stories" : "idle")
1120
1606
  // The run's own work under way (its gates, outside any story), innermost.
@@ -1140,32 +1626,37 @@ const mainOf = (
1140
1626
  ],
1141
1627
  state.title
1142
1628
  )
1143
- const selected = running.find((lane) => lane.id === view.selected)
1144
- const shown = running.slice(0, maxLaneBoxes)
1145
- const executors = state.executors.slice(0, maxLaneBoxes + 1)
1146
- const lanes =
1629
+ const selected = selectedLane(state, view)
1630
+ const counts = leaseCounts(state)
1631
+ const agents = centre(
1632
+ [span(`agents · ${running.length} running${counts.length === 0 ? "" : ` · ${counts}`}`)],
1633
+ width
1634
+ )
1635
+ const body =
1147
1636
  view.tail && view.selected !== undefined
1148
1637
  ? tailOf(view, tail, width)
1149
- : view.expanded && selected !== undefined
1150
- ? expandedLane(selected, width, state.now)
1151
- : view.mode === "executors"
1152
- ? executors.length === 0
1153
- ? [centre([span("no executors yet", "dim")], width)]
1154
- : columnsOf(executors, width, (executor, each) =>
1638
+ : view.mode === "executors"
1639
+ ? state.executors.length === 0
1640
+ ? [centre([span("no executors yet", "dim")], width)]
1641
+ : chunked(state.executors, 4).flatMap((group) =>
1642
+ columnsOf(group, width, (executor, each) =>
1155
1643
  executorBox(state, executor, each, executor === view.selected)
1156
1644
  )
1157
- : shown.length === 0
1645
+ )
1646
+ : view.mode === "boards"
1647
+ ? boardsOf(state, width, selected, state.now)
1648
+ : running.length === 0 || selected === undefined
1158
1649
  ? [centre([span("no stories in flight", "dim")], width)]
1159
- : columnsOf(shown, width, (lane, each) =>
1160
- laneBox(lane, each, state.now, lane.id === view.selected, state.idleAfterMs)
1161
- )
1650
+ : [
1651
+ ...agentRows(state, width, view, selected, listRows),
1652
+ ...detailBox(selected, width, state.now, state.idleAfterMs, view.expanded)
1653
+ ]
1162
1654
  return [
1163
1655
  ...orchestrator.map((line) => centre(line, width)),
1164
1656
  centre([span("•", "orchestrator")], width),
1165
1657
  ...judgmentOf(state, width),
1166
- centre([span(`delegate to roster · ${running.length} running`)], width),
1167
- centre([span("▼", "dim")], width),
1168
- ...lanes,
1658
+ agents,
1659
+ ...body,
1169
1660
  [],
1170
1661
  ...wrapChips(chipsOf(state).map(chipOf), width)
1171
1662
  ]
@@ -1198,7 +1689,8 @@ const timeSplitOf = (time: TreeTimeSplit): Array<Line> => {
1198
1689
  const frameOf = (
1199
1690
  state: TreeState,
1200
1691
  options: TreeRenderOptions,
1201
- logCount: number | "none"
1692
+ logCount: number | "none",
1693
+ listRows: number | undefined
1202
1694
  ): ReadonlyArray<string> => {
1203
1695
  const { width } = options
1204
1696
  const mainWidth = width - railWidth - 2
@@ -1214,7 +1706,7 @@ const frameOf = (
1214
1706
  [span("═".repeat(width), "dim")],
1215
1707
  [],
1216
1708
  ...beside(
1217
- [railOf(state), mainOf(state, mainWidth, options.view, options.tail)],
1709
+ [railOf(state), mainOf(state, mainWidth, options.view, options.tail, listRows)],
1218
1710
  [railWidth, mainWidth]
1219
1711
  ),
1220
1712
  ...(logCount === "none"
@@ -1236,24 +1728,38 @@ const frameOf = (
1236
1728
  }
1237
1729
 
1238
1730
  /**
1239
- * The tree at a width and, when `height` is given, no taller: the session
1240
- * log gives way first — fewer lines, down to one, then none — and only then
1241
- * is the frame cut, keeping its status lines, so a redraw never scrolls.
1731
+ * The tree at a width and, when `height` is given, no taller: the agent
1732
+ * list gives way first (down to `minListRows`, with a count of what is out
1733
+ * of view), then the session log — fewer lines, down to one, then none —
1734
+ * and only then is the frame cut, keeping its status lines, so a redraw
1735
+ * never scrolls.
1242
1736
  */
1243
1737
  export const renderTree = (state: TreeState, options: TreeRenderOptions): ReadonlyArray<string> => {
1244
1738
  const wanted = options.view.fullLog ? fullLogLines : logLines
1245
- const full = frameOf(state, options, wanted)
1739
+ const full = frameOf(state, options, wanted, undefined)
1246
1740
  const { height } = options
1247
1741
  if (height === undefined || full.length <= height) {
1248
1742
  return full
1249
1743
  }
1744
+ const total = listRowsOf(state)
1745
+ const floor = Math.min(total, minListRows)
1746
+ const listRows =
1747
+ options.view.mode === "lanes" && total > floor
1748
+ ? Math.max(floor, total - (full.length - height))
1749
+ : undefined
1750
+ if (listRows !== undefined) {
1751
+ const shorter = frameOf(state, options, wanted, listRows)
1752
+ if (shorter.length <= height) {
1753
+ return shorter
1754
+ }
1755
+ }
1250
1756
  for (let count = wanted - 1; count >= 1; count -= 1) {
1251
- const shorter = frameOf(state, options, count)
1757
+ const shorter = frameOf(state, options, count, listRows)
1252
1758
  if (shorter.length <= height) {
1253
1759
  return shorter
1254
1760
  }
1255
1761
  }
1256
- return fitToRows(frameOf(state, options, "none"), height)
1762
+ return fitToRows(frameOf(state, options, "none", listRows), height)
1257
1763
  }
1258
1764
 
1259
1765
  /**