@llm4ts/runner 2.19.0 → 2.20.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 +150 -0
- package/dist/AgentTree.d.ts.map +1 -0
- package/dist/AgentTree.js +638 -0
- package/dist/AgentTree.js.map +1 -0
- package/dist/AgentTreeSurface.d.ts +34 -0
- package/dist/AgentTreeSurface.d.ts.map +1 -0
- package/dist/AgentTreeSurface.js +118 -0
- package/dist/AgentTreeSurface.js.map +1 -0
- package/dist/FlowArgs.d.ts +1 -1
- package/dist/FlowArgs.d.ts.map +1 -1
- package/dist/FlowArgs.js +1 -0
- package/dist/FlowArgs.js.map +1 -1
- package/dist/FlowRunner.d.ts.map +1 -1
- package/dist/FlowRunner.js +33 -4
- package/dist/FlowRunner.js.map +1 -1
- package/dist/Terminal.d.ts.map +1 -1
- package/dist/Terminal.js +15 -3
- package/dist/Terminal.js.map +1 -1
- package/dist/Watch.d.ts +46 -0
- package/dist/Watch.d.ts.map +1 -0
- package/dist/Watch.js +177 -0
- package/dist/Watch.js.map +1 -0
- package/package.json +6 -3
- package/src/AgentTree.ts +954 -0
- package/src/AgentTreeSurface.ts +202 -0
- package/src/FlowArgs.ts +1 -0
- package/src/FlowRunner.ts +51 -4
- package/src/Terminal.ts +15 -2
- package/src/Watch.ts +291 -0
|
@@ -0,0 +1,638 @@
|
|
|
1
|
+
import * as Option from "effect/Option";
|
|
2
|
+
import * as Schema from "effect/Schema";
|
|
3
|
+
import { FlowEvent } from "@llm4ts/flow/FlowEvents";
|
|
4
|
+
import { estimateCostUsd } from "@llm4ts/flow/PriceList";
|
|
5
|
+
import { formatCount } from "./Terminal.js";
|
|
6
|
+
export const emptyTree = (options = {}) => ({
|
|
7
|
+
title: options.title ?? "flow",
|
|
8
|
+
action: options.action,
|
|
9
|
+
stories: options.stories ?? [],
|
|
10
|
+
startedAt: undefined,
|
|
11
|
+
now: undefined,
|
|
12
|
+
stages: [],
|
|
13
|
+
lanes: [],
|
|
14
|
+
executors: [],
|
|
15
|
+
leases: [],
|
|
16
|
+
exclusions: [],
|
|
17
|
+
judge: { executor: undefined, reviews: 0, verdicts: 0 },
|
|
18
|
+
verdict: undefined,
|
|
19
|
+
judgments: [],
|
|
20
|
+
log: [],
|
|
21
|
+
tokens: 0,
|
|
22
|
+
costUsd: 0,
|
|
23
|
+
ended: undefined
|
|
24
|
+
});
|
|
25
|
+
const costOf = (model, usage) => usage.costUsd ?? (model === undefined ? undefined : estimateCostUsd(model, usage)) ?? 0;
|
|
26
|
+
const logged = (state, entry) => ({
|
|
27
|
+
...state,
|
|
28
|
+
log: [...state.log, entry]
|
|
29
|
+
});
|
|
30
|
+
const storyStage = /^story (\S+)$/u;
|
|
31
|
+
const expandedTools = 20;
|
|
32
|
+
const updateLane = (state, id, update) => ({
|
|
33
|
+
...state,
|
|
34
|
+
lanes: state.lanes.map((lane) => (lane.id === id ? update(lane) : lane))
|
|
35
|
+
});
|
|
36
|
+
const withoutLast = (items, item) => {
|
|
37
|
+
const index = items.lastIndexOf(item);
|
|
38
|
+
return index < 0 ? items : [...items.slice(0, index), ...items.slice(index + 1)];
|
|
39
|
+
};
|
|
40
|
+
const withExecutor = (state, executor) => state.executors.includes(executor)
|
|
41
|
+
? state
|
|
42
|
+
: { ...state, executors: [...state.executors, executor] };
|
|
43
|
+
const rosterLog = (state, at, what) => logged(state, { at, source: "roster", who: "roster", what });
|
|
44
|
+
const reduceLease = (state, at, lease) => {
|
|
45
|
+
const held = { ...withExecutor(state, lease.executor), leases: [...state.leases, lease] };
|
|
46
|
+
switch (lease.role) {
|
|
47
|
+
case "coder": {
|
|
48
|
+
const lane = held.lanes.find((candidate) => candidate.id === lease.label);
|
|
49
|
+
const placed = lane === undefined
|
|
50
|
+
? held
|
|
51
|
+
: updateLane(held, lane.id, (open) => ({ ...open, executor: lease.executor }));
|
|
52
|
+
return rosterLog(placed, at, `${lease.executor} → coder${lease.label === undefined ? "" : ` · ${lease.label}`}`);
|
|
53
|
+
}
|
|
54
|
+
case "reviewer":
|
|
55
|
+
return {
|
|
56
|
+
...held,
|
|
57
|
+
judge: {
|
|
58
|
+
...held.judge,
|
|
59
|
+
executor: held.judge.executor ?? lease.executor,
|
|
60
|
+
reviews: held.judge.reviews + 1
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
case "judge":
|
|
64
|
+
return {
|
|
65
|
+
...held,
|
|
66
|
+
judge: { ...held.judge, executor: lease.executor, verdicts: held.judge.verdicts + 1 }
|
|
67
|
+
};
|
|
68
|
+
default:
|
|
69
|
+
return held;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
const issueCount = (issues) => `${issues} issue${issues === 1 ? "" : "s"}`;
|
|
73
|
+
const verdictWords = (verdict) => verdict.cleared ? "cleared" : issueCount(verdict.issues);
|
|
74
|
+
const reduceEvent = (state, at, event) => {
|
|
75
|
+
const lane = "lane" in event && event.lane !== undefined
|
|
76
|
+
? state.lanes.find((candidate) => candidate.id === event.lane)
|
|
77
|
+
: undefined;
|
|
78
|
+
const executor = "executor" in event ? event.executor : undefined;
|
|
79
|
+
const named = executor === undefined || state.executors.includes(executor)
|
|
80
|
+
? state
|
|
81
|
+
: { ...state, executors: [...state.executors, executor] };
|
|
82
|
+
const current = lane !== undefined && executor !== undefined && lane.executor !== executor
|
|
83
|
+
? updateLane(named, lane.id, (open) => ({ ...open, executor }))
|
|
84
|
+
: named;
|
|
85
|
+
switch (event._tag) {
|
|
86
|
+
case "StageStarted": {
|
|
87
|
+
const story = storyStage.exec(event.stage)?.[1];
|
|
88
|
+
if (story !== undefined && event.lane === story) {
|
|
89
|
+
const fresh = {
|
|
90
|
+
id: story,
|
|
91
|
+
stages: [],
|
|
92
|
+
executor: event.executor,
|
|
93
|
+
startedAt: at,
|
|
94
|
+
status: "running",
|
|
95
|
+
lastTool: undefined,
|
|
96
|
+
tools: [],
|
|
97
|
+
tokens: 0,
|
|
98
|
+
costUsd: 0
|
|
99
|
+
};
|
|
100
|
+
return {
|
|
101
|
+
...current,
|
|
102
|
+
lanes: [...current.lanes.filter((open) => open.id !== story), fresh]
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
if (lane !== undefined) {
|
|
106
|
+
return updateLane(current, lane.id, (open) => ({
|
|
107
|
+
...open,
|
|
108
|
+
stages: [...open.stages, event.stage]
|
|
109
|
+
}));
|
|
110
|
+
}
|
|
111
|
+
return { ...current, stages: [...current.stages, event.stage] };
|
|
112
|
+
}
|
|
113
|
+
case "StageCompleted":
|
|
114
|
+
case "StageFailed": {
|
|
115
|
+
if (lane !== undefined && event.stage === `story ${lane.id}`) {
|
|
116
|
+
const status = event._tag === "StageFailed" ? "failed" : "done";
|
|
117
|
+
const closed = updateLane(current, lane.id, (open) => ({ ...open, stages: [], status }));
|
|
118
|
+
return logged(closed, {
|
|
119
|
+
at,
|
|
120
|
+
source: "lane",
|
|
121
|
+
who: lane.id,
|
|
122
|
+
what: event._tag === "StageFailed" ? `failed · ${event.message}` : "done"
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
if (lane !== undefined) {
|
|
126
|
+
return updateLane(current, lane.id, (open) => ({
|
|
127
|
+
...open,
|
|
128
|
+
stages: withoutLast(open.stages, event.stage)
|
|
129
|
+
}));
|
|
130
|
+
}
|
|
131
|
+
return { ...current, stages: withoutLast(current.stages, event.stage) };
|
|
132
|
+
}
|
|
133
|
+
case "Info": {
|
|
134
|
+
// A story's own milestones are `story <id>: …` (resuming, merged,
|
|
135
|
+
// skipped); its other chatter stays in the expanded lane.
|
|
136
|
+
if (event.lane === undefined) {
|
|
137
|
+
// Traces from before the roster's typed events (2.20) carry its notes
|
|
138
|
+
// as prose: leases are chatter, the rest (exclusions, handovers) stays.
|
|
139
|
+
const legacyRoster = /^roster: (.*)$/u.exec(event.message)?.[1];
|
|
140
|
+
if (legacyRoster !== undefined) {
|
|
141
|
+
return / takes \S+/u.test(legacyRoster)
|
|
142
|
+
? current
|
|
143
|
+
: logged(current, { at, source: "roster", who: "roster", what: legacyRoster });
|
|
144
|
+
}
|
|
145
|
+
return logged(current, { at, source: "run", who: "flow", what: event.message });
|
|
146
|
+
}
|
|
147
|
+
const prefix = `story ${event.lane}: `;
|
|
148
|
+
return event.message.startsWith(prefix)
|
|
149
|
+
? logged(current, {
|
|
150
|
+
at,
|
|
151
|
+
source: "lane",
|
|
152
|
+
who: event.lane,
|
|
153
|
+
what: event.message.slice(prefix.length)
|
|
154
|
+
})
|
|
155
|
+
: current;
|
|
156
|
+
}
|
|
157
|
+
case "StoryJudged": {
|
|
158
|
+
const verdict = {
|
|
159
|
+
lane: event.lane,
|
|
160
|
+
round: event.round,
|
|
161
|
+
cleared: event.cleared,
|
|
162
|
+
issues: event.issues,
|
|
163
|
+
dimensions: event.dimensions
|
|
164
|
+
};
|
|
165
|
+
return logged({ ...current, verdict }, {
|
|
166
|
+
at,
|
|
167
|
+
source: "judge",
|
|
168
|
+
who: "judge",
|
|
169
|
+
what: `${verdict.lane} r${verdict.round} · ${verdictWords(verdict)} → ${verdict.cleared ? "merge" : "coder"}`
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
case "JudgmentObserved":
|
|
173
|
+
return {
|
|
174
|
+
...current,
|
|
175
|
+
judgments: [
|
|
176
|
+
...current.judgments.filter((judgment) => judgment.key !== event.key),
|
|
177
|
+
{ key: event.key, certainty: event.certainty, decision: event.decision }
|
|
178
|
+
]
|
|
179
|
+
};
|
|
180
|
+
case "ExecutorLeased":
|
|
181
|
+
return reduceLease(current, at, {
|
|
182
|
+
executor: event.executor,
|
|
183
|
+
role: event.role,
|
|
184
|
+
label: event.label
|
|
185
|
+
});
|
|
186
|
+
case "ExecutorReleased": {
|
|
187
|
+
const index = current.leases.findIndex((lease) => lease.executor === event.executor &&
|
|
188
|
+
lease.role === event.role &&
|
|
189
|
+
lease.label === event.label);
|
|
190
|
+
return index < 0
|
|
191
|
+
? current
|
|
192
|
+
: {
|
|
193
|
+
...current,
|
|
194
|
+
leases: [...current.leases.slice(0, index), ...current.leases.slice(index + 1)]
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
case "ExecutorExcluded":
|
|
198
|
+
return rosterLog({
|
|
199
|
+
...withExecutor(current, event.executor),
|
|
200
|
+
exclusions: [
|
|
201
|
+
...current.exclusions.filter((out) => out.executor !== event.executor),
|
|
202
|
+
{ executor: event.executor, reason: event.reason }
|
|
203
|
+
]
|
|
204
|
+
}, at, `${event.executor} out · ${event.reason}`);
|
|
205
|
+
case "ExecutorResumed":
|
|
206
|
+
return rosterLog({
|
|
207
|
+
...current,
|
|
208
|
+
exclusions: current.exclusions.filter((out) => out.executor !== event.executor)
|
|
209
|
+
}, at, `${event.executor} back · ${event.why}`);
|
|
210
|
+
case "ExecutorHandedOver":
|
|
211
|
+
return rosterLog(current, at, `${event.label ?? "the run"}: ${event.role} leaves ${event.from} · ${event.reason}`);
|
|
212
|
+
case "ToolUse":
|
|
213
|
+
return lane === undefined
|
|
214
|
+
? current
|
|
215
|
+
: updateLane(current, lane.id, (open) => ({
|
|
216
|
+
...open,
|
|
217
|
+
lastTool: `${event.tool} ${event.args}`,
|
|
218
|
+
tools: [...open.tools, `${event.tool} ${event.args}`].slice(-expandedTools)
|
|
219
|
+
}));
|
|
220
|
+
case "TokensUsed": {
|
|
221
|
+
const tokens = event.usage.total;
|
|
222
|
+
const costUsd = costOf(event.model, event.usage);
|
|
223
|
+
const counted = {
|
|
224
|
+
...current,
|
|
225
|
+
tokens: current.tokens + tokens,
|
|
226
|
+
costUsd: current.costUsd + costUsd
|
|
227
|
+
};
|
|
228
|
+
return lane === undefined
|
|
229
|
+
? counted
|
|
230
|
+
: updateLane(counted, lane.id, (open) => ({
|
|
231
|
+
...open,
|
|
232
|
+
tokens: open.tokens + tokens,
|
|
233
|
+
costUsd: open.costUsd + costUsd
|
|
234
|
+
}));
|
|
235
|
+
}
|
|
236
|
+
default:
|
|
237
|
+
return current;
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
export const reduceTree = (state, input) => {
|
|
241
|
+
const timed = {
|
|
242
|
+
...state,
|
|
243
|
+
startedAt: state.startedAt ?? input.at,
|
|
244
|
+
now: Math.max(state.now ?? input.at, input.at)
|
|
245
|
+
};
|
|
246
|
+
switch (input._tag) {
|
|
247
|
+
case "Event":
|
|
248
|
+
return reduceEvent(timed, input.at, input.event);
|
|
249
|
+
case "RunEnded":
|
|
250
|
+
return { ...timed, ended: input.outcome };
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
const decodeEvent = Schema.decodeUnknownOption(Schema.fromJsonString(FlowEvent));
|
|
254
|
+
/**
|
|
255
|
+
* A trace's lines as tree inputs. Kinds this version does not know — raw
|
|
256
|
+
* stream lines, events from a newer llm4ts — are skipped, so any trace renders.
|
|
257
|
+
*/
|
|
258
|
+
export const treeInputsOfTrace = (lines) => lines.flatMap((line) => {
|
|
259
|
+
if (line.kind === "RunEnded") {
|
|
260
|
+
const outcome = line.fields.outcome;
|
|
261
|
+
return [
|
|
262
|
+
{
|
|
263
|
+
_tag: "RunEnded",
|
|
264
|
+
at: line.timestamp,
|
|
265
|
+
outcome: typeof outcome === "string" ? outcome : "completed"
|
|
266
|
+
}
|
|
267
|
+
];
|
|
268
|
+
}
|
|
269
|
+
const raw = line.fields.event;
|
|
270
|
+
if (typeof raw !== "string") {
|
|
271
|
+
return [];
|
|
272
|
+
}
|
|
273
|
+
return Option.match(decodeEvent(raw), {
|
|
274
|
+
onNone: () => [],
|
|
275
|
+
onSome: (event) => [{ _tag: "Event", at: line.timestamp, event }]
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
const span = (text, style) => style === undefined ? { text } : { text, style };
|
|
279
|
+
const lengthOf = (line) => line.reduce((total, part) => total + [...part.text].length, 0);
|
|
280
|
+
/** Pads or cuts a line to exactly `width` characters; a cut ends in `…`. */
|
|
281
|
+
const fit = (line, width) => {
|
|
282
|
+
const length = lengthOf(line);
|
|
283
|
+
if (length <= width) {
|
|
284
|
+
return length === width ? line : [...line, span(" ".repeat(width - length))];
|
|
285
|
+
}
|
|
286
|
+
const kept = [];
|
|
287
|
+
let room = width - 1;
|
|
288
|
+
for (const part of line) {
|
|
289
|
+
if (room <= 0) {
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
const characters = [...part.text];
|
|
293
|
+
const taken = characters.slice(0, room).join("");
|
|
294
|
+
kept.push(part.style === undefined ? span(taken) : span(taken, part.style));
|
|
295
|
+
room -= Math.min(room, characters.length);
|
|
296
|
+
}
|
|
297
|
+
return [...kept, span("…")];
|
|
298
|
+
};
|
|
299
|
+
const centre = (line, width) => {
|
|
300
|
+
const pad = Math.max(0, Math.floor((width - lengthOf(line)) / 2));
|
|
301
|
+
return fit([span(" ".repeat(pad)), ...line], width);
|
|
302
|
+
};
|
|
303
|
+
const box = (width, style, body, title) => {
|
|
304
|
+
const inner = width - 4;
|
|
305
|
+
const top = title === undefined
|
|
306
|
+
? [span(`┌${"─".repeat(width - 2)}┐`, style)]
|
|
307
|
+
: fit([
|
|
308
|
+
span("┌─ ", style),
|
|
309
|
+
span(title, "bold"),
|
|
310
|
+
span(` ${"─".repeat(Math.max(0, width - 5 - [...title].length))}┐`, style)
|
|
311
|
+
], width);
|
|
312
|
+
return [
|
|
313
|
+
top,
|
|
314
|
+
...body.map((line) => [span("│ ", style), ...fit(line, inner), span(" │", style)]),
|
|
315
|
+
[span(`└${"─".repeat(width - 2)}┘`, style)]
|
|
316
|
+
];
|
|
317
|
+
};
|
|
318
|
+
const beside = (columns, widths, gap = 2) => {
|
|
319
|
+
const height = Math.max(0, ...columns.map((column) => column.length));
|
|
320
|
+
return Array.from({ length: height }, (_, row) => columns.flatMap((column, index) => [
|
|
321
|
+
...(index === 0 ? [] : [span(" ".repeat(gap))]),
|
|
322
|
+
...fit(column[row] ?? [], widths[index] ?? 0)
|
|
323
|
+
]));
|
|
324
|
+
};
|
|
325
|
+
const clock = (from, to) => {
|
|
326
|
+
const seconds = Math.max(0, Math.round((to - (from ?? to)) / 1_000));
|
|
327
|
+
return [Math.floor(seconds / 3_600), Math.floor(seconds / 60) % 60, seconds % 60]
|
|
328
|
+
.map((part) => String(part).padStart(2, "0"))
|
|
329
|
+
.join(":");
|
|
330
|
+
};
|
|
331
|
+
const logStyle = {
|
|
332
|
+
lane: "judgment",
|
|
333
|
+
run: "orchestrator",
|
|
334
|
+
roster: "running",
|
|
335
|
+
judge: "judge"
|
|
336
|
+
};
|
|
337
|
+
const logLines = 5;
|
|
338
|
+
const fullLogLines = 30;
|
|
339
|
+
const logOf = (state, count) => {
|
|
340
|
+
const recent = state.log.slice(-count);
|
|
341
|
+
return recent.length === 0
|
|
342
|
+
? [[span("—", "dim")]]
|
|
343
|
+
: recent.map((entry) => [
|
|
344
|
+
span(clock(state.startedAt, entry.at), "dim"),
|
|
345
|
+
span(" "),
|
|
346
|
+
...fit([span(entry.who, logStyle[entry.source])], 16),
|
|
347
|
+
span(" "),
|
|
348
|
+
span(entry.what)
|
|
349
|
+
]);
|
|
350
|
+
};
|
|
351
|
+
const dollars = (usd) => `~$${usd.toFixed(2)}`;
|
|
352
|
+
const elapsed = (from, to) => {
|
|
353
|
+
const seconds = Math.max(0, Math.round(((to ?? 0) - (from ?? to ?? 0)) / 1_000));
|
|
354
|
+
return seconds < 60
|
|
355
|
+
? `${seconds}s`
|
|
356
|
+
: `${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, "0")}s`;
|
|
357
|
+
};
|
|
358
|
+
const colours = {
|
|
359
|
+
bold: "\u001b[1m",
|
|
360
|
+
dim: "\u001b[90m",
|
|
361
|
+
orchestrator: "\u001b[38;5;173m",
|
|
362
|
+
judgment: "\u001b[38;5;114m",
|
|
363
|
+
judge: "\u001b[38;5;141m",
|
|
364
|
+
log: "\u001b[38;5;244m",
|
|
365
|
+
running: "\u001b[38;5;110m"
|
|
366
|
+
};
|
|
367
|
+
const paint = (line, colour) => line
|
|
368
|
+
.map((part) => colour && part.style !== undefined ? `${colours[part.style]}${part.text}\u001b[0m` : part.text)
|
|
369
|
+
.join("");
|
|
370
|
+
export const initialView = {
|
|
371
|
+
mode: "lanes",
|
|
372
|
+
selected: undefined,
|
|
373
|
+
expanded: false,
|
|
374
|
+
fullLog: false
|
|
375
|
+
};
|
|
376
|
+
const runningLanes = (state) => state.lanes.filter((lane) => lane.status === "running");
|
|
377
|
+
/**
|
|
378
|
+
* The view after a key: arrows or 1–9 select a running lane, enter expands
|
|
379
|
+
* it, escape collapses, `e` switches lanes and executors, `l` the full log,
|
|
380
|
+
* `q` quits. Keys it does not know return the same view.
|
|
381
|
+
*/
|
|
382
|
+
export const onTreeKey = (view, key, state) => {
|
|
383
|
+
const running = runningLanes(state);
|
|
384
|
+
const index = running.findIndex((lane) => lane.id === view.selected);
|
|
385
|
+
const select = (next) => {
|
|
386
|
+
const lane = running[Math.max(0, Math.min(running.length - 1, next))];
|
|
387
|
+
return lane === undefined ? view : { ...view, selected: lane.id };
|
|
388
|
+
};
|
|
389
|
+
switch (key) {
|
|
390
|
+
case "q":
|
|
391
|
+
return "quit";
|
|
392
|
+
case "down":
|
|
393
|
+
case "right":
|
|
394
|
+
return select(index + 1);
|
|
395
|
+
case "up":
|
|
396
|
+
case "left":
|
|
397
|
+
return select(index < 0 ? 0 : index - 1);
|
|
398
|
+
case "enter":
|
|
399
|
+
return view.selected === undefined ? view : { ...view, expanded: !view.expanded };
|
|
400
|
+
case "escape":
|
|
401
|
+
return { ...view, expanded: false };
|
|
402
|
+
case "e":
|
|
403
|
+
return { ...view, mode: view.mode === "lanes" ? "executors" : "lanes" };
|
|
404
|
+
case "l":
|
|
405
|
+
return { ...view, fullLog: !view.fullLog };
|
|
406
|
+
default:
|
|
407
|
+
return /^[1-9]$/u.test(key) ? select(Number(key) - 1) : view;
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
const railWidth = 26;
|
|
411
|
+
const maxLaneBoxes = 3;
|
|
412
|
+
const counter = (label, value) => [
|
|
413
|
+
span(`${label.padEnd(15)}${String(value).padStart(5)}`)
|
|
414
|
+
];
|
|
415
|
+
const railOf = (state) => box(railWidth, "judge", [
|
|
416
|
+
centre([span("JUDGE SEAT", "bold")], railWidth - 4),
|
|
417
|
+
centre([span(`${state.judge.executor ?? "—"} · on call`)], railWidth - 4),
|
|
418
|
+
[],
|
|
419
|
+
[span("last verdict:")],
|
|
420
|
+
[
|
|
421
|
+
span(state.verdict === undefined
|
|
422
|
+
? "» none yet"
|
|
423
|
+
: `» ${state.verdict.lane} r${state.verdict.round}: ${verdictWords(state.verdict)}`, "judge")
|
|
424
|
+
],
|
|
425
|
+
[],
|
|
426
|
+
counter("reviews", state.judge.reviews),
|
|
427
|
+
counter("verdicts", state.judge.verdicts)
|
|
428
|
+
]);
|
|
429
|
+
const laneBox = (lane, width, now, selected) => box(width, "running", [
|
|
430
|
+
[span(selected ? `▸ ${lane.id}` : lane.id, "bold")],
|
|
431
|
+
[span(lane.executor ?? "(leasing)")],
|
|
432
|
+
[span(lane.stages.at(-1) ?? "starting")],
|
|
433
|
+
[span(lane.lastTool ?? "", "dim")],
|
|
434
|
+
[
|
|
435
|
+
span(`${elapsed(lane.startedAt, now)} · ${formatCount(lane.tokens)} tok${lane.costUsd > 0 ? ` · ${dollars(lane.costUsd)}` : ""}`)
|
|
436
|
+
],
|
|
437
|
+
[span("◐ running", "running")]
|
|
438
|
+
]);
|
|
439
|
+
/** Every story once: the board's in plan order, then lanes the board does not list. */
|
|
440
|
+
const chipsOf = (state) => {
|
|
441
|
+
const laneStatus = (id) => state.lanes.find((lane) => lane.id === id)?.status;
|
|
442
|
+
const onBoard = new Set(state.stories.map((story) => story.id));
|
|
443
|
+
return [
|
|
444
|
+
...state.stories.map((story) => ({
|
|
445
|
+
id: story.id,
|
|
446
|
+
status: laneStatus(story.id) ??
|
|
447
|
+
(story.status === "skipped" ? "done" : story.status === "active" ? "planned" : story.status)
|
|
448
|
+
})),
|
|
449
|
+
...state.lanes
|
|
450
|
+
.filter((lane) => !onBoard.has(lane.id))
|
|
451
|
+
.map((lane) => ({ id: lane.id, status: lane.status }))
|
|
452
|
+
];
|
|
453
|
+
};
|
|
454
|
+
const chipMark = {
|
|
455
|
+
planned: ["·", "dim"],
|
|
456
|
+
running: ["◐", "running"],
|
|
457
|
+
waiting: ["◌", "judge"],
|
|
458
|
+
done: ["✓", "judgment"],
|
|
459
|
+
failed: ["✗", undefined]
|
|
460
|
+
};
|
|
461
|
+
const chipOf = (chip) => {
|
|
462
|
+
const [mark, style] = chipMark[chip.status];
|
|
463
|
+
return [span(`${mark} ${chip.id}`, style)];
|
|
464
|
+
};
|
|
465
|
+
const wrapChips = (chips, width) => {
|
|
466
|
+
const rows = [];
|
|
467
|
+
let row = [];
|
|
468
|
+
for (const chip of chips) {
|
|
469
|
+
if (row.length > 0 && lengthOf(row) + 2 + lengthOf(chip) > width) {
|
|
470
|
+
rows.push(row);
|
|
471
|
+
row = [];
|
|
472
|
+
}
|
|
473
|
+
row = row.length === 0 ? [...chip] : [...row, span(" "), ...chip];
|
|
474
|
+
}
|
|
475
|
+
return row.length === 0 ? rows : [...rows, row];
|
|
476
|
+
};
|
|
477
|
+
/**
|
|
478
|
+
* What an executor holds: its leases, or — for a trace from before the
|
|
479
|
+
* roster's events — the running lanes that name it.
|
|
480
|
+
*/
|
|
481
|
+
const heldBy = (state, executor) => {
|
|
482
|
+
const leases = state.leases
|
|
483
|
+
.filter((lease) => lease.executor === executor)
|
|
484
|
+
.map((lease) => `${lease.role}${lease.label === undefined ? "" : ` · ${lease.label}`}`);
|
|
485
|
+
return leases.length > 0
|
|
486
|
+
? leases
|
|
487
|
+
: runningLanes(state)
|
|
488
|
+
.filter((lane) => lane.executor === executor)
|
|
489
|
+
.map((lane) => `coder · ${lane.id}`);
|
|
490
|
+
};
|
|
491
|
+
const executorBox = (state, executor, width) => {
|
|
492
|
+
const held = heldBy(state, executor);
|
|
493
|
+
const out = state.exclusions.find((exclusion) => exclusion.executor === executor);
|
|
494
|
+
return box(width, "running", [
|
|
495
|
+
[span(executor, "bold")],
|
|
496
|
+
...(held.length === 0
|
|
497
|
+
? [[span("no lease", "dim")]]
|
|
498
|
+
: held.slice(0, 3).map((lease) => [span(lease)])),
|
|
499
|
+
out !== undefined
|
|
500
|
+
? [span(`✗ out: ${out.reason}`)]
|
|
501
|
+
: held.length === 0
|
|
502
|
+
? [span("○ idle", "dim")]
|
|
503
|
+
: [span("◐ busy", "running")]
|
|
504
|
+
]);
|
|
505
|
+
};
|
|
506
|
+
/** One box per item, side by side, sharing `width` with a two-column gap. */
|
|
507
|
+
const columnsOf = (items, width, draw) => {
|
|
508
|
+
const each = Math.floor((width - 2 * (items.length - 1)) / items.length);
|
|
509
|
+
return beside(items.map((item) => draw(item, each)), items.map(() => each));
|
|
510
|
+
};
|
|
511
|
+
const expandedLane = (lane, width, now) => box(width, "running", [
|
|
512
|
+
[span(`story ${lane.id}`, "bold"), span(` ${elapsed(lane.startedAt, now)}`)],
|
|
513
|
+
...lane.stages.map((stage) => [span(stage)]),
|
|
514
|
+
[span(`tools (last ${lane.tools.length})`, "dim")],
|
|
515
|
+
...lane.tools.map((tool) => [span(` ${tool}`, "dim")]),
|
|
516
|
+
[span(`${formatCount(lane.tokens)} tok · ${dollars(lane.costUsd)}`)]
|
|
517
|
+
], `${lane.id} · ${lane.executor ?? "(leasing)"}`);
|
|
518
|
+
const barWidth = 10;
|
|
519
|
+
const judgmentRows = 3;
|
|
520
|
+
const meter = (fraction) => {
|
|
521
|
+
const filled = Math.round(Math.max(0, Math.min(1, fraction)) * barWidth);
|
|
522
|
+
return [span("█".repeat(filled), "judgment"), span("░".repeat(barWidth - filled), "dim")];
|
|
523
|
+
};
|
|
524
|
+
const judgmentOf = (state, width) => {
|
|
525
|
+
const { verdict } = state;
|
|
526
|
+
const rows = [
|
|
527
|
+
...(verdict?.dimensions ?? []).map((dimension) => [
|
|
528
|
+
span(dimension.id.padEnd(16)),
|
|
529
|
+
...meter(dimension.max === 0 ? 0 : dimension.score / dimension.max),
|
|
530
|
+
span(` ${dimension.score}/${dimension.max}`)
|
|
531
|
+
]),
|
|
532
|
+
...state.judgments
|
|
533
|
+
.slice(-judgmentRows)
|
|
534
|
+
.map((judgment) => [
|
|
535
|
+
span(judgment.key.padEnd(16)),
|
|
536
|
+
...meter(judgment.certainty),
|
|
537
|
+
span(` ${judgment.certainty.toFixed(2)} ${judgment.decision}`)
|
|
538
|
+
]),
|
|
539
|
+
...(verdict === undefined
|
|
540
|
+
? []
|
|
541
|
+
: [
|
|
542
|
+
[
|
|
543
|
+
verdict.cleared
|
|
544
|
+
? span("cleared → merge", "judgment")
|
|
545
|
+
: span(`${issueCount(verdict.issues)} → coder`, "judge")
|
|
546
|
+
]
|
|
547
|
+
])
|
|
548
|
+
];
|
|
549
|
+
return box(width, "judgment", rows.length === 0 ? [[span("no verdicts yet", "dim")]] : rows, verdict === undefined ? "JUDGMENT" : `JUDGMENT · ${verdict.lane} r${verdict.round}`);
|
|
550
|
+
};
|
|
551
|
+
const mainOf = (state, width, view) => {
|
|
552
|
+
const running = state.lanes.filter((lane) => lane.status === "running");
|
|
553
|
+
const orchestratorWidth = Math.min(46, width);
|
|
554
|
+
const stage = state.stages.at(-1) ?? (running.length > 0 ? "implement stories" : "idle");
|
|
555
|
+
const orchestrator = box(orchestratorWidth, "orchestrator", [
|
|
556
|
+
...(state.action === undefined
|
|
557
|
+
? []
|
|
558
|
+
: [centre([span(state.action, "bold")], orchestratorWidth - 4)]),
|
|
559
|
+
[span("stage "), span(stage)],
|
|
560
|
+
[span(`stories ${chipsOf(state).length} elapsed ${elapsed(state.startedAt, state.now)}`)]
|
|
561
|
+
], state.title);
|
|
562
|
+
const selected = running.find((lane) => lane.id === view.selected);
|
|
563
|
+
const shown = running.slice(0, maxLaneBoxes);
|
|
564
|
+
const executors = state.executors.slice(0, maxLaneBoxes + 1);
|
|
565
|
+
const lanes = view.expanded && selected !== undefined
|
|
566
|
+
? expandedLane(selected, width, state.now)
|
|
567
|
+
: view.mode === "executors"
|
|
568
|
+
? executors.length === 0
|
|
569
|
+
? [centre([span("no executors yet", "dim")], width)]
|
|
570
|
+
: columnsOf(executors, width, (executor, each) => executorBox(state, executor, each))
|
|
571
|
+
: shown.length === 0
|
|
572
|
+
? [centre([span("no stories in flight", "dim")], width)]
|
|
573
|
+
: columnsOf(shown, width, (lane, each) => laneBox(lane, each, state.now, lane.id === view.selected));
|
|
574
|
+
return [
|
|
575
|
+
...orchestrator.map((line) => centre(line, width)),
|
|
576
|
+
centre([span("•", "orchestrator")], width),
|
|
577
|
+
...judgmentOf(state, width),
|
|
578
|
+
centre([span(`delegate to roster · ${running.length} running`)], width),
|
|
579
|
+
centre([span("▼", "dim")], width),
|
|
580
|
+
...lanes,
|
|
581
|
+
[],
|
|
582
|
+
...wrapChips(chipsOf(state).map(chipOf), width)
|
|
583
|
+
];
|
|
584
|
+
};
|
|
585
|
+
/** A frame with the session log at `logCount` lines, or without it. */
|
|
586
|
+
const frameOf = (state, options, logCount) => {
|
|
587
|
+
const { width } = options;
|
|
588
|
+
const mainWidth = width - railWidth - 2;
|
|
589
|
+
const chips = chipsOf(state);
|
|
590
|
+
const busy = state.executors.filter((executor) => heldBy(state, executor).length > 0).length;
|
|
591
|
+
const count = (status) => chips.filter((chip) => chip.status === status).length;
|
|
592
|
+
const lines = [
|
|
593
|
+
centre([span("LLM4TS AGENT TREE", "bold"), span(" · "), span(state.title, "orchestrator")], width),
|
|
594
|
+
[span("═".repeat(width), "dim")],
|
|
595
|
+
[],
|
|
596
|
+
...beside([railOf(state), mainOf(state, mainWidth, options.view)], [railWidth, mainWidth]),
|
|
597
|
+
...(logCount === "none"
|
|
598
|
+
? []
|
|
599
|
+
: [[], ...box(width, "log", logOf(state, logCount), "session log")]),
|
|
600
|
+
[
|
|
601
|
+
span(`stories [${count("done")}/${chips.length} done · ${count("running")} running · ${count("failed")} failed · ${count("waiting")} waiting] roster [${busy}/${state.executors.length} busy]`)
|
|
602
|
+
],
|
|
603
|
+
[
|
|
604
|
+
span(`tokens [${formatCount(state.tokens)}] cost [${dollars(state.costUsd)}] run [`),
|
|
605
|
+
state.ended === undefined ? span("live", "running") : span(state.ended, "bold"),
|
|
606
|
+
span("]")
|
|
607
|
+
]
|
|
608
|
+
];
|
|
609
|
+
return lines.map((line) => paint(fit(line, width), options.colour));
|
|
610
|
+
};
|
|
611
|
+
/**
|
|
612
|
+
* The tree at a width and, when `height` is given, no taller: the session
|
|
613
|
+
* log gives way first — fewer lines, down to one, then none — and only then
|
|
614
|
+
* is the frame cut, keeping its status lines, so a redraw never scrolls.
|
|
615
|
+
*/
|
|
616
|
+
export const renderTree = (state, options) => {
|
|
617
|
+
const wanted = options.view.fullLog ? fullLogLines : logLines;
|
|
618
|
+
const full = frameOf(state, options, wanted);
|
|
619
|
+
const { height } = options;
|
|
620
|
+
if (height === undefined || full.length <= height) {
|
|
621
|
+
return full;
|
|
622
|
+
}
|
|
623
|
+
for (let count = wanted - 1; count >= 1; count -= 1) {
|
|
624
|
+
const shorter = frameOf(state, options, count);
|
|
625
|
+
if (shorter.length <= height) {
|
|
626
|
+
return shorter;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
return fitToRows(frameOf(state, options, "none"), height);
|
|
630
|
+
};
|
|
631
|
+
/**
|
|
632
|
+
* A frame cut to a terminal's height: the top of the tree and its two status
|
|
633
|
+
* lines, so a full-screen redraw never scrolls.
|
|
634
|
+
*/
|
|
635
|
+
const fitToRows = (lines, rows) => rows === undefined || rows < 4 || lines.length <= rows
|
|
636
|
+
? lines
|
|
637
|
+
: [...lines.slice(0, rows - 2), ...lines.slice(-2)];
|
|
638
|
+
//# sourceMappingURL=AgentTree.js.map
|