@llm4ts/runner 2.19.0 → 2.21.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.
@@ -0,0 +1,954 @@
1
+ import type { TokenUsage } from "@llm4ts/core/Models"
2
+ import * as Option from "effect/Option"
3
+ import * as Schema from "effect/Schema"
4
+ import { FlowEvent } from "@llm4ts/flow/FlowEvents"
5
+ import type { TraceLine } from "@llm4ts/flow/FlowRecorder"
6
+ import { estimateCostUsd } from "@llm4ts/flow/PriceList"
7
+ import { formatCount } from "./Terminal.ts"
8
+
9
+ /**
10
+ * The agent tree (ADR 0022): a full-screen view of a running or finished
11
+ * flow — the orchestrator, the judgment layer, the story lanes on the
12
+ * executor roster, the judge seat and a session log.
13
+ *
14
+ * Pure: `reduceTree` folds what a run published into a `TreeState`, and
15
+ * `renderTree` draws that state at a width. Where the inputs come from (the
16
+ * event hub of a live run, or a trace file) is the host's business, so a live
17
+ * view, a finished run and a replay are the same fold.
18
+ */
19
+
20
+ export type TreeInput =
21
+ | { readonly _tag: "Event"; readonly at: number; readonly event: FlowEvent }
22
+ | { readonly _tag: "RunEnded"; readonly at: number; readonly outcome: string }
23
+
24
+ export type LaneStatus = "running" | "done" | "failed"
25
+
26
+ export interface TreeLane {
27
+ readonly id: string
28
+ /** The lane's open stages below `story <id>`, innermost last. */
29
+ readonly stages: ReadonlyArray<string>
30
+ readonly executor: string | undefined
31
+ readonly startedAt: number
32
+ readonly status: LaneStatus
33
+ readonly lastTool: string | undefined
34
+ /** The lane's most recent tool calls, oldest first, for the expanded view. */
35
+ readonly tools: ReadonlyArray<string>
36
+ readonly tokens: number
37
+ readonly costUsd: number
38
+ }
39
+
40
+ export type LogSource = "lane" | "run" | "roster" | "judge"
41
+
42
+ export interface TreeLogEntry {
43
+ readonly at: number
44
+ readonly source: LogSource
45
+ /** The lane, executor or `flow` the entry is about. */
46
+ readonly who: string
47
+ readonly what: string
48
+ }
49
+
50
+ /** A story as the epic's board lists it, before or without a lane of its own. */
51
+ export interface TreeStory {
52
+ readonly id: string
53
+ readonly status: "planned" | "active" | "waiting" | "done" | "failed" | "skipped"
54
+ }
55
+
56
+ /** A lease an executor holds now: its role and who it is for (a story id). */
57
+ export interface TreeLease {
58
+ readonly executor: string
59
+ readonly role: string
60
+ readonly label: string | undefined
61
+ }
62
+
63
+ /** The judge seat: who sits in it, and how often it was asked. */
64
+ export interface TreeJudge {
65
+ readonly executor: string | undefined
66
+ readonly reviews: number
67
+ readonly verdicts: number
68
+ }
69
+
70
+ /** The latest story verdict (`StoryJudged`). */
71
+ export interface TreeVerdict {
72
+ readonly lane: string
73
+ readonly round: number
74
+ readonly cleared: boolean
75
+ readonly issues: number
76
+ readonly dimensions: ReadonlyArray<{
77
+ readonly id: string
78
+ readonly score: number
79
+ readonly max: number
80
+ }>
81
+ }
82
+
83
+ /** The latest typed judgment per key (`JudgmentObserved`). */
84
+ export interface TreeJudgment {
85
+ readonly key: string
86
+ readonly certainty: number
87
+ readonly decision: string
88
+ }
89
+
90
+ export interface TreeState {
91
+ readonly title: string
92
+ readonly action: string | undefined
93
+ /** The board's stories, in plan order; lanes not on it are added after. */
94
+ readonly stories: ReadonlyArray<TreeStory>
95
+ readonly startedAt: number | undefined
96
+ readonly now: number | undefined
97
+ /** Run-wide open stages, innermost last. */
98
+ readonly stages: ReadonlyArray<string>
99
+ /** Lanes in the order they started. */
100
+ readonly lanes: ReadonlyArray<TreeLane>
101
+ /** Executors in the order the run first named them. */
102
+ readonly executors: ReadonlyArray<string>
103
+ readonly leases: ReadonlyArray<TreeLease>
104
+ /** Executors out of the round, with the roster's reason. */
105
+ readonly exclusions: ReadonlyArray<{ readonly executor: string; readonly reason: string }>
106
+ readonly judge: TreeJudge
107
+ readonly verdict: TreeVerdict | undefined
108
+ readonly judgments: ReadonlyArray<TreeJudgment>
109
+ readonly log: ReadonlyArray<TreeLogEntry>
110
+ readonly tokens: number
111
+ /** Estimated: the backend's own figure when it reports one, else the price list. */
112
+ readonly costUsd: number
113
+ /** The run's outcome once its trace says it ended; a live run has none. */
114
+ readonly ended: string | undefined
115
+ }
116
+
117
+ export interface TreeOptions {
118
+ readonly title?: string
119
+ /** What the run set out to do ("run plan", "round 2"), when the caller knows. */
120
+ readonly action?: string
121
+ readonly stories?: ReadonlyArray<TreeStory>
122
+ }
123
+
124
+ export const emptyTree = (options: TreeOptions = {}): TreeState => ({
125
+ title: options.title ?? "flow",
126
+ action: options.action,
127
+ stories: options.stories ?? [],
128
+ startedAt: undefined,
129
+ now: undefined,
130
+ stages: [],
131
+ lanes: [],
132
+ executors: [],
133
+ leases: [],
134
+ exclusions: [],
135
+ judge: { executor: undefined, reviews: 0, verdicts: 0 },
136
+ verdict: undefined,
137
+ judgments: [],
138
+ log: [],
139
+ tokens: 0,
140
+ costUsd: 0,
141
+ ended: undefined
142
+ })
143
+
144
+ const costOf = (model: string | undefined, usage: TokenUsage): number =>
145
+ usage.costUsd ?? (model === undefined ? undefined : estimateCostUsd(model, usage)) ?? 0
146
+
147
+ const logged = (state: TreeState, entry: TreeLogEntry): TreeState => ({
148
+ ...state,
149
+ log: [...state.log, entry]
150
+ })
151
+
152
+ const storyStage = /^story (\S+)$/u
153
+ const expandedTools = 20
154
+
155
+ const updateLane = (
156
+ state: TreeState,
157
+ id: string,
158
+ update: (lane: TreeLane) => TreeLane
159
+ ): TreeState => ({
160
+ ...state,
161
+ lanes: state.lanes.map((lane) => (lane.id === id ? update(lane) : lane))
162
+ })
163
+
164
+ const withoutLast = <A>(items: ReadonlyArray<A>, item: A): ReadonlyArray<A> => {
165
+ const index = items.lastIndexOf(item)
166
+ return index < 0 ? items : [...items.slice(0, index), ...items.slice(index + 1)]
167
+ }
168
+
169
+ const withExecutor = (state: TreeState, executor: string): TreeState =>
170
+ state.executors.includes(executor)
171
+ ? state
172
+ : { ...state, executors: [...state.executors, executor] }
173
+
174
+ const rosterLog = (state: TreeState, at: number, what: string): TreeState =>
175
+ logged(state, { at, source: "roster", who: "roster", what })
176
+
177
+ const reduceLease = (state: TreeState, at: number, lease: TreeLease): TreeState => {
178
+ const held = { ...withExecutor(state, lease.executor), leases: [...state.leases, lease] }
179
+ switch (lease.role) {
180
+ case "coder": {
181
+ const lane = held.lanes.find((candidate) => candidate.id === lease.label)
182
+ const placed =
183
+ lane === undefined
184
+ ? held
185
+ : updateLane(held, lane.id, (open) => ({ ...open, executor: lease.executor }))
186
+ return rosterLog(
187
+ placed,
188
+ at,
189
+ `${lease.executor} → coder${lease.label === undefined ? "" : ` · ${lease.label}`}`
190
+ )
191
+ }
192
+ case "reviewer":
193
+ return {
194
+ ...held,
195
+ judge: {
196
+ ...held.judge,
197
+ executor: held.judge.executor ?? lease.executor,
198
+ reviews: held.judge.reviews + 1
199
+ }
200
+ }
201
+ case "judge":
202
+ return {
203
+ ...held,
204
+ judge: { ...held.judge, executor: lease.executor, verdicts: held.judge.verdicts + 1 }
205
+ }
206
+ default:
207
+ return held
208
+ }
209
+ }
210
+
211
+ const issueCount = (issues: number): string => `${issues} issue${issues === 1 ? "" : "s"}`
212
+
213
+ const verdictWords = (verdict: TreeVerdict): string =>
214
+ verdict.cleared ? "cleared" : issueCount(verdict.issues)
215
+
216
+ const reduceEvent = (state: TreeState, at: number, event: FlowEvent): TreeState => {
217
+ const lane =
218
+ "lane" in event && event.lane !== undefined
219
+ ? state.lanes.find((candidate) => candidate.id === event.lane)
220
+ : undefined
221
+ const executor = "executor" in event ? event.executor : undefined
222
+ const named =
223
+ executor === undefined || state.executors.includes(executor)
224
+ ? state
225
+ : { ...state, executors: [...state.executors, executor] }
226
+ const current =
227
+ lane !== undefined && executor !== undefined && lane.executor !== executor
228
+ ? updateLane(named, lane.id, (open) => ({ ...open, executor }))
229
+ : named
230
+ switch (event._tag) {
231
+ case "StageStarted": {
232
+ const story = storyStage.exec(event.stage)?.[1]
233
+ if (story !== undefined && event.lane === story) {
234
+ const fresh: TreeLane = {
235
+ id: story,
236
+ stages: [],
237
+ executor: event.executor,
238
+ startedAt: at,
239
+ status: "running",
240
+ lastTool: undefined,
241
+ tools: [],
242
+ tokens: 0,
243
+ costUsd: 0
244
+ }
245
+ return {
246
+ ...current,
247
+ lanes: [...current.lanes.filter((open) => open.id !== story), fresh]
248
+ }
249
+ }
250
+ if (lane !== undefined) {
251
+ return updateLane(current, lane.id, (open) => ({
252
+ ...open,
253
+ stages: [...open.stages, event.stage]
254
+ }))
255
+ }
256
+ return { ...current, stages: [...current.stages, event.stage] }
257
+ }
258
+ case "StageCompleted":
259
+ case "StageFailed": {
260
+ if (lane !== undefined && event.stage === `story ${lane.id}`) {
261
+ const status: LaneStatus = event._tag === "StageFailed" ? "failed" : "done"
262
+ const closed = updateLane(current, lane.id, (open) => ({ ...open, stages: [], status }))
263
+ return logged(closed, {
264
+ at,
265
+ source: "lane",
266
+ who: lane.id,
267
+ what: event._tag === "StageFailed" ? `failed · ${event.message}` : "done"
268
+ })
269
+ }
270
+ if (lane !== undefined) {
271
+ return updateLane(current, lane.id, (open) => ({
272
+ ...open,
273
+ stages: withoutLast(open.stages, event.stage)
274
+ }))
275
+ }
276
+ return { ...current, stages: withoutLast(current.stages, event.stage) }
277
+ }
278
+ case "Info": {
279
+ // A story's own milestones are `story <id>: …` (resuming, merged,
280
+ // skipped); its other chatter stays in the expanded lane.
281
+ if (event.lane === undefined) {
282
+ // Traces from before the roster's typed events (2.20) carry its notes
283
+ // as prose: leases are chatter, the rest (exclusions, handovers) stays.
284
+ const legacyRoster = /^roster: (.*)$/u.exec(event.message)?.[1]
285
+ if (legacyRoster !== undefined) {
286
+ return / takes \S+/u.test(legacyRoster)
287
+ ? current
288
+ : logged(current, { at, source: "roster", who: "roster", what: legacyRoster })
289
+ }
290
+ return logged(current, { at, source: "run", who: "flow", what: event.message })
291
+ }
292
+ const prefix = `story ${event.lane}: `
293
+ return event.message.startsWith(prefix)
294
+ ? logged(current, {
295
+ at,
296
+ source: "lane",
297
+ who: event.lane,
298
+ what: event.message.slice(prefix.length)
299
+ })
300
+ : current
301
+ }
302
+ case "StoryJudged": {
303
+ const verdict: TreeVerdict = {
304
+ lane: event.lane,
305
+ round: event.round,
306
+ cleared: event.cleared,
307
+ issues: event.issues,
308
+ dimensions: event.dimensions
309
+ }
310
+ return logged(
311
+ { ...current, verdict },
312
+ {
313
+ at,
314
+ source: "judge",
315
+ who: "judge",
316
+ what: `${verdict.lane} r${verdict.round} · ${verdictWords(verdict)} → ${verdict.cleared ? "merge" : "coder"}`
317
+ }
318
+ )
319
+ }
320
+ case "JudgmentObserved":
321
+ return {
322
+ ...current,
323
+ judgments: [
324
+ ...current.judgments.filter((judgment) => judgment.key !== event.key),
325
+ { key: event.key, certainty: event.certainty, decision: event.decision }
326
+ ]
327
+ }
328
+ case "ExecutorLeased":
329
+ return reduceLease(current, at, {
330
+ executor: event.executor,
331
+ role: event.role,
332
+ label: event.label
333
+ })
334
+ case "ExecutorReleased": {
335
+ const index = current.leases.findIndex(
336
+ (lease) =>
337
+ lease.executor === event.executor &&
338
+ lease.role === event.role &&
339
+ lease.label === event.label
340
+ )
341
+ return index < 0
342
+ ? current
343
+ : {
344
+ ...current,
345
+ leases: [...current.leases.slice(0, index), ...current.leases.slice(index + 1)]
346
+ }
347
+ }
348
+ case "ExecutorExcluded":
349
+ return rosterLog(
350
+ {
351
+ ...withExecutor(current, event.executor),
352
+ exclusions: [
353
+ ...current.exclusions.filter((out) => out.executor !== event.executor),
354
+ { executor: event.executor, reason: event.reason }
355
+ ]
356
+ },
357
+ at,
358
+ `${event.executor} out · ${event.reason}`
359
+ )
360
+ case "ExecutorResumed":
361
+ return rosterLog(
362
+ {
363
+ ...current,
364
+ exclusions: current.exclusions.filter((out) => out.executor !== event.executor)
365
+ },
366
+ at,
367
+ `${event.executor} back · ${event.why}`
368
+ )
369
+ case "ExecutorHandedOver":
370
+ return rosterLog(
371
+ current,
372
+ at,
373
+ `${event.label ?? "the run"}: ${event.role} leaves ${event.from} · ${event.reason}`
374
+ )
375
+ case "ToolUse":
376
+ return lane === undefined
377
+ ? current
378
+ : updateLane(current, lane.id, (open) => ({
379
+ ...open,
380
+ lastTool: `${event.tool} ${event.args}`,
381
+ tools: [...open.tools, `${event.tool} ${event.args}`].slice(-expandedTools)
382
+ }))
383
+ case "TokensUsed": {
384
+ const tokens = event.usage.total
385
+ const costUsd = costOf(event.model, event.usage)
386
+ const counted: TreeState = {
387
+ ...current,
388
+ tokens: current.tokens + tokens,
389
+ costUsd: current.costUsd + costUsd
390
+ }
391
+ return lane === undefined
392
+ ? counted
393
+ : updateLane(counted, lane.id, (open) => ({
394
+ ...open,
395
+ tokens: open.tokens + tokens,
396
+ costUsd: open.costUsd + costUsd
397
+ }))
398
+ }
399
+ default:
400
+ return current
401
+ }
402
+ }
403
+
404
+ export const reduceTree = (state: TreeState, input: TreeInput): TreeState => {
405
+ const timed: TreeState = {
406
+ ...state,
407
+ startedAt: state.startedAt ?? input.at,
408
+ now: Math.max(state.now ?? input.at, input.at)
409
+ }
410
+ switch (input._tag) {
411
+ case "Event":
412
+ return reduceEvent(timed, input.at, input.event)
413
+ case "RunEnded":
414
+ return { ...timed, ended: input.outcome }
415
+ }
416
+ }
417
+
418
+ const decodeEvent = Schema.decodeUnknownOption(Schema.fromJsonString(FlowEvent))
419
+
420
+ /**
421
+ * A trace's lines as tree inputs. Kinds this version does not know — raw
422
+ * stream lines, events from a newer llm4ts — are skipped, so any trace renders.
423
+ */
424
+ export const treeInputsOfTrace = (lines: ReadonlyArray<TraceLine>): ReadonlyArray<TreeInput> =>
425
+ lines.flatMap((line): ReadonlyArray<TreeInput> => {
426
+ if (line.kind === "RunEnded") {
427
+ const outcome = line.fields.outcome
428
+ return [
429
+ {
430
+ _tag: "RunEnded",
431
+ at: line.timestamp,
432
+ outcome: typeof outcome === "string" ? outcome : "completed"
433
+ }
434
+ ]
435
+ }
436
+ const raw = line.fields.event
437
+ if (typeof raw !== "string") {
438
+ return []
439
+ }
440
+ return Option.match(decodeEvent(raw), {
441
+ onNone: () => [],
442
+ onSome: (event) => [{ _tag: "Event", at: line.timestamp, event }]
443
+ })
444
+ })
445
+
446
+ // ── drawing ─────────────────────────────────────────────────────────────────
447
+
448
+ type Style = "bold" | "dim" | "orchestrator" | "judgment" | "judge" | "log" | "running"
449
+
450
+ interface Span {
451
+ readonly text: string
452
+ readonly style?: Style
453
+ }
454
+
455
+ /** A frame line before colour: spans, so cutting to width never splits an escape. */
456
+ type Line = ReadonlyArray<Span>
457
+
458
+ const span = (text: string, style?: Style): Span =>
459
+ style === undefined ? { text } : { text, style }
460
+
461
+ const lengthOf = (line: Line): number =>
462
+ line.reduce((total, part) => total + [...part.text].length, 0)
463
+
464
+ /** Pads or cuts a line to exactly `width` characters; a cut ends in `…`. */
465
+ const fit = (line: Line, width: number): Line => {
466
+ const length = lengthOf(line)
467
+ if (length <= width) {
468
+ return length === width ? line : [...line, span(" ".repeat(width - length))]
469
+ }
470
+ const kept: Array<Span> = []
471
+ let room = width - 1
472
+ for (const part of line) {
473
+ if (room <= 0) {
474
+ break
475
+ }
476
+ const characters = [...part.text]
477
+ const taken = characters.slice(0, room).join("")
478
+ kept.push(part.style === undefined ? span(taken) : span(taken, part.style))
479
+ room -= Math.min(room, characters.length)
480
+ }
481
+ return [...kept, span("…")]
482
+ }
483
+
484
+ const centre = (line: Line, width: number): Line => {
485
+ const pad = Math.max(0, Math.floor((width - lengthOf(line)) / 2))
486
+ return fit([span(" ".repeat(pad)), ...line], width)
487
+ }
488
+
489
+ const box = (
490
+ width: number,
491
+ style: Style,
492
+ body: ReadonlyArray<Line>,
493
+ title?: string
494
+ ): Array<Line> => {
495
+ const inner = width - 4
496
+ const top: Line =
497
+ title === undefined
498
+ ? [span(`┌${"─".repeat(width - 2)}┐`, style)]
499
+ : fit(
500
+ [
501
+ span("┌─ ", style),
502
+ span(title, "bold"),
503
+ span(` ${"─".repeat(Math.max(0, width - 5 - [...title].length))}┐`, style)
504
+ ],
505
+ width
506
+ )
507
+ return [
508
+ top,
509
+ ...body.map((line): Line => [span("│ ", style), ...fit(line, inner), span(" │", style)]),
510
+ [span(`└${"─".repeat(width - 2)}┘`, style)]
511
+ ]
512
+ }
513
+
514
+ const beside = (
515
+ columns: ReadonlyArray<ReadonlyArray<Line>>,
516
+ widths: ReadonlyArray<number>,
517
+ gap = 2
518
+ ): Array<Line> => {
519
+ const height = Math.max(0, ...columns.map((column) => column.length))
520
+ return Array.from({ length: height }, (_, row) =>
521
+ columns.flatMap((column, index) => [
522
+ ...(index === 0 ? [] : [span(" ".repeat(gap))]),
523
+ ...fit(column[row] ?? [], widths[index] ?? 0)
524
+ ])
525
+ )
526
+ }
527
+
528
+ const clock = (from: number | undefined, to: number): string => {
529
+ const seconds = Math.max(0, Math.round((to - (from ?? to)) / 1_000))
530
+ return [Math.floor(seconds / 3_600), Math.floor(seconds / 60) % 60, seconds % 60]
531
+ .map((part) => String(part).padStart(2, "0"))
532
+ .join(":")
533
+ }
534
+
535
+ const logStyle: Readonly<Record<LogSource, Style>> = {
536
+ lane: "judgment",
537
+ run: "orchestrator",
538
+ roster: "running",
539
+ judge: "judge"
540
+ }
541
+
542
+ const logLines = 5
543
+ const fullLogLines = 30
544
+
545
+ const logOf = (state: TreeState, count: number): Array<Line> => {
546
+ const recent = state.log.slice(-count)
547
+ return recent.length === 0
548
+ ? [[span("—", "dim")]]
549
+ : recent.map((entry) => [
550
+ span(clock(state.startedAt, entry.at), "dim"),
551
+ span(" "),
552
+ ...fit([span(entry.who, logStyle[entry.source])], 16),
553
+ span(" "),
554
+ span(entry.what)
555
+ ])
556
+ }
557
+
558
+ const dollars = (usd: number): string => `~$${usd.toFixed(2)}`
559
+
560
+ const elapsed = (from: number | undefined, to: number | undefined): string => {
561
+ const seconds = Math.max(0, Math.round(((to ?? 0) - (from ?? to ?? 0)) / 1_000))
562
+ return seconds < 60
563
+ ? `${seconds}s`
564
+ : `${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, "0")}s`
565
+ }
566
+
567
+ const colours: Readonly<Record<Style, string>> = {
568
+ bold: "\u001b[1m",
569
+ dim: "\u001b[90m",
570
+ orchestrator: "\u001b[38;5;173m",
571
+ judgment: "\u001b[38;5;114m",
572
+ judge: "\u001b[38;5;141m",
573
+ log: "\u001b[38;5;244m",
574
+ running: "\u001b[38;5;110m"
575
+ }
576
+
577
+ const paint = (line: Line, colour: boolean): string =>
578
+ line
579
+ .map((part) =>
580
+ colour && part.style !== undefined ? `${colours[part.style]}${part.text}\u001b[0m` : part.text
581
+ )
582
+ .join("")
583
+
584
+ export type TreeMode = "lanes" | "executors"
585
+
586
+ /** What the reader chose to look at; the run's state is separate. */
587
+ export interface TreeView {
588
+ readonly mode: TreeMode
589
+ /** The selected lane's story id. */
590
+ readonly selected: string | undefined
591
+ readonly expanded: boolean
592
+ readonly fullLog: boolean
593
+ }
594
+
595
+ export const initialView: TreeView = {
596
+ mode: "lanes",
597
+ selected: undefined,
598
+ expanded: false,
599
+ fullLog: false
600
+ }
601
+
602
+ const runningLanes = (state: TreeState): ReadonlyArray<TreeLane> =>
603
+ state.lanes.filter((lane) => lane.status === "running")
604
+
605
+ /**
606
+ * The view after a key: arrows or 1–9 select a running lane, enter expands
607
+ * it, escape collapses, `e` switches lanes and executors, `l` the full log,
608
+ * `q` quits. Keys it does not know return the same view.
609
+ */
610
+ export const onTreeKey = (view: TreeView, key: string, state: TreeState): TreeView | "quit" => {
611
+ const running = runningLanes(state)
612
+ const index = running.findIndex((lane) => lane.id === view.selected)
613
+ const select = (next: number): TreeView => {
614
+ const lane = running[Math.max(0, Math.min(running.length - 1, next))]
615
+ return lane === undefined ? view : { ...view, selected: lane.id }
616
+ }
617
+ switch (key) {
618
+ case "q":
619
+ return "quit"
620
+ case "down":
621
+ case "right":
622
+ return select(index + 1)
623
+ case "up":
624
+ case "left":
625
+ return select(index < 0 ? 0 : index - 1)
626
+ case "enter":
627
+ return view.selected === undefined ? view : { ...view, expanded: !view.expanded }
628
+ case "escape":
629
+ return { ...view, expanded: false }
630
+ case "e":
631
+ return { ...view, mode: view.mode === "lanes" ? "executors" : "lanes" }
632
+ case "l":
633
+ return { ...view, fullLog: !view.fullLog }
634
+ default:
635
+ return /^[1-9]$/u.test(key) ? select(Number(key) - 1) : view
636
+ }
637
+ }
638
+
639
+ export interface TreeRenderOptions {
640
+ readonly width: number
641
+ readonly colour: boolean
642
+ readonly view: TreeView
643
+ /** The terminal's rows; the frame fits them (see `renderTree`). */
644
+ readonly height?: number
645
+ }
646
+
647
+ const railWidth = 26
648
+ const maxLaneBoxes = 3
649
+
650
+ const counter = (label: string, value: number): Line => [
651
+ span(`${label.padEnd(15)}${String(value).padStart(5)}`)
652
+ ]
653
+
654
+ const railOf = (state: TreeState): Array<Line> =>
655
+ box(railWidth, "judge", [
656
+ centre([span("JUDGE SEAT", "bold")], railWidth - 4),
657
+ centre([span(`${state.judge.executor ?? "—"} · on call`)], railWidth - 4),
658
+ [],
659
+ [span("last verdict:")],
660
+ [
661
+ span(
662
+ state.verdict === undefined
663
+ ? "» none yet"
664
+ : `» ${state.verdict.lane} r${state.verdict.round}: ${verdictWords(state.verdict)}`,
665
+ "judge"
666
+ )
667
+ ],
668
+ [],
669
+ counter("reviews", state.judge.reviews),
670
+ counter("verdicts", state.judge.verdicts)
671
+ ])
672
+
673
+ const laneBox = (
674
+ lane: TreeLane,
675
+ width: number,
676
+ now: number | undefined,
677
+ selected: boolean
678
+ ): Array<Line> =>
679
+ box(width, "running", [
680
+ [span(selected ? `▸ ${lane.id}` : lane.id, "bold")],
681
+ [span(lane.executor ?? "(leasing)")],
682
+ [span(lane.stages.at(-1) ?? "starting")],
683
+ [span(lane.lastTool ?? "", "dim")],
684
+ [
685
+ span(
686
+ `${elapsed(lane.startedAt, now)} · ${formatCount(lane.tokens)} tok${lane.costUsd > 0 ? ` · ${dollars(lane.costUsd)}` : ""}`
687
+ )
688
+ ],
689
+ [span("◐ running", "running")]
690
+ ])
691
+
692
+ type ChipStatus = "planned" | "running" | "waiting" | "done" | "failed"
693
+
694
+ /** Every story once: the board's in plan order, then lanes the board does not list. */
695
+ const chipsOf = (
696
+ state: TreeState
697
+ ): ReadonlyArray<{ readonly id: string; readonly status: ChipStatus }> => {
698
+ const laneStatus = (id: string): ChipStatus | undefined =>
699
+ state.lanes.find((lane) => lane.id === id)?.status
700
+ const onBoard = new Set(state.stories.map((story) => story.id))
701
+ return [
702
+ ...state.stories.map((story) => ({
703
+ id: story.id,
704
+ status:
705
+ laneStatus(story.id) ??
706
+ (story.status === "skipped" ? "done" : story.status === "active" ? "planned" : story.status)
707
+ })),
708
+ ...state.lanes
709
+ .filter((lane) => !onBoard.has(lane.id))
710
+ .map((lane) => ({ id: lane.id, status: lane.status }))
711
+ ]
712
+ }
713
+
714
+ const chipMark: Readonly<Record<ChipStatus, readonly [string, Style | undefined]>> = {
715
+ planned: ["·", "dim"],
716
+ running: ["◐", "running"],
717
+ waiting: ["◌", "judge"],
718
+ done: ["✓", "judgment"],
719
+ failed: ["✗", undefined]
720
+ }
721
+
722
+ const chipOf = (chip: { readonly id: string; readonly status: ChipStatus }): Line => {
723
+ const [mark, style] = chipMark[chip.status]
724
+ return [span(`${mark} ${chip.id}`, style)]
725
+ }
726
+
727
+ const wrapChips = (chips: ReadonlyArray<Line>, width: number): Array<Line> => {
728
+ const rows: Array<Array<Span>> = []
729
+ let row: Array<Span> = []
730
+ for (const chip of chips) {
731
+ if (row.length > 0 && lengthOf(row) + 2 + lengthOf(chip) > width) {
732
+ rows.push(row)
733
+ row = []
734
+ }
735
+ row = row.length === 0 ? [...chip] : [...row, span(" "), ...chip]
736
+ }
737
+ return row.length === 0 ? rows : [...rows, row]
738
+ }
739
+
740
+ /**
741
+ * What an executor holds: its leases, or — for a trace from before the
742
+ * roster's events — the running lanes that name it.
743
+ */
744
+ const heldBy = (state: TreeState, executor: string): ReadonlyArray<string> => {
745
+ const leases = state.leases
746
+ .filter((lease) => lease.executor === executor)
747
+ .map((lease) => `${lease.role}${lease.label === undefined ? "" : ` · ${lease.label}`}`)
748
+ return leases.length > 0
749
+ ? leases
750
+ : runningLanes(state)
751
+ .filter((lane) => lane.executor === executor)
752
+ .map((lane) => `coder · ${lane.id}`)
753
+ }
754
+
755
+ const executorBox = (state: TreeState, executor: string, width: number): Array<Line> => {
756
+ const held = heldBy(state, executor)
757
+ const out = state.exclusions.find((exclusion) => exclusion.executor === executor)
758
+ return box(width, "running", [
759
+ [span(executor, "bold")],
760
+ ...(held.length === 0
761
+ ? [[span("no lease", "dim")]]
762
+ : held.slice(0, 3).map((lease): Line => [span(lease)])),
763
+ out !== undefined
764
+ ? [span(`✗ out: ${out.reason}`)]
765
+ : held.length === 0
766
+ ? [span("○ idle", "dim")]
767
+ : [span("◐ busy", "running")]
768
+ ])
769
+ }
770
+
771
+ /** One box per item, side by side, sharing `width` with a two-column gap. */
772
+ const columnsOf = <A>(
773
+ items: ReadonlyArray<A>,
774
+ width: number,
775
+ draw: (item: A, width: number) => Array<Line>
776
+ ): Array<Line> => {
777
+ const each = Math.floor((width - 2 * (items.length - 1)) / items.length)
778
+ return beside(
779
+ items.map((item) => draw(item, each)),
780
+ items.map(() => each)
781
+ )
782
+ }
783
+
784
+ const expandedLane = (lane: TreeLane, width: number, now: number | undefined): Array<Line> =>
785
+ box(
786
+ width,
787
+ "running",
788
+ [
789
+ [span(`story ${lane.id}`, "bold"), span(` ${elapsed(lane.startedAt, now)}`)],
790
+ ...lane.stages.map((stage): Line => [span(stage)]),
791
+ [span(`tools (last ${lane.tools.length})`, "dim")],
792
+ ...lane.tools.map((tool): Line => [span(` ${tool}`, "dim")]),
793
+ [span(`${formatCount(lane.tokens)} tok · ${dollars(lane.costUsd)}`)]
794
+ ],
795
+ `${lane.id} · ${lane.executor ?? "(leasing)"}`
796
+ )
797
+
798
+ const barWidth = 10
799
+ const judgmentRows = 3
800
+
801
+ const meter = (fraction: number): ReadonlyArray<Span> => {
802
+ const filled = Math.round(Math.max(0, Math.min(1, fraction)) * barWidth)
803
+ return [span("█".repeat(filled), "judgment"), span("░".repeat(barWidth - filled), "dim")]
804
+ }
805
+
806
+ const judgmentOf = (state: TreeState, width: number): Array<Line> => {
807
+ const { verdict } = state
808
+ const rows: Array<Line> = [
809
+ ...(verdict?.dimensions ?? []).map(
810
+ (dimension): Line => [
811
+ span(dimension.id.padEnd(16)),
812
+ ...meter(dimension.max === 0 ? 0 : dimension.score / dimension.max),
813
+ span(` ${dimension.score}/${dimension.max}`)
814
+ ]
815
+ ),
816
+ ...state.judgments
817
+ .slice(-judgmentRows)
818
+ .map(
819
+ (judgment): Line => [
820
+ span(judgment.key.padEnd(16)),
821
+ ...meter(judgment.certainty),
822
+ span(` ${judgment.certainty.toFixed(2)} ${judgment.decision}`)
823
+ ]
824
+ ),
825
+ ...(verdict === undefined
826
+ ? []
827
+ : [
828
+ [
829
+ verdict.cleared
830
+ ? span("cleared → merge", "judgment")
831
+ : span(`${issueCount(verdict.issues)} → coder`, "judge")
832
+ ]
833
+ ])
834
+ ]
835
+ return box(
836
+ width,
837
+ "judgment",
838
+ rows.length === 0 ? [[span("no verdicts yet", "dim")]] : rows,
839
+ verdict === undefined ? "JUDGMENT" : `JUDGMENT · ${verdict.lane} r${verdict.round}`
840
+ )
841
+ }
842
+
843
+ const mainOf = (state: TreeState, width: number, view: TreeView): Array<Line> => {
844
+ const running = state.lanes.filter((lane) => lane.status === "running")
845
+ const orchestratorWidth = Math.min(46, width)
846
+ const stage = state.stages.at(-1) ?? (running.length > 0 ? "implement stories" : "idle")
847
+ const orchestrator = box(
848
+ orchestratorWidth,
849
+ "orchestrator",
850
+ [
851
+ ...(state.action === undefined
852
+ ? []
853
+ : [centre([span(state.action, "bold")], orchestratorWidth - 4)]),
854
+ [span("stage "), span(stage)],
855
+ [span(`stories ${chipsOf(state).length} elapsed ${elapsed(state.startedAt, state.now)}`)]
856
+ ],
857
+ state.title
858
+ )
859
+ const selected = running.find((lane) => lane.id === view.selected)
860
+ const shown = running.slice(0, maxLaneBoxes)
861
+ const executors = state.executors.slice(0, maxLaneBoxes + 1)
862
+ const lanes =
863
+ view.expanded && selected !== undefined
864
+ ? expandedLane(selected, width, state.now)
865
+ : view.mode === "executors"
866
+ ? executors.length === 0
867
+ ? [centre([span("no executors yet", "dim")], width)]
868
+ : columnsOf(executors, width, (executor, each) => executorBox(state, executor, each))
869
+ : shown.length === 0
870
+ ? [centre([span("no stories in flight", "dim")], width)]
871
+ : columnsOf(shown, width, (lane, each) =>
872
+ laneBox(lane, each, state.now, lane.id === view.selected)
873
+ )
874
+ return [
875
+ ...orchestrator.map((line) => centre(line, width)),
876
+ centre([span("•", "orchestrator")], width),
877
+ ...judgmentOf(state, width),
878
+ centre([span(`delegate to roster · ${running.length} running`)], width),
879
+ centre([span("▼", "dim")], width),
880
+ ...lanes,
881
+ [],
882
+ ...wrapChips(chipsOf(state).map(chipOf), width)
883
+ ]
884
+ }
885
+
886
+ /** A frame with the session log at `logCount` lines, or without it. */
887
+ const frameOf = (
888
+ state: TreeState,
889
+ options: TreeRenderOptions,
890
+ logCount: number | "none"
891
+ ): ReadonlyArray<string> => {
892
+ const { width } = options
893
+ const mainWidth = width - railWidth - 2
894
+ const chips = chipsOf(state)
895
+ const busy = state.executors.filter((executor) => heldBy(state, executor).length > 0).length
896
+ const count = (status: ChipStatus): number =>
897
+ chips.filter((chip) => chip.status === status).length
898
+ const lines: Array<Line> = [
899
+ centre(
900
+ [span("LLM4TS AGENT TREE", "bold"), span(" · "), span(state.title, "orchestrator")],
901
+ width
902
+ ),
903
+ [span("═".repeat(width), "dim")],
904
+ [],
905
+ ...beside([railOf(state), mainOf(state, mainWidth, options.view)], [railWidth, mainWidth]),
906
+ ...(logCount === "none"
907
+ ? []
908
+ : [[], ...box(width, "log", logOf(state, logCount), "session log")]),
909
+ [
910
+ span(
911
+ `stories [${count("done")}/${chips.length} done · ${count("running")} running · ${count("failed")} failed · ${count("waiting")} waiting] roster [${busy}/${state.executors.length} busy]`
912
+ )
913
+ ],
914
+ [
915
+ span(`tokens [${formatCount(state.tokens)}] cost [${dollars(state.costUsd)}] run [`),
916
+ state.ended === undefined ? span("live", "running") : span(state.ended, "bold"),
917
+ span("]")
918
+ ]
919
+ ]
920
+ return lines.map((line) => paint(fit(line, width), options.colour))
921
+ }
922
+
923
+ /**
924
+ * The tree at a width and, when `height` is given, no taller: the session
925
+ * log gives way first — fewer lines, down to one, then none — and only then
926
+ * is the frame cut, keeping its status lines, so a redraw never scrolls.
927
+ */
928
+ export const renderTree = (state: TreeState, options: TreeRenderOptions): ReadonlyArray<string> => {
929
+ const wanted = options.view.fullLog ? fullLogLines : logLines
930
+ const full = frameOf(state, options, wanted)
931
+ const { height } = options
932
+ if (height === undefined || full.length <= height) {
933
+ return full
934
+ }
935
+ for (let count = wanted - 1; count >= 1; count -= 1) {
936
+ const shorter = frameOf(state, options, count)
937
+ if (shorter.length <= height) {
938
+ return shorter
939
+ }
940
+ }
941
+ return fitToRows(frameOf(state, options, "none"), height)
942
+ }
943
+
944
+ /**
945
+ * A frame cut to a terminal's height: the top of the tree and its two status
946
+ * lines, so a full-screen redraw never scrolls.
947
+ */
948
+ const fitToRows = (
949
+ lines: ReadonlyArray<string>,
950
+ rows: number | undefined
951
+ ): ReadonlyArray<string> =>
952
+ rows === undefined || rows < 4 || lines.length <= rows
953
+ ? lines
954
+ : [...lines.slice(0, rows - 2), ...lines.slice(-2)]