@llm4ts/runner 2.40.0 → 2.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/AgentTree.d.ts +59 -8
- package/dist/AgentTree.d.ts.map +1 -1
- package/dist/AgentTree.js +462 -101
- package/dist/AgentTree.js.map +1 -1
- package/dist/FlowRunner.d.ts.map +1 -1
- package/dist/FlowRunner.js +7 -3
- package/dist/FlowRunner.js.map +1 -1
- package/dist/Terminal.d.ts.map +1 -1
- package/dist/Terminal.js +13 -0
- package/dist/Terminal.js.map +1 -1
- package/package.json +3 -3
- package/src/AgentTree.ts +630 -124
- package/src/FlowRunner.ts +11 -3
- package/src/Terminal.ts +13 -0
package/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 {
|
|
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 = (
|
|
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) => ({
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
343
|
+
case "judge": {
|
|
344
|
+
const seat = {
|
|
260
345
|
...held,
|
|
261
346
|
judge: {
|
|
262
347
|
...held.judge,
|
|
263
|
-
executor:
|
|
264
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
:
|
|
296
|
-
...open
|
|
297
|
-
|
|
298
|
-
|
|
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) => ({
|
|
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(
|
|
444
|
-
|
|
445
|
-
|
|
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
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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 === "
|
|
741
|
-
? {
|
|
742
|
-
: {
|
|
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
|
|
747
|
-
* cycles the tail's role, escape closes, `e` switches lanes and
|
|
748
|
-
* `l` the full log, `q` quits. Keys it does not
|
|
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 === "
|
|
753
|
-
const
|
|
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
|
|
756
|
-
|
|
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.
|
|
982
|
+
return view.mode === "executors" || chosen() === undefined
|
|
769
983
|
? view
|
|
770
984
|
: { ...view, expanded: !view.expanded }
|
|
771
|
-
case "t":
|
|
772
|
-
|
|
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 === "
|
|
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
|
-
|
|
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
|
-
|
|
1029
|
-
|
|
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
|
-
|
|
1034
|
-
...
|
|
1035
|
-
|
|
1036
|
-
...lane.
|
|
1037
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
1144
|
-
const
|
|
1145
|
-
const
|
|
1146
|
-
|
|
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.
|
|
1150
|
-
?
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
1160
|
-
|
|
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
|
-
|
|
1167
|
-
|
|
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
|
|
1240
|
-
*
|
|
1241
|
-
*
|
|
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
|
/**
|