@llm4ts/runner 2.40.1 → 2.42.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/AgentTree.d.ts +70 -8
- package/dist/AgentTree.d.ts.map +1 -1
- package/dist/AgentTree.js +516 -103
- package/dist/AgentTree.js.map +1 -1
- package/dist/FlowRunner.d.ts.map +1 -1
- package/dist/FlowRunner.js +9 -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 +709 -126
- package/src/FlowRunner.ts +13 -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,53 @@ 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
|
+
/** Tasks running beside the lane's coder, each on a coder of its own (ADR 0034). */
|
|
70
|
+
readonly taskCoders: ReadonlyArray<TreeTaskCoder>
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A task running beside a story's coder, in its own worktree (ADR 0034). */
|
|
74
|
+
export interface TreeTaskCoder {
|
|
75
|
+
readonly index: number
|
|
76
|
+
readonly count: number
|
|
77
|
+
readonly title: string
|
|
78
|
+
readonly executor: string | undefined
|
|
79
|
+
readonly clone: number | undefined
|
|
80
|
+
readonly since: number
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** One task of a lane's plan, as the checklist shows it. */
|
|
84
|
+
export interface TreeTask {
|
|
85
|
+
readonly index: number
|
|
86
|
+
readonly title: string
|
|
87
|
+
readonly done: boolean
|
|
88
|
+
readonly satisfies?: ReadonlyArray<number>
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A sub-agent a harness spawned inside a lane, observed through its tool calls (ADR 0033). */
|
|
92
|
+
export interface TreeChild {
|
|
93
|
+
readonly id: string
|
|
94
|
+
readonly tool: string
|
|
95
|
+
readonly args: string
|
|
96
|
+
readonly since: number
|
|
97
|
+
readonly lastTool: string | undefined
|
|
98
|
+
readonly ended: boolean
|
|
51
99
|
}
|
|
52
100
|
|
|
53
101
|
/** Work under way: a model call, a gate or setup command, git, a merge, a wait. */
|
|
@@ -87,6 +135,8 @@ export interface TreeLease {
|
|
|
87
135
|
readonly executor: string
|
|
88
136
|
readonly role: string
|
|
89
137
|
readonly label: string | undefined
|
|
138
|
+
/** Which clone took the slot, when the lease carried one (ADR 0033). */
|
|
139
|
+
readonly clone: number | undefined
|
|
90
140
|
}
|
|
91
141
|
|
|
92
142
|
/** The judge seat: who sits in it, and how often it was asked. */
|
|
@@ -94,6 +144,8 @@ export interface TreeJudge {
|
|
|
94
144
|
readonly executor: string | undefined
|
|
95
145
|
readonly reviews: number
|
|
96
146
|
readonly verdicts: number
|
|
147
|
+
/** Reviews and verdicts that ran on the story's own executor: not independent (ADR 0033). */
|
|
148
|
+
readonly borrowed: number
|
|
97
149
|
}
|
|
98
150
|
|
|
99
151
|
/** The latest story verdict (`StoryJudged`). */
|
|
@@ -146,6 +198,8 @@ export interface TreeState {
|
|
|
146
198
|
readonly running: ReadonlyArray<TreeWork>
|
|
147
199
|
/** A lane with no event for this long, and no tool running, is marked idle. */
|
|
148
200
|
readonly idleAfterMs: number
|
|
201
|
+
/** The lane that published last, which the detail box follows by default (ADR 0033). */
|
|
202
|
+
readonly lastChanged: string | undefined
|
|
149
203
|
}
|
|
150
204
|
|
|
151
205
|
export interface TreeOptions {
|
|
@@ -182,7 +236,7 @@ export const emptyTree = (options: TreeOptions = {}): TreeState => ({
|
|
|
182
236
|
executors: [],
|
|
183
237
|
leases: [],
|
|
184
238
|
exclusions: [],
|
|
185
|
-
judge: { executor: undefined, reviews: 0, verdicts: 0 },
|
|
239
|
+
judge: { executor: undefined, reviews: 0, verdicts: 0, borrowed: 0 },
|
|
186
240
|
verdict: undefined,
|
|
187
241
|
judgments: [],
|
|
188
242
|
log: [],
|
|
@@ -191,7 +245,8 @@ export const emptyTree = (options: TreeOptions = {}): TreeState => ({
|
|
|
191
245
|
ended: undefined,
|
|
192
246
|
time: { model: 0, tools: 0, gates: 0, merge: 0, wait: 0 },
|
|
193
247
|
running: [],
|
|
194
|
-
idleAfterMs: options.idleAfterMs ?? defaultIdleAfterMs
|
|
248
|
+
idleAfterMs: options.idleAfterMs ?? defaultIdleAfterMs,
|
|
249
|
+
lastChanged: undefined
|
|
195
250
|
})
|
|
196
251
|
|
|
197
252
|
const costOf = (model: string | undefined, usage: TokenUsage): number =>
|
|
@@ -214,6 +269,40 @@ const updateLane = (
|
|
|
214
269
|
lanes: state.lanes.map((lane) => (lane.id === id ? update(lane) : lane))
|
|
215
270
|
})
|
|
216
271
|
|
|
272
|
+
/** A wait label that is a harness pausing itself, not the roster or a gate. */
|
|
273
|
+
const isPause = (label: string): boolean => /^pi (retry|compaction)$/u.test(label)
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* A delegate tool that starts a sub-agent: Claude's Agent (Task before
|
|
277
|
+
* 2.1.63), Codex's spawn_agent, Gemini's agents. Codex's wait, send_input,
|
|
278
|
+
* resume_agent and close_agent act on one that exists.
|
|
279
|
+
*/
|
|
280
|
+
const spawningTools =
|
|
281
|
+
/^(agent|task|spawn_agent|codebase_investigator|generalist|cli_help|browser_agent)$/iu
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* A delegate call whose end ends the sub-agent: a synchronous one (Agent,
|
|
285
|
+
* a Gemini agent) or Codex's wait and close; spawn_agent returns at once
|
|
286
|
+
* with the sub-agent still running (ADR 0033).
|
|
287
|
+
*/
|
|
288
|
+
const endsChild = (tool: string): boolean => !/^(spawn_agent|send_input|resume_agent)$/iu.test(tool)
|
|
289
|
+
|
|
290
|
+
const newestOpenChild = (children: ReadonlyArray<TreeChild>): number => {
|
|
291
|
+
for (let index = children.length - 1; index >= 0; index -= 1) {
|
|
292
|
+
if (children[index]?.ended === false) {
|
|
293
|
+
return index
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return -1
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const endNewestChild = (children: ReadonlyArray<TreeChild>): ReadonlyArray<TreeChild> => {
|
|
300
|
+
const index = newestOpenChild(children)
|
|
301
|
+
return index < 0
|
|
302
|
+
? children
|
|
303
|
+
: children.map((child, position) => (position === index ? { ...child, ended: true } : child))
|
|
304
|
+
}
|
|
305
|
+
|
|
217
306
|
const withoutLast = <A>(items: ReadonlyArray<A>, item: A): ReadonlyArray<A> => {
|
|
218
307
|
const index = items.lastIndexOf(item)
|
|
219
308
|
return index < 0 ? items : [...items.slice(0, index), ...items.slice(index + 1)]
|
|
@@ -240,35 +329,45 @@ const withExecutor = (state: TreeState, executor: string): TreeState =>
|
|
|
240
329
|
const rosterLog = (state: TreeState, at: number, what: string): TreeState =>
|
|
241
330
|
logged(state, { at, source: "roster", who: "roster", what })
|
|
242
331
|
|
|
243
|
-
const reduceLease = (
|
|
332
|
+
const reduceLease = (
|
|
333
|
+
state: TreeState,
|
|
334
|
+
at: number,
|
|
335
|
+
lease: TreeLease,
|
|
336
|
+
borrowed: boolean
|
|
337
|
+
): TreeState => {
|
|
244
338
|
const held = { ...withExecutor(state, lease.executor), leases: [...state.leases, lease] }
|
|
339
|
+
const who = cloneName(lease.executor, lease.clone)
|
|
340
|
+
const forLabel = lease.label === undefined ? "" : ` · ${lease.label}`
|
|
245
341
|
switch (lease.role) {
|
|
246
342
|
case "coder": {
|
|
247
343
|
const lane = held.lanes.find((candidate) => candidate.id === lease.label)
|
|
248
344
|
const placed =
|
|
249
345
|
lane === undefined
|
|
250
346
|
? held
|
|
251
|
-
: updateLane(held, lane.id, (open) => ({
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
)
|
|
347
|
+
: updateLane(held, lane.id, (open) => ({
|
|
348
|
+
...open,
|
|
349
|
+
executor: lease.executor,
|
|
350
|
+
clone: lease.clone
|
|
351
|
+
}))
|
|
352
|
+
return rosterLog(placed, at, `${who} → coder${forLabel}`)
|
|
257
353
|
}
|
|
258
354
|
case "reviewer":
|
|
259
|
-
|
|
355
|
+
case "judge": {
|
|
356
|
+
const seat = {
|
|
260
357
|
...held,
|
|
261
358
|
judge: {
|
|
262
359
|
...held.judge,
|
|
263
|
-
executor:
|
|
264
|
-
|
|
360
|
+
executor:
|
|
361
|
+
lease.role === "judge" ? lease.executor : (held.judge.executor ?? lease.executor),
|
|
362
|
+
reviews: held.judge.reviews + (lease.role === "reviewer" ? 1 : 0),
|
|
363
|
+
verdicts: held.judge.verdicts + (lease.role === "judge" ? 1 : 0),
|
|
364
|
+
borrowed: held.judge.borrowed + (borrowed ? 1 : 0)
|
|
265
365
|
}
|
|
266
366
|
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
}
|
|
367
|
+
return borrowed
|
|
368
|
+
? rosterLog(seat, at, `${lease.role} borrowed from own executor ${who}${forLabel}`)
|
|
369
|
+
: seat
|
|
370
|
+
}
|
|
272
371
|
default:
|
|
273
372
|
return held
|
|
274
373
|
}
|
|
@@ -292,11 +391,14 @@ const reduceEvent = (state: TreeState, at: number, event: FlowEvent): TreeState
|
|
|
292
391
|
const current =
|
|
293
392
|
lane === undefined
|
|
294
393
|
? named
|
|
295
|
-
:
|
|
296
|
-
...open
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
394
|
+
: {
|
|
395
|
+
...updateLane(named, lane.id, (open) => ({
|
|
396
|
+
...open,
|
|
397
|
+
lastEventAt: at,
|
|
398
|
+
...(executor !== undefined && open.executor !== executor ? { executor } : {})
|
|
399
|
+
})),
|
|
400
|
+
lastChanged: lane.id
|
|
401
|
+
}
|
|
300
402
|
switch (event._tag) {
|
|
301
403
|
case "StageStarted": {
|
|
302
404
|
const story = storyStage.exec(event.stage)?.[1]
|
|
@@ -315,11 +417,18 @@ const reduceEvent = (state: TreeState, at: number, event: FlowEvent): TreeState
|
|
|
315
417
|
activity: undefined,
|
|
316
418
|
turns: [],
|
|
317
419
|
gatesMs: 0,
|
|
318
|
-
running: []
|
|
420
|
+
running: [],
|
|
421
|
+
clone: undefined,
|
|
422
|
+
tasks: [],
|
|
423
|
+
task: undefined,
|
|
424
|
+
children: [],
|
|
425
|
+
pause: undefined,
|
|
426
|
+
taskCoders: []
|
|
319
427
|
}
|
|
320
428
|
return {
|
|
321
429
|
...current,
|
|
322
|
-
lanes: [...current.lanes.filter((open) => open.id !== story), fresh]
|
|
430
|
+
lanes: [...current.lanes.filter((open) => open.id !== story), fresh],
|
|
431
|
+
lastChanged: story
|
|
323
432
|
}
|
|
324
433
|
}
|
|
325
434
|
if (lane !== undefined) {
|
|
@@ -328,7 +437,10 @@ const reduceEvent = (state: TreeState, at: number, event: FlowEvent): TreeState
|
|
|
328
437
|
stages: [...open.stages, event.stage]
|
|
329
438
|
}))
|
|
330
439
|
}
|
|
331
|
-
|
|
440
|
+
// A lane that is not a story's (a task run aside, ADR 0034) is not the run's either.
|
|
441
|
+
return event.lane === undefined
|
|
442
|
+
? { ...current, stages: [...current.stages, event.stage] }
|
|
443
|
+
: current
|
|
332
444
|
}
|
|
333
445
|
case "StageCompleted":
|
|
334
446
|
case "StageFailed": {
|
|
@@ -353,7 +465,9 @@ const reduceEvent = (state: TreeState, at: number, event: FlowEvent): TreeState
|
|
|
353
465
|
stages: withoutLast(open.stages, event.stage)
|
|
354
466
|
}))
|
|
355
467
|
}
|
|
356
|
-
return
|
|
468
|
+
return event.lane === undefined
|
|
469
|
+
? { ...current, stages: withoutLast(current.stages, event.stage) }
|
|
470
|
+
: current
|
|
357
471
|
}
|
|
358
472
|
case "Info": {
|
|
359
473
|
// A story's own milestones are `story <id>: …` (resuming, merged,
|
|
@@ -402,7 +516,15 @@ const reduceEvent = (state: TreeState, at: number, event: FlowEvent): TreeState
|
|
|
402
516
|
: event.kind === "model" && event.label === "coder"
|
|
403
517
|
? { activity: undefined, turns: [...open.turns, event.ms] }
|
|
404
518
|
: {}),
|
|
405
|
-
gatesMs: open.gatesMs + (event.kind === "gate" ? event.ms : 0)
|
|
519
|
+
gatesMs: open.gatesMs + (event.kind === "gate" ? event.ms : 0),
|
|
520
|
+
// The delegate call ended: its sub-agent is done — unless it was
|
|
521
|
+
// only spawned, and runs on until a wait or close.
|
|
522
|
+
...(event.kind === "tool" && event.category === "delegate" && endsChild(event.label)
|
|
523
|
+
? { children: endNewestChild(open.children) }
|
|
524
|
+
: {}),
|
|
525
|
+
...(event.kind === "wait" && open.pause?.label === event.label
|
|
526
|
+
? { pause: undefined }
|
|
527
|
+
: {})
|
|
406
528
|
}))
|
|
407
529
|
}
|
|
408
530
|
case "Began": {
|
|
@@ -411,7 +533,14 @@ const reduceEvent = (state: TreeState, at: number, event: FlowEvent): TreeState
|
|
|
411
533
|
? event.lane === undefined
|
|
412
534
|
? { ...current, running: [...current.running, work] }
|
|
413
535
|
: current
|
|
414
|
-
: updateLane(current, lane.id, (open) => ({
|
|
536
|
+
: updateLane(current, lane.id, (open) => ({
|
|
537
|
+
...open,
|
|
538
|
+
running: [...open.running, work],
|
|
539
|
+
// A harness pause (`pi retry`, `pi compaction`) is the lane's state while it lasts.
|
|
540
|
+
...(event.kind === "wait" && isPause(event.label)
|
|
541
|
+
? { pause: { label: event.label, since: at } }
|
|
542
|
+
: {})
|
|
543
|
+
}))
|
|
415
544
|
}
|
|
416
545
|
case "StoryJudged": {
|
|
417
546
|
const verdict: TreeVerdict = {
|
|
@@ -440,11 +569,12 @@ const reduceEvent = (state: TreeState, at: number, event: FlowEvent): TreeState
|
|
|
440
569
|
]
|
|
441
570
|
}
|
|
442
571
|
case "ExecutorLeased":
|
|
443
|
-
return reduceLease(
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
label: event.label
|
|
447
|
-
|
|
572
|
+
return reduceLease(
|
|
573
|
+
current,
|
|
574
|
+
at,
|
|
575
|
+
{ executor: event.executor, role: event.role, label: event.label, clone: event.clone },
|
|
576
|
+
event.borrowed === true
|
|
577
|
+
)
|
|
448
578
|
case "ExecutorReleased": {
|
|
449
579
|
const index = current.leases.findIndex(
|
|
450
580
|
(lease) =>
|
|
@@ -486,15 +616,115 @@ const reduceEvent = (state: TreeState, at: number, event: FlowEvent): TreeState
|
|
|
486
616
|
at,
|
|
487
617
|
`${event.label ?? "the run"}: ${event.role} leaves ${event.from} · ${event.reason}`
|
|
488
618
|
)
|
|
489
|
-
case "ToolUse":
|
|
619
|
+
case "ToolUse": {
|
|
620
|
+
if (lane === undefined) {
|
|
621
|
+
return current
|
|
622
|
+
}
|
|
623
|
+
const text = `${event.tool} ${event.args}`
|
|
624
|
+
return updateLane(current, lane.id, (open) => {
|
|
625
|
+
// A call inside a sub-agent lands on the newest open child; without
|
|
626
|
+
// one (an older harness, a lost parent) it is the lane's as before.
|
|
627
|
+
const child = event.parent === undefined ? -1 : newestOpenChild(open.children)
|
|
628
|
+
if (child >= 0) {
|
|
629
|
+
return {
|
|
630
|
+
...open,
|
|
631
|
+
children: open.children.map((candidate, index) =>
|
|
632
|
+
index === child ? { ...candidate, lastTool: text } : candidate
|
|
633
|
+
)
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
const delegated =
|
|
637
|
+
toolCategory(event.tool, event.args) === "delegate" && spawningTools.test(event.tool)
|
|
638
|
+
? [
|
|
639
|
+
...open.children,
|
|
640
|
+
{
|
|
641
|
+
id: `${open.id}:${open.children.length + 1}`,
|
|
642
|
+
tool: event.tool,
|
|
643
|
+
args: event.args,
|
|
644
|
+
since: at,
|
|
645
|
+
lastTool: undefined,
|
|
646
|
+
ended: false
|
|
647
|
+
}
|
|
648
|
+
]
|
|
649
|
+
: open.children
|
|
650
|
+
return {
|
|
651
|
+
...open,
|
|
652
|
+
activity: { text, since: at, tool: true },
|
|
653
|
+
lastTool: text,
|
|
654
|
+
tools: [...open.tools, text].slice(-expandedTools),
|
|
655
|
+
children: delegated
|
|
656
|
+
}
|
|
657
|
+
})
|
|
658
|
+
}
|
|
659
|
+
case "TasksPlanned":
|
|
490
660
|
return lane === undefined
|
|
491
661
|
? current
|
|
492
662
|
: updateLane(current, lane.id, (open) => ({
|
|
493
663
|
...open,
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
664
|
+
tasks: event.tasks.map((task, position) => ({
|
|
665
|
+
index: position + 1,
|
|
666
|
+
title: task.title,
|
|
667
|
+
done: task.completed,
|
|
668
|
+
...(task.satisfies === undefined ? {} : { satisfies: task.satisfies })
|
|
669
|
+
}))
|
|
497
670
|
}))
|
|
671
|
+
case "TaskStarted": {
|
|
672
|
+
if (lane === undefined) {
|
|
673
|
+
return current
|
|
674
|
+
}
|
|
675
|
+
const satisfies = event.satisfies === undefined ? {} : { satisfies: event.satisfies }
|
|
676
|
+
const listed = (open: TreeLane): ReadonlyArray<TreeTask> =>
|
|
677
|
+
open.tasks.some((task) => task.index === event.index)
|
|
678
|
+
? open.tasks
|
|
679
|
+
: [
|
|
680
|
+
...open.tasks,
|
|
681
|
+
{ index: event.index, title: event.title, done: false, ...satisfies }
|
|
682
|
+
].sort((left, right) => left.index - right.index)
|
|
683
|
+
if (event.parallel === true) {
|
|
684
|
+
return updateLane(current, lane.id, (open) => ({
|
|
685
|
+
...open,
|
|
686
|
+
tasks: listed(open),
|
|
687
|
+
taskCoders: [
|
|
688
|
+
...open.taskCoders.filter((coder) => coder.index !== event.index),
|
|
689
|
+
{
|
|
690
|
+
index: event.index,
|
|
691
|
+
count: event.count,
|
|
692
|
+
title: event.title,
|
|
693
|
+
executor: event.executor,
|
|
694
|
+
clone: event.clone,
|
|
695
|
+
since: at
|
|
696
|
+
}
|
|
697
|
+
]
|
|
698
|
+
}))
|
|
699
|
+
}
|
|
700
|
+
return updateLane(current, lane.id, (open) => ({
|
|
701
|
+
...open,
|
|
702
|
+
task: { index: event.index, count: event.count, title: event.title, ...satisfies },
|
|
703
|
+
tasks: listed(open),
|
|
704
|
+
// Back with the story's coder: no longer beside it.
|
|
705
|
+
taskCoders: open.taskCoders.filter((coder) => coder.index !== event.index)
|
|
706
|
+
}))
|
|
707
|
+
}
|
|
708
|
+
case "TaskCompleted":
|
|
709
|
+
return lane === undefined
|
|
710
|
+
? current
|
|
711
|
+
: updateLane(current, lane.id, (open) => ({
|
|
712
|
+
...open,
|
|
713
|
+
task: open.task?.index === event.index ? undefined : open.task,
|
|
714
|
+
taskCoders: open.taskCoders.filter((coder) => coder.index !== event.index),
|
|
715
|
+
tasks: open.tasks.map((task) =>
|
|
716
|
+
task.index === event.index ? { ...task, done: true } : task
|
|
717
|
+
)
|
|
718
|
+
}))
|
|
719
|
+
case "StoryStatusChanged":
|
|
720
|
+
return {
|
|
721
|
+
...current,
|
|
722
|
+
stories: current.stories.some((story) => story.id === event.id)
|
|
723
|
+
? current.stories.map((story) =>
|
|
724
|
+
story.id === event.id ? { ...story, status: event.status } : story
|
|
725
|
+
)
|
|
726
|
+
: [...current.stories, { id: event.id, status: event.status }]
|
|
727
|
+
}
|
|
498
728
|
case "TokensUsed": {
|
|
499
729
|
const tokens = event.usage.total
|
|
500
730
|
const costUsd = costOf(event.model, event.usage)
|
|
@@ -698,12 +928,12 @@ const paint = (line: Line, colour: boolean): string =>
|
|
|
698
928
|
)
|
|
699
929
|
.join("")
|
|
700
930
|
|
|
701
|
-
export type TreeMode = "lanes" | "executors"
|
|
931
|
+
export type TreeMode = "lanes" | "executors" | "boards"
|
|
702
932
|
|
|
703
933
|
/** What the reader chose to look at; the run's state is separate. */
|
|
704
934
|
export interface TreeView {
|
|
705
935
|
readonly mode: TreeMode
|
|
706
|
-
/** The selected lane's story id, or in the executors view the executor. */
|
|
936
|
+
/** The selected lane's story id, or in the executors view the executor; unset follows the lane that moved last. */
|
|
707
937
|
readonly selected: string | undefined
|
|
708
938
|
readonly expanded: boolean
|
|
709
939
|
readonly fullLog: boolean
|
|
@@ -713,6 +943,8 @@ export interface TreeView {
|
|
|
713
943
|
readonly tailRole: string | undefined
|
|
714
944
|
/** How many lines the tail is scrolled back from its end (page up/down). */
|
|
715
945
|
readonly tailBack: number
|
|
946
|
+
/** The first row of the agent list shown, when the list is taller than its room. */
|
|
947
|
+
readonly scroll: number
|
|
716
948
|
}
|
|
717
949
|
|
|
718
950
|
export const initialView: TreeView = {
|
|
@@ -722,12 +954,27 @@ export const initialView: TreeView = {
|
|
|
722
954
|
fullLog: false,
|
|
723
955
|
tail: false,
|
|
724
956
|
tailRole: undefined,
|
|
725
|
-
tailBack: 0
|
|
957
|
+
tailBack: 0,
|
|
958
|
+
scroll: 0
|
|
726
959
|
}
|
|
727
960
|
|
|
728
961
|
const runningLanes = (state: TreeState): ReadonlyArray<TreeLane> =>
|
|
729
962
|
state.lanes.filter((lane) => lane.status === "running")
|
|
730
963
|
|
|
964
|
+
/**
|
|
965
|
+
* The lane the detail box is about: the selection, else the lane that moved
|
|
966
|
+
* last, else the first running one — so an unattended screen still shows
|
|
967
|
+
* what is happening (ADR 0033).
|
|
968
|
+
*/
|
|
969
|
+
export const selectedLane = (state: TreeState, view: TreeView): TreeLane | undefined => {
|
|
970
|
+
const running = runningLanes(state)
|
|
971
|
+
return (
|
|
972
|
+
running.find((lane) => lane.id === view.selected) ??
|
|
973
|
+
running.find((lane) => lane.id === state.lastChanged) ??
|
|
974
|
+
running[0]
|
|
975
|
+
)
|
|
976
|
+
}
|
|
977
|
+
|
|
731
978
|
const tailRoles: ReadonlyArray<string | undefined> = [undefined, "coder", "reviewer", "judge"]
|
|
732
979
|
const tailPage = 10
|
|
733
980
|
|
|
@@ -737,24 +984,31 @@ export const tailTargetOf = (
|
|
|
737
984
|
): { readonly lane: string } | { readonly executor: string } | undefined =>
|
|
738
985
|
view.selected === undefined
|
|
739
986
|
? undefined
|
|
740
|
-
: view.mode === "
|
|
741
|
-
? {
|
|
742
|
-
: {
|
|
987
|
+
: view.mode === "executors"
|
|
988
|
+
? { executor: view.selected }
|
|
989
|
+
: { lane: view.selected }
|
|
743
990
|
|
|
744
991
|
/**
|
|
745
992
|
* 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
|
|
993
|
+
* the executors view), enter expands its detail, `t` tails its transcript,
|
|
994
|
+
* `r` cycles the tail's role, escape closes, `e` switches lanes and
|
|
995
|
+
* executors, `b` the boards, `l` the full log, `q` quits. Keys it does not
|
|
996
|
+
* know return the same view.
|
|
749
997
|
*/
|
|
750
998
|
export const onTreeKey = (view: TreeView, key: string, state: TreeState): TreeView | "quit" => {
|
|
751
999
|
const choices =
|
|
752
|
-
view.mode === "
|
|
753
|
-
const
|
|
1000
|
+
view.mode === "executors" ? state.executors : runningLanes(state).map((lane) => lane.id)
|
|
1001
|
+
const current = view.mode === "executors" ? view.selected : selectedLane(state, view)?.id
|
|
1002
|
+
const index = choices.findIndex((id) => id === current)
|
|
754
1003
|
const select = (next: number): TreeView => {
|
|
755
|
-
const
|
|
756
|
-
|
|
1004
|
+
const row = Math.max(0, Math.min(choices.length - 1, next))
|
|
1005
|
+
const id = choices[row]
|
|
1006
|
+
return id === undefined
|
|
1007
|
+
? view
|
|
1008
|
+
: { ...view, selected: id, scroll: row < view.scroll ? row : view.scroll }
|
|
757
1009
|
}
|
|
1010
|
+
const chosen = (): string | undefined =>
|
|
1011
|
+
view.mode === "executors" ? view.selected : selectedLane(state, view)?.id
|
|
758
1012
|
switch (key) {
|
|
759
1013
|
case "q":
|
|
760
1014
|
return "quit"
|
|
@@ -765,13 +1019,15 @@ export const onTreeKey = (view: TreeView, key: string, state: TreeState): TreeVi
|
|
|
765
1019
|
case "left":
|
|
766
1020
|
return select(index < 0 ? 0 : index - 1)
|
|
767
1021
|
case "enter":
|
|
768
|
-
return view.
|
|
1022
|
+
return view.mode === "executors" || chosen() === undefined
|
|
769
1023
|
? view
|
|
770
1024
|
: { ...view, expanded: !view.expanded }
|
|
771
|
-
case "t":
|
|
772
|
-
|
|
1025
|
+
case "t": {
|
|
1026
|
+
const id = chosen()
|
|
1027
|
+
return id === undefined
|
|
773
1028
|
? view
|
|
774
|
-
: { ...view, tail: !view.tail, expanded: false, tailBack: 0 }
|
|
1029
|
+
: { ...view, selected: id, tail: !view.tail, expanded: false, tailBack: 0 }
|
|
1030
|
+
}
|
|
775
1031
|
case "pageup":
|
|
776
1032
|
return view.tail ? { ...view, tailBack: view.tailBack + tailPage } : view
|
|
777
1033
|
case "pagedown":
|
|
@@ -788,9 +1044,17 @@ export const onTreeKey = (view: TreeView, key: string, state: TreeState): TreeVi
|
|
|
788
1044
|
case "e":
|
|
789
1045
|
return {
|
|
790
1046
|
...view,
|
|
791
|
-
mode: view.mode === "
|
|
1047
|
+
mode: view.mode === "executors" ? "lanes" : "executors",
|
|
792
1048
|
selected: undefined,
|
|
793
1049
|
expanded: false,
|
|
1050
|
+
tail: false,
|
|
1051
|
+
scroll: 0
|
|
1052
|
+
}
|
|
1053
|
+
case "b":
|
|
1054
|
+
return {
|
|
1055
|
+
...view,
|
|
1056
|
+
mode: view.mode === "boards" ? "lanes" : "boards",
|
|
1057
|
+
expanded: false,
|
|
794
1058
|
tail: false
|
|
795
1059
|
}
|
|
796
1060
|
case "l":
|
|
@@ -811,7 +1075,8 @@ export interface TreeRenderOptions {
|
|
|
811
1075
|
}
|
|
812
1076
|
|
|
813
1077
|
const railWidth = 26
|
|
814
|
-
|
|
1078
|
+
/** The agent list never shrinks below this many rows for the log's sake. */
|
|
1079
|
+
const minListRows = 3
|
|
815
1080
|
|
|
816
1081
|
const counter = (label: string, value: number): Line => [
|
|
817
1082
|
span(`${label.padEnd(15)}${String(value).padStart(5)}`)
|
|
@@ -833,40 +1098,8 @@ const railOf = (state: TreeState): Array<Line> =>
|
|
|
833
1098
|
],
|
|
834
1099
|
[],
|
|
835
1100
|
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)
|
|
1101
|
+
counter("verdicts", state.judge.verdicts),
|
|
1102
|
+
...(state.judge.borrowed === 0 ? [] : [counter("borrowed", state.judge.borrowed)])
|
|
870
1103
|
])
|
|
871
1104
|
|
|
872
1105
|
/** Work under way as a card names it: the command itself, the call, the wait. */
|
|
@@ -1025,18 +1258,347 @@ const columnsOf = <A>(
|
|
|
1025
1258
|
)
|
|
1026
1259
|
}
|
|
1027
1260
|
|
|
1028
|
-
|
|
1029
|
-
|
|
1261
|
+
/** `◐`/`⏸`: the lane's status at a glance, as `statusLine` grades it. */
|
|
1262
|
+
const statusMark = (lane: TreeLane, now: number | undefined, idleAfterMs: number): Span => {
|
|
1263
|
+
const line = statusLine(lane, now, idleAfterMs)
|
|
1264
|
+
const text = line[0]?.text ?? ""
|
|
1265
|
+
return span(text.startsWith("◐") ? "◐" : "⏸", line[0]?.style)
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
/** `3/5 wire session cookie`, or `2/5 tasks` between tasks, or nothing before the plan. */
|
|
1269
|
+
const taskText = (lane: TreeLane): string =>
|
|
1270
|
+
lane.task !== undefined
|
|
1271
|
+
? `${lane.task.index}/${lane.task.count} ${lane.task.title}`
|
|
1272
|
+
: lane.tasks.length > 0
|
|
1273
|
+
? `${lane.tasks.filter((task) => task.done).length}/${lane.tasks.length} tasks`
|
|
1274
|
+
: ""
|
|
1275
|
+
|
|
1276
|
+
/** `head` then `tail` in `width`: a long head is cut, the tail (a timer, a count) never is. */
|
|
1277
|
+
const headThenTail = (head: Line, tail: Span, width: number): Line => {
|
|
1278
|
+
const room = width - [...tail.text].length
|
|
1279
|
+
return lengthOf(head) <= room ? [...head, tail] : [...fit(head, Math.max(1, room)), tail]
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
/** The one line a running lane is in the agent list (ADR 0033). */
|
|
1283
|
+
const laneLine = (
|
|
1284
|
+
lane: TreeLane,
|
|
1285
|
+
width: number,
|
|
1286
|
+
now: number | undefined,
|
|
1287
|
+
selected: boolean,
|
|
1288
|
+
idleAfterMs: number
|
|
1289
|
+
): Line => {
|
|
1290
|
+
const task = taskText(lane)
|
|
1291
|
+
const stage = lane.stages.at(-1) ?? "starting"
|
|
1292
|
+
const head: Line = [
|
|
1293
|
+
span(selected ? "▸ " : " "),
|
|
1294
|
+
statusMark(lane, now, idleAfterMs),
|
|
1295
|
+
span(" "),
|
|
1296
|
+
span(lane.id, "bold"),
|
|
1297
|
+
span(` ${cloneName(lane.executor ?? "(leasing)", lane.clone)}`),
|
|
1298
|
+
...(task.length === 0 ? [] : [span(" · "), span(task)]),
|
|
1299
|
+
// A task is a stage of its own name: say it once.
|
|
1300
|
+
...(stage === lane.task?.title ? [] : [span(" · "), span(stage, "dim")]),
|
|
1301
|
+
...(lane.pause === undefined
|
|
1302
|
+
? []
|
|
1303
|
+
: [span(" · "), span(`⏸ ${lane.pause.label} ${elapsed(lane.pause.since, now)}`, "judge")])
|
|
1304
|
+
]
|
|
1305
|
+
return headThenTail(
|
|
1306
|
+
head,
|
|
1307
|
+
span(` · ${elapsed(lane.startedAt, now)} · ${formatCount(lane.tokens)} tok`),
|
|
1308
|
+
width
|
|
1309
|
+
)
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
/** A sub-agent the lane's harness spawned, indented under it. */
|
|
1313
|
+
/** A task running beside the lane's coder, indented under it (ADR 0034). */
|
|
1314
|
+
const taskCoderLine = (coder: TreeTaskCoder, width: number, now: number | undefined): Line =>
|
|
1315
|
+
headThenTail(
|
|
1316
|
+
[
|
|
1317
|
+
span(" └ "),
|
|
1318
|
+
span("task coder ", "dim"),
|
|
1319
|
+
span(`${cloneName(coder.executor ?? "(leasing)", coder.clone)} · `),
|
|
1320
|
+
span(`${coder.index}/${coder.count} ${coder.title}`)
|
|
1321
|
+
],
|
|
1322
|
+
span(` · ${elapsed(coder.since, now)}`, "dim"),
|
|
1323
|
+
width
|
|
1324
|
+
)
|
|
1325
|
+
|
|
1326
|
+
const childLine = (child: TreeChild, width: number, now: number | undefined): Line =>
|
|
1327
|
+
headThenTail(
|
|
1328
|
+
[
|
|
1329
|
+
span(" └ "),
|
|
1330
|
+
span("sub-agent ", "dim"),
|
|
1331
|
+
span(`${child.tool} ${child.args}`),
|
|
1332
|
+
span(` · ${child.lastTool ?? "starting"}`, "dim")
|
|
1333
|
+
],
|
|
1334
|
+
span(` · ${elapsed(child.since, now)}`, "dim"),
|
|
1335
|
+
width
|
|
1336
|
+
)
|
|
1337
|
+
|
|
1338
|
+
/** The list's rows: a running lane, then each sub-agent still running under it. */
|
|
1339
|
+
const listRowsOf = (state: TreeState): number =>
|
|
1340
|
+
runningLanes(state).reduce(
|
|
1341
|
+
(total, lane) =>
|
|
1342
|
+
total + 1 + lane.taskCoders.length + lane.children.filter((child) => !child.ended).length,
|
|
1343
|
+
0
|
|
1344
|
+
)
|
|
1345
|
+
|
|
1346
|
+
/**
|
|
1347
|
+
* Every running agent, one row each, scrolled so the selected lane is in
|
|
1348
|
+
* view and cut to `rows` with a count of what is out of view (ADR 0033).
|
|
1349
|
+
*/
|
|
1350
|
+
const agentRows = (
|
|
1351
|
+
state: TreeState,
|
|
1352
|
+
width: number,
|
|
1353
|
+
view: TreeView,
|
|
1354
|
+
selected: TreeLane | undefined,
|
|
1355
|
+
rows: number | undefined
|
|
1356
|
+
): Array<Line> => {
|
|
1357
|
+
const all: Array<{ readonly lane: string; readonly line: Line }> = runningLanes(state).flatMap(
|
|
1358
|
+
(lane) => [
|
|
1359
|
+
{
|
|
1360
|
+
lane: lane.id,
|
|
1361
|
+
line: laneLine(lane, width, state.now, lane.id === selected?.id, state.idleAfterMs)
|
|
1362
|
+
},
|
|
1363
|
+
...lane.taskCoders.map((coder) => ({
|
|
1364
|
+
lane: lane.id,
|
|
1365
|
+
line: taskCoderLine(coder, width, state.now)
|
|
1366
|
+
})),
|
|
1367
|
+
...lane.children
|
|
1368
|
+
.filter((child) => !child.ended)
|
|
1369
|
+
.map((child) => ({ lane: lane.id, line: childLine(child, width, state.now) }))
|
|
1370
|
+
]
|
|
1371
|
+
)
|
|
1372
|
+
if (rows === undefined || all.length <= rows) {
|
|
1373
|
+
return all.map((row) => row.line)
|
|
1374
|
+
}
|
|
1375
|
+
const wanted = all.findIndex((row) => row.lane === selected?.id)
|
|
1376
|
+
// The "… n above/more" notes take rows of the budget too.
|
|
1377
|
+
const window = (room: number): number => {
|
|
1378
|
+
const start = Math.max(0, Math.min(view.scroll, all.length - room))
|
|
1379
|
+
return wanted < 0
|
|
1380
|
+
? start
|
|
1381
|
+
: wanted < start
|
|
1382
|
+
? wanted
|
|
1383
|
+
: wanted >= start + room
|
|
1384
|
+
? wanted - room + 1
|
|
1385
|
+
: start
|
|
1386
|
+
}
|
|
1387
|
+
let room = Math.max(1, rows - 1)
|
|
1388
|
+
let first = window(room)
|
|
1389
|
+
if (first > 0 && first + room < all.length) {
|
|
1390
|
+
room = Math.max(1, rows - 2)
|
|
1391
|
+
first = window(room)
|
|
1392
|
+
}
|
|
1393
|
+
const shown = all.slice(first, first + room)
|
|
1394
|
+
const above = first
|
|
1395
|
+
const below = all.length - first - shown.length
|
|
1396
|
+
return [
|
|
1397
|
+
...(above > 0 ? [[span(`… ${above} above`, "dim")]] : []),
|
|
1398
|
+
...shown.map((row) => row.line),
|
|
1399
|
+
...(below > 0 ? [[span(`… ${below} more`, "dim")]] : [])
|
|
1400
|
+
]
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
const tick = (task: TreeTask, running: boolean): string =>
|
|
1404
|
+
task.done ? "[x]" : running ? "[▶]" : "[ ]"
|
|
1405
|
+
|
|
1406
|
+
/**
|
|
1407
|
+
* The selected lane in full: its task checklist with the criteria each task
|
|
1408
|
+
* satisfies, its stage, what it is doing, its tools, a harness pause, and
|
|
1409
|
+
* its figures (ADR 0033). Expanded, every stage and tool it ran.
|
|
1410
|
+
*/
|
|
1411
|
+
const detailBox = (
|
|
1412
|
+
lane: TreeLane,
|
|
1413
|
+
width: number,
|
|
1414
|
+
now: number | undefined,
|
|
1415
|
+
idleAfterMs: number,
|
|
1416
|
+
expanded: boolean
|
|
1417
|
+
): Array<Line> => {
|
|
1418
|
+
const inner = width - 4
|
|
1419
|
+
const checklist: Array<Line> =
|
|
1420
|
+
lane.tasks.length === 0
|
|
1421
|
+
? [[span("no task plan yet", "dim")]]
|
|
1422
|
+
: lane.tasks.map((task): Line => {
|
|
1423
|
+
const beside = lane.taskCoders.find((coder) => coder.index === task.index)
|
|
1424
|
+
const running = lane.task?.index === task.index || beside !== undefined
|
|
1425
|
+
const right = task.satisfies === undefined ? "" : `satisfies ${task.satisfies.join(",")}`
|
|
1426
|
+
const left: Line = [
|
|
1427
|
+
span(
|
|
1428
|
+
`${tick(task, running)} ${task.index} ${task.title}`,
|
|
1429
|
+
running ? "bold" : undefined
|
|
1430
|
+
),
|
|
1431
|
+
...(beside === undefined
|
|
1432
|
+
? []
|
|
1433
|
+
: [span(` ← ${cloneName(beside.executor ?? "(leasing)", beside.clone)}`, "dim")])
|
|
1434
|
+
]
|
|
1435
|
+
return right.length === 0
|
|
1436
|
+
? left
|
|
1437
|
+
: [...fit(left, Math.max(1, inner - [...right].length - 1)), span(` ${right}`, "dim")]
|
|
1438
|
+
})
|
|
1439
|
+
const stages: Array<Line> = expanded
|
|
1440
|
+
? lane.stages.map((stage): Line => [span(stage)])
|
|
1441
|
+
: [[span("stage "), span(lane.stages.at(-1) ?? "starting")]]
|
|
1442
|
+
const tools: Array<Line> = expanded
|
|
1443
|
+
? [
|
|
1444
|
+
[span(`tools (last ${lane.tools.length})`, "dim")],
|
|
1445
|
+
...lane.tools.map((tool): Line => [span(` ${tool}`, "dim")])
|
|
1446
|
+
]
|
|
1447
|
+
: lane.tools.length === 0
|
|
1448
|
+
? []
|
|
1449
|
+
: [[span("tools "), span(lane.tools.slice(-3).join(" · "), "dim")]]
|
|
1450
|
+
const title = `${lane.id} · ${cloneName(lane.executor ?? "(leasing)", lane.clone)}${
|
|
1451
|
+
lane.task === undefined ? "" : ` · task ${lane.task.index}/${lane.task.count}`
|
|
1452
|
+
}`
|
|
1453
|
+
return box(
|
|
1030
1454
|
width,
|
|
1031
1455
|
"running",
|
|
1032
1456
|
[
|
|
1033
|
-
|
|
1034
|
-
...
|
|
1035
|
-
|
|
1036
|
-
...lane.
|
|
1037
|
-
|
|
1457
|
+
...checklist,
|
|
1458
|
+
...stages,
|
|
1459
|
+
doingLine(lane, now),
|
|
1460
|
+
...(lane.pause === undefined
|
|
1461
|
+
? []
|
|
1462
|
+
: [[span(`⏸ ${lane.pause.label} ${elapsed(lane.pause.since, now)}`, "judge")]]),
|
|
1463
|
+
...tools,
|
|
1464
|
+
[
|
|
1465
|
+
span(
|
|
1466
|
+
`${elapsed(lane.startedAt, now)} · ${formatCount(lane.tokens)} tok${lane.costUsd > 0 ? ` · ${dollars(lane.costUsd)}` : ""}`
|
|
1467
|
+
)
|
|
1468
|
+
],
|
|
1469
|
+
...(lane.turns.length === 0 && lane.gatesMs === 0
|
|
1470
|
+
? []
|
|
1471
|
+
: [
|
|
1472
|
+
[
|
|
1473
|
+
span(
|
|
1474
|
+
`turns ${lane.turns.length} · avg ${elapsedMs(
|
|
1475
|
+
lane.turns.length === 0
|
|
1476
|
+
? 0
|
|
1477
|
+
: lane.turns.reduce((total, turn) => total + turn, 0) / lane.turns.length
|
|
1478
|
+
)} · gates ${elapsedMs(lane.gatesMs)}`
|
|
1479
|
+
)
|
|
1480
|
+
]
|
|
1481
|
+
]),
|
|
1482
|
+
statusLine(lane, now, idleAfterMs)
|
|
1483
|
+
],
|
|
1484
|
+
title
|
|
1485
|
+
)
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
const bar = (done: number, total: number, width = 5): string => {
|
|
1489
|
+
const filled = total === 0 ? 0 : Math.round((done / total) * width)
|
|
1490
|
+
return "█".repeat(filled) + "░".repeat(width - filled)
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
const boardColumns: ReadonlyArray<readonly [string, ChipStatus]> = [
|
|
1494
|
+
["planned", "planned"],
|
|
1495
|
+
["active", "running"],
|
|
1496
|
+
["waiting", "waiting"],
|
|
1497
|
+
["done", "done"],
|
|
1498
|
+
["failed", "failed"]
|
|
1499
|
+
]
|
|
1500
|
+
|
|
1501
|
+
/**
|
|
1502
|
+
* The boards (ADR 0033): the epic's stories by column, with a running lane's
|
|
1503
|
+
* clone and progress on its card, then the selected lane's tasks by column.
|
|
1504
|
+
*/
|
|
1505
|
+
const boardsOf = (
|
|
1506
|
+
state: TreeState,
|
|
1507
|
+
width: number,
|
|
1508
|
+
selected: TreeLane | undefined,
|
|
1509
|
+
now: number | undefined
|
|
1510
|
+
): Array<Line> => {
|
|
1511
|
+
const chips = chipsOf(state)
|
|
1512
|
+
// Five columns need ~16 characters each to read; narrower, two rows of columns.
|
|
1513
|
+
const epic = chunked(boardColumns, width >= 5 * 16 + 8 ? 5 : 3).flatMap((group) =>
|
|
1514
|
+
columnsOf(group, width, ([name, status], each) => {
|
|
1515
|
+
const members = chips.filter((chip) => chip.status === status)
|
|
1516
|
+
const cards = members.flatMap((chip): Array<Line> => {
|
|
1517
|
+
const lane = state.lanes.find((candidate) => candidate.id === chip.id)
|
|
1518
|
+
if (lane === undefined || lane.status !== "running") {
|
|
1519
|
+
return [[span(chip.id)]]
|
|
1520
|
+
}
|
|
1521
|
+
const done = lane.tasks.filter((task) => task.done).length
|
|
1522
|
+
const progress =
|
|
1523
|
+
lane.tasks.length === 0
|
|
1524
|
+
? ""
|
|
1525
|
+
: ` ${done}/${lane.tasks.length}${each >= 24 ? ` ${bar(done, lane.tasks.length)}` : ""}`
|
|
1526
|
+
return [
|
|
1527
|
+
[span(chip.id, "bold")],
|
|
1528
|
+
[span(` ${cloneName(lane.executor ?? "(leasing)", lane.clone)}${progress}`, "dim")]
|
|
1529
|
+
]
|
|
1530
|
+
})
|
|
1531
|
+
return box(
|
|
1532
|
+
each,
|
|
1533
|
+
"running",
|
|
1534
|
+
cards.length === 0 ? [[span("—", "dim")]] : cards,
|
|
1535
|
+
`${name} (${members.length})`
|
|
1536
|
+
)
|
|
1537
|
+
})
|
|
1538
|
+
)
|
|
1539
|
+
if (selected === undefined) {
|
|
1540
|
+
return [[span("epic board", "bold")], ...epic]
|
|
1541
|
+
}
|
|
1542
|
+
const taskColumns: ReadonlyArray<readonly [string, (task: TreeTask) => boolean]> = [
|
|
1543
|
+
[
|
|
1544
|
+
"todo",
|
|
1545
|
+
(task) =>
|
|
1546
|
+
!task.done &&
|
|
1547
|
+
task.index !== selected.task?.index &&
|
|
1548
|
+
!selected.taskCoders.some((coder) => coder.index === task.index)
|
|
1549
|
+
],
|
|
1550
|
+
[
|
|
1551
|
+
"doing",
|
|
1552
|
+
(task) =>
|
|
1553
|
+
!task.done &&
|
|
1554
|
+
(task.index === selected.task?.index ||
|
|
1555
|
+
selected.taskCoders.some((coder) => coder.index === task.index))
|
|
1038
1556
|
],
|
|
1039
|
-
|
|
1557
|
+
["review", () => false],
|
|
1558
|
+
["done", (task) => task.done]
|
|
1559
|
+
]
|
|
1560
|
+
const story = chunked(taskColumns, width >= 4 * 16 + 6 ? 4 : 2).flatMap((group) =>
|
|
1561
|
+
columnsOf(group, width, ([name, keep], each) => {
|
|
1562
|
+
const members = selected.tasks.filter(keep)
|
|
1563
|
+
return box(
|
|
1564
|
+
each,
|
|
1565
|
+
"running",
|
|
1566
|
+
members.length === 0
|
|
1567
|
+
? [[span("—", "dim")]]
|
|
1568
|
+
: members.map((task): Line => [span(`${task.index} ${task.title}`)]),
|
|
1569
|
+
`${name} (${members.length})`
|
|
1570
|
+
)
|
|
1571
|
+
})
|
|
1572
|
+
)
|
|
1573
|
+
return [
|
|
1574
|
+
[span("epic board", "bold")],
|
|
1575
|
+
...epic,
|
|
1576
|
+
[],
|
|
1577
|
+
[
|
|
1578
|
+
span(
|
|
1579
|
+
`story board · ${selected.id} · ${cloneName(selected.executor ?? "(leasing)", selected.clone)}`,
|
|
1580
|
+
"bold"
|
|
1581
|
+
),
|
|
1582
|
+
span(` · ${elapsed(selected.startedAt, now)}`)
|
|
1583
|
+
],
|
|
1584
|
+
...story
|
|
1585
|
+
]
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
/** `codex ×2 · claude ×1`: how many leases each executor holds now. */
|
|
1589
|
+
const leaseCounts = (state: TreeState): string =>
|
|
1590
|
+
state.executors
|
|
1591
|
+
.map(
|
|
1592
|
+
(executor) =>
|
|
1593
|
+
[executor, state.leases.filter((lease) => lease.executor === executor).length] as const
|
|
1594
|
+
)
|
|
1595
|
+
.filter(([, count]) => count > 0)
|
|
1596
|
+
.map(([executor, count]) => `${executor} ×${count}`)
|
|
1597
|
+
.join(" · ")
|
|
1598
|
+
|
|
1599
|
+
const chunked = <A>(items: ReadonlyArray<A>, size: number): Array<ReadonlyArray<A>> =>
|
|
1600
|
+
Array.from({ length: Math.ceil(items.length / size) }, (_, index) =>
|
|
1601
|
+
items.slice(index * size, index * size + size)
|
|
1040
1602
|
)
|
|
1041
1603
|
|
|
1042
1604
|
const barWidth = 10
|
|
@@ -1112,9 +1674,10 @@ const mainOf = (
|
|
|
1112
1674
|
state: TreeState,
|
|
1113
1675
|
width: number,
|
|
1114
1676
|
view: TreeView,
|
|
1115
|
-
tail: ReadonlyArray<string> | undefined
|
|
1677
|
+
tail: ReadonlyArray<string> | undefined,
|
|
1678
|
+
listRows: number | undefined
|
|
1116
1679
|
): Array<Line> => {
|
|
1117
|
-
const running = state
|
|
1680
|
+
const running = runningLanes(state)
|
|
1118
1681
|
const orchestratorWidth = Math.min(46, width)
|
|
1119
1682
|
const stage = state.stages.at(-1) ?? (running.length > 0 ? "implement stories" : "idle")
|
|
1120
1683
|
// The run's own work under way (its gates, outside any story), innermost.
|
|
@@ -1140,32 +1703,37 @@ const mainOf = (
|
|
|
1140
1703
|
],
|
|
1141
1704
|
state.title
|
|
1142
1705
|
)
|
|
1143
|
-
const selected =
|
|
1144
|
-
const
|
|
1145
|
-
const
|
|
1146
|
-
|
|
1706
|
+
const selected = selectedLane(state, view)
|
|
1707
|
+
const counts = leaseCounts(state)
|
|
1708
|
+
const agents = centre(
|
|
1709
|
+
[span(`agents · ${running.length} running${counts.length === 0 ? "" : ` · ${counts}`}`)],
|
|
1710
|
+
width
|
|
1711
|
+
)
|
|
1712
|
+
const body =
|
|
1147
1713
|
view.tail && view.selected !== undefined
|
|
1148
1714
|
? tailOf(view, tail, width)
|
|
1149
|
-
: view.
|
|
1150
|
-
?
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
: columnsOf(executors, width, (executor, each) =>
|
|
1715
|
+
: view.mode === "executors"
|
|
1716
|
+
? state.executors.length === 0
|
|
1717
|
+
? [centre([span("no executors yet", "dim")], width)]
|
|
1718
|
+
: chunked(state.executors, 4).flatMap((group) =>
|
|
1719
|
+
columnsOf(group, width, (executor, each) =>
|
|
1155
1720
|
executorBox(state, executor, each, executor === view.selected)
|
|
1156
1721
|
)
|
|
1157
|
-
|
|
1722
|
+
)
|
|
1723
|
+
: view.mode === "boards"
|
|
1724
|
+
? boardsOf(state, width, selected, state.now)
|
|
1725
|
+
: running.length === 0 || selected === undefined
|
|
1158
1726
|
? [centre([span("no stories in flight", "dim")], width)]
|
|
1159
|
-
:
|
|
1160
|
-
|
|
1161
|
-
|
|
1727
|
+
: [
|
|
1728
|
+
...agentRows(state, width, view, selected, listRows),
|
|
1729
|
+
...detailBox(selected, width, state.now, state.idleAfterMs, view.expanded)
|
|
1730
|
+
]
|
|
1162
1731
|
return [
|
|
1163
1732
|
...orchestrator.map((line) => centre(line, width)),
|
|
1164
1733
|
centre([span("•", "orchestrator")], width),
|
|
1165
1734
|
...judgmentOf(state, width),
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
...lanes,
|
|
1735
|
+
agents,
|
|
1736
|
+
...body,
|
|
1169
1737
|
[],
|
|
1170
1738
|
...wrapChips(chipsOf(state).map(chipOf), width)
|
|
1171
1739
|
]
|
|
@@ -1198,7 +1766,8 @@ const timeSplitOf = (time: TreeTimeSplit): Array<Line> => {
|
|
|
1198
1766
|
const frameOf = (
|
|
1199
1767
|
state: TreeState,
|
|
1200
1768
|
options: TreeRenderOptions,
|
|
1201
|
-
logCount: number | "none"
|
|
1769
|
+
logCount: number | "none",
|
|
1770
|
+
listRows: number | undefined
|
|
1202
1771
|
): ReadonlyArray<string> => {
|
|
1203
1772
|
const { width } = options
|
|
1204
1773
|
const mainWidth = width - railWidth - 2
|
|
@@ -1214,7 +1783,7 @@ const frameOf = (
|
|
|
1214
1783
|
[span("═".repeat(width), "dim")],
|
|
1215
1784
|
[],
|
|
1216
1785
|
...beside(
|
|
1217
|
-
[railOf(state), mainOf(state, mainWidth, options.view, options.tail)],
|
|
1786
|
+
[railOf(state), mainOf(state, mainWidth, options.view, options.tail, listRows)],
|
|
1218
1787
|
[railWidth, mainWidth]
|
|
1219
1788
|
),
|
|
1220
1789
|
...(logCount === "none"
|
|
@@ -1236,24 +1805,38 @@ const frameOf = (
|
|
|
1236
1805
|
}
|
|
1237
1806
|
|
|
1238
1807
|
/**
|
|
1239
|
-
* The tree at a width and, when `height` is given, no taller: the
|
|
1240
|
-
*
|
|
1241
|
-
*
|
|
1808
|
+
* The tree at a width and, when `height` is given, no taller: the agent
|
|
1809
|
+
* list gives way first (down to `minListRows`, with a count of what is out
|
|
1810
|
+
* of view), then the session log — fewer lines, down to one, then none —
|
|
1811
|
+
* and only then is the frame cut, keeping its status lines, so a redraw
|
|
1812
|
+
* never scrolls.
|
|
1242
1813
|
*/
|
|
1243
1814
|
export const renderTree = (state: TreeState, options: TreeRenderOptions): ReadonlyArray<string> => {
|
|
1244
1815
|
const wanted = options.view.fullLog ? fullLogLines : logLines
|
|
1245
|
-
const full = frameOf(state, options, wanted)
|
|
1816
|
+
const full = frameOf(state, options, wanted, undefined)
|
|
1246
1817
|
const { height } = options
|
|
1247
1818
|
if (height === undefined || full.length <= height) {
|
|
1248
1819
|
return full
|
|
1249
1820
|
}
|
|
1821
|
+
const total = listRowsOf(state)
|
|
1822
|
+
const floor = Math.min(total, minListRows)
|
|
1823
|
+
const listRows =
|
|
1824
|
+
options.view.mode === "lanes" && total > floor
|
|
1825
|
+
? Math.max(floor, total - (full.length - height))
|
|
1826
|
+
: undefined
|
|
1827
|
+
if (listRows !== undefined) {
|
|
1828
|
+
const shorter = frameOf(state, options, wanted, listRows)
|
|
1829
|
+
if (shorter.length <= height) {
|
|
1830
|
+
return shorter
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1250
1833
|
for (let count = wanted - 1; count >= 1; count -= 1) {
|
|
1251
|
-
const shorter = frameOf(state, options, count)
|
|
1834
|
+
const shorter = frameOf(state, options, count, listRows)
|
|
1252
1835
|
if (shorter.length <= height) {
|
|
1253
1836
|
return shorter
|
|
1254
1837
|
}
|
|
1255
1838
|
}
|
|
1256
|
-
return fitToRows(frameOf(state, options, "none"), height)
|
|
1839
|
+
return fitToRows(frameOf(state, options, "none", listRows), height)
|
|
1257
1840
|
}
|
|
1258
1841
|
|
|
1259
1842
|
/**
|