@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,202 @@
|
|
|
1
|
+
import * as Clock from "effect/Clock"
|
|
2
|
+
import * as Effect from "effect/Effect"
|
|
3
|
+
import * as Queue from "effect/Queue"
|
|
4
|
+
import * as Ref from "effect/Ref"
|
|
5
|
+
import type * as Scope from "effect/Scope"
|
|
6
|
+
import * as Semaphore from "effect/Semaphore"
|
|
7
|
+
import * as Stream from "effect/Stream"
|
|
8
|
+
import { awaitConsumed, type FlowEventHub } from "@llm4ts/flow/FlowEvents"
|
|
9
|
+
import {
|
|
10
|
+
emptyTree,
|
|
11
|
+
initialView,
|
|
12
|
+
onTreeKey,
|
|
13
|
+
reduceTree,
|
|
14
|
+
renderTree,
|
|
15
|
+
type TreeView
|
|
16
|
+
} from "./AgentTree.ts"
|
|
17
|
+
import type { TerminalSurface } from "./Terminal.ts"
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The agent tree inside a running flow (`llm4ts run <flow> --ui tree`,
|
|
21
|
+
* ADR 0022): a full-screen view fed from the run's event hub, in front of the
|
|
22
|
+
* classic surface. While it shows, the classic event lines and status block
|
|
23
|
+
* are silent; `q` hands the screen back to them for the rest of the run, and
|
|
24
|
+
* the run's end leaves the last frame on screen before the summary.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export interface AgentTreeOutput {
|
|
28
|
+
readonly write: (text: string) => Effect.Effect<void>
|
|
29
|
+
readonly columns: () => number | undefined
|
|
30
|
+
readonly rows: () => number | undefined
|
|
31
|
+
readonly colour: boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface AgentTreeHost {
|
|
35
|
+
/** The surface the runner and the classic consumer write to. */
|
|
36
|
+
readonly surface: TerminalSurface
|
|
37
|
+
/** Catches up with the run's events, leaves the full screen and prints the last frame. */
|
|
38
|
+
readonly close: Effect.Effect<void>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const enterScreen = "\u001b[?1049h\u001b[?25l"
|
|
42
|
+
const leaveScreen = "\u001b[?25h\u001b[?1049l"
|
|
43
|
+
const home = "\u001b[H\u001b[2J"
|
|
44
|
+
const redrawEvery = "100 millis"
|
|
45
|
+
|
|
46
|
+
export const makeAgentTreeHost = Effect.fn("@llm4ts/runner/AgentTreeSurface.make")(function* (
|
|
47
|
+
hub: FlowEventHub,
|
|
48
|
+
classic: TerminalSurface,
|
|
49
|
+
output: AgentTreeOutput,
|
|
50
|
+
keys: Stream.Stream<string>
|
|
51
|
+
): Effect.fn.Return<AgentTreeHost, never, Scope.Scope> {
|
|
52
|
+
const state = yield* Ref.make(emptyTree())
|
|
53
|
+
const view = yield* Ref.make<TreeView>(initialView)
|
|
54
|
+
const active = yield* Ref.make(true)
|
|
55
|
+
const consumed = yield* Ref.make(0)
|
|
56
|
+
const lock = yield* Semaphore.make(1)
|
|
57
|
+
|
|
58
|
+
const subscription = yield* hub.subscribe
|
|
59
|
+
yield* Stream.fromSubscription(subscription).pipe(
|
|
60
|
+
Stream.runForEach((event) =>
|
|
61
|
+
Effect.flatMap(Clock.currentTimeMillis, (at) =>
|
|
62
|
+
Ref.update(state, (current) => reduceTree(current, { _tag: "Event", at, event }))
|
|
63
|
+
).pipe(Effect.andThen(Ref.update(consumed, (count) => count + 1)))
|
|
64
|
+
),
|
|
65
|
+
Effect.forkScoped
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
const render = (rows: number | undefined): Effect.Effect<string> =>
|
|
69
|
+
Effect.gen(function* () {
|
|
70
|
+
const lines = renderTree(yield* Ref.get(state), {
|
|
71
|
+
width: Math.max(90, output.columns() ?? 90),
|
|
72
|
+
colour: output.colour,
|
|
73
|
+
view: yield* Ref.get(view),
|
|
74
|
+
...(rows === undefined ? {} : { height: rows })
|
|
75
|
+
})
|
|
76
|
+
return lines.join("\n")
|
|
77
|
+
})
|
|
78
|
+
/** The full screen fits the terminal; the last frame left behind is whole. */
|
|
79
|
+
const frame = render(undefined)
|
|
80
|
+
const screen = Effect.suspend(() => render(output.rows()))
|
|
81
|
+
|
|
82
|
+
/** Runs `effect` only while the tree has the screen, under the drawing lock. */
|
|
83
|
+
const whileShown = (effect: Effect.Effect<void>): Effect.Effect<void> =>
|
|
84
|
+
lock.withPermit(Effect.flatMap(Ref.get(active), (shown) => (shown ? effect : Effect.void)))
|
|
85
|
+
|
|
86
|
+
const leave = (withFrame: boolean): Effect.Effect<void> =>
|
|
87
|
+
whileShown(
|
|
88
|
+
Effect.gen(function* () {
|
|
89
|
+
yield* Ref.set(active, false)
|
|
90
|
+
yield* output.write(withFrame ? `${leaveScreen}${yield* frame}\n` : leaveScreen)
|
|
91
|
+
})
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
yield* output.write(enterScreen)
|
|
95
|
+
yield* Effect.addFinalizer(() => leave(false))
|
|
96
|
+
yield* Effect.forever(
|
|
97
|
+
Effect.andThen(
|
|
98
|
+
Effect.sleep(redrawEvery),
|
|
99
|
+
whileShown(Effect.flatMap(screen, (text) => output.write(`${home}${text}`)))
|
|
100
|
+
)
|
|
101
|
+
).pipe(Effect.forkScoped)
|
|
102
|
+
yield* Stream.runForEach(keys, (key) =>
|
|
103
|
+
Effect.gen(function* () {
|
|
104
|
+
const next = onTreeKey(yield* Ref.get(view), key, yield* Ref.get(state))
|
|
105
|
+
if (next === "quit") {
|
|
106
|
+
return yield* leave(false)
|
|
107
|
+
}
|
|
108
|
+
yield* Ref.set(view, next)
|
|
109
|
+
})
|
|
110
|
+
).pipe(Effect.forkScoped)
|
|
111
|
+
|
|
112
|
+
const surface: TerminalSurface = {
|
|
113
|
+
palette: classic.palette,
|
|
114
|
+
log: (line) =>
|
|
115
|
+
Effect.flatMap(Ref.get(active), (shown) => (shown ? Effect.void : classic.log(line))),
|
|
116
|
+
setStatus: (label) =>
|
|
117
|
+
Effect.flatMap(Ref.get(active), (shown) => (shown ? Effect.void : classic.setStatus(label))),
|
|
118
|
+
// A prompt needs the ordinary screen: step out of the tree for its length.
|
|
119
|
+
suspend: (effect) =>
|
|
120
|
+
Effect.flatMap(Ref.get(active), (shown) =>
|
|
121
|
+
shown
|
|
122
|
+
? Effect.acquireUseRelease(
|
|
123
|
+
lock.withPermit(Effect.andThen(Ref.set(active, false), output.write(leaveScreen))),
|
|
124
|
+
() => classic.suspend(effect),
|
|
125
|
+
() =>
|
|
126
|
+
lock.withPermit(Effect.andThen(Ref.set(active, true), output.write(enterScreen)))
|
|
127
|
+
)
|
|
128
|
+
: classic.suspend(effect)
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
surface,
|
|
134
|
+
close: Effect.andThen(awaitConsumed(hub, consumed), leave(true))
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
// ── node wiring ─────────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
const keyNames: Readonly<Record<string, string>> = {
|
|
141
|
+
"\u001b[A": "up",
|
|
142
|
+
"\u001b[B": "down",
|
|
143
|
+
"\u001b[C": "right",
|
|
144
|
+
"\u001b[D": "left",
|
|
145
|
+
"\r": "enter",
|
|
146
|
+
"\n": "enter",
|
|
147
|
+
"\u001b": "escape"
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const ctrlC = "\u0003"
|
|
151
|
+
|
|
152
|
+
/** Raw terminal input as key names; a burst of plain characters is one key each. */
|
|
153
|
+
export const keyNamesOf = (chunk: string): ReadonlyArray<string> => {
|
|
154
|
+
const named = keyNames[chunk]
|
|
155
|
+
if (named !== undefined) {
|
|
156
|
+
return [named]
|
|
157
|
+
}
|
|
158
|
+
return chunk.startsWith("\u001b") ? [] : [...chunk]
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The terminal's keys while the tree has it (raw mode). Raw mode swallows
|
|
163
|
+
* the interrupt signal, so ctrl-c is handled here: `detach` quits the viewer
|
|
164
|
+
* (`watch`), `interrupt` raises SIGINT so a run aborts as it always has.
|
|
165
|
+
*/
|
|
166
|
+
export const nodeTreeKeys = (ctrlCMeans: "detach" | "interrupt"): Stream.Stream<string> =>
|
|
167
|
+
Stream.callback<string>((queue) =>
|
|
168
|
+
Effect.acquireRelease(
|
|
169
|
+
Effect.sync(() => {
|
|
170
|
+
const input = process.stdin
|
|
171
|
+
if (!input.isTTY) {
|
|
172
|
+
return undefined
|
|
173
|
+
}
|
|
174
|
+
const onData = (data: Buffer) => {
|
|
175
|
+
const chunk = data.toString("utf8")
|
|
176
|
+
if (chunk === ctrlC) {
|
|
177
|
+
if (ctrlCMeans === "interrupt") {
|
|
178
|
+
process.kill(process.pid, "SIGINT")
|
|
179
|
+
} else {
|
|
180
|
+
Queue.offerUnsafe(queue, "q")
|
|
181
|
+
}
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
for (const key of keyNamesOf(chunk)) {
|
|
185
|
+
Queue.offerUnsafe(queue, key)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
input.setRawMode(true)
|
|
189
|
+
input.resume()
|
|
190
|
+
input.on("data", onData)
|
|
191
|
+
return onData
|
|
192
|
+
}),
|
|
193
|
+
(onData) =>
|
|
194
|
+
Effect.sync(() => {
|
|
195
|
+
if (onData !== undefined) {
|
|
196
|
+
process.stdin.off("data", onData)
|
|
197
|
+
process.stdin.setRawMode(false)
|
|
198
|
+
process.stdin.pause()
|
|
199
|
+
}
|
|
200
|
+
})
|
|
201
|
+
)
|
|
202
|
+
)
|
package/src/FlowArgs.ts
CHANGED
|
@@ -32,6 +32,7 @@ commands:
|
|
|
32
32
|
environment:
|
|
33
33
|
LLM4TS_CODER coding agent: claude|codex|gemini|pi|agy|grok|cursor|opencode (default: claude)
|
|
34
34
|
LLM4TS_VERBOSITY terminal verbosity: quiet|normal|verbose
|
|
35
|
+
LLM4TS_UI tree: the full-screen agent tree (ADR 0022; q returns to the classic view)
|
|
35
36
|
OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY
|
|
36
37
|
credentials for API providers
|
|
37
38
|
|
package/src/FlowRunner.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { join } from "node:path"
|
|
2
|
+
import * as Cause from "effect/Cause"
|
|
2
3
|
import * as Clock from "effect/Clock"
|
|
3
4
|
import * as Effect from "effect/Effect"
|
|
5
|
+
import * as Exit from "effect/Exit"
|
|
4
6
|
import * as Ref from "effect/Ref"
|
|
5
7
|
import type * as Scope from "effect/Scope"
|
|
6
8
|
import type { ConnectorConfig } from "@llm4ts/core/ConnectorConfig"
|
|
@@ -55,7 +57,8 @@ import {
|
|
|
55
57
|
type FlowEventsShape
|
|
56
58
|
} from "@llm4ts/flow/FlowEvents"
|
|
57
59
|
import { FlowContext, type ContextOptions, type FlowContextShape } from "@llm4ts/flow/FlowContext"
|
|
58
|
-
import { makeFlowRecorder } from "@llm4ts/flow/FlowRecorder"
|
|
60
|
+
import { makeFlowRecorder, type RunOutcome } from "@llm4ts/flow/FlowRecorder"
|
|
61
|
+
import { makeAgentTreeHost, nodeTreeKeys } from "./AgentTreeSurface.ts"
|
|
59
62
|
import { makeJudgmentLog, type JudgmentLogShape } from "@llm4ts/flow/JudgmentLog"
|
|
60
63
|
import { makeGitHubTool } from "@llm4ts/flow/GitHubTool"
|
|
61
64
|
import { makeGitTool } from "@llm4ts/flow/GitTool"
|
|
@@ -748,7 +751,24 @@ export const runWithBundle = Effect.fn("@llm4ts/runner/FlowRunner.runWithBundle"
|
|
|
748
751
|
options.costLedgerPath ??
|
|
749
752
|
(persistRun ? join(options.workDir, ".llm4ts", CostLedgerFileName) : undefined)
|
|
750
753
|
const tracker = bundle.tracker
|
|
751
|
-
const
|
|
754
|
+
const classic = options.surface ?? (yield* makeTerminalSurface(environment))
|
|
755
|
+
// The agent tree (ADR 0022) takes the screen only where it fits: a real
|
|
756
|
+
// terminal, colour allowed, at least 90 columns; the classic view otherwise.
|
|
757
|
+
const tree =
|
|
758
|
+
options.surface === undefined && wantsAgentTree(environment, process.stdout, process.stdin)
|
|
759
|
+
? yield* makeAgentTreeHost(
|
|
760
|
+
bundle.events,
|
|
761
|
+
classic,
|
|
762
|
+
{
|
|
763
|
+
write: (text) => Effect.sync(() => void process.stdout.write(text)),
|
|
764
|
+
columns: () => process.stdout.columns,
|
|
765
|
+
rows: () => process.stdout.rows,
|
|
766
|
+
colour: true
|
|
767
|
+
},
|
|
768
|
+
nodeTreeKeys("interrupt")
|
|
769
|
+
)
|
|
770
|
+
: undefined
|
|
771
|
+
const surface = tree?.surface ?? classic
|
|
752
772
|
const palette = surface.palette
|
|
753
773
|
const terminal = yield* consumeTerminalEvents(bundle.events, surface, verbosity, {
|
|
754
774
|
timestamps: environment.LLM4TS_TIMESTAMPS === "1" || environment.LLM4TS_TIMESTAMPS === "true"
|
|
@@ -827,8 +847,12 @@ export const runWithBundle = Effect.fn("@llm4ts/runner/FlowRunner.runWithBundle"
|
|
|
827
847
|
surface.log(palette.fail(`cost ledger not written: ${describeFlowError(error)}`))
|
|
828
848
|
)
|
|
829
849
|
)
|
|
830
|
-
|
|
831
|
-
|
|
850
|
+
const context: FlowContextShape =
|
|
851
|
+
tracePath === undefined
|
|
852
|
+
? bundle.context
|
|
853
|
+
: { ...bundle.context, trace: { runId: bundle.runId, path: tracePath } }
|
|
854
|
+
return yield* body(context).pipe(
|
|
855
|
+
Effect.provideService(FlowContext, context),
|
|
832
856
|
Effect.andThen((value) => Effect.as(enforceBudget, value)),
|
|
833
857
|
Effect.ensuring(
|
|
834
858
|
Effect.all(
|
|
@@ -859,6 +883,7 @@ export const runWithBundle = Effect.fn("@llm4ts/runner/FlowRunner.runWithBundle"
|
|
|
859
883
|
)
|
|
860
884
|
)
|
|
861
885
|
),
|
|
886
|
+
Effect.andThen(tree === undefined ? Effect.void : tree.close),
|
|
862
887
|
Effect.andThen(appendLedger),
|
|
863
888
|
Effect.andThen(surface.setStatus(undefined)),
|
|
864
889
|
Effect.andThen(tracker.summary),
|
|
@@ -896,10 +921,32 @@ export const runWithBundle = Effect.fn("@llm4ts/runner/FlowRunner.runWithBundle"
|
|
|
896
921
|
)}`
|
|
897
922
|
)
|
|
898
923
|
})
|
|
924
|
+
),
|
|
925
|
+
// Last, after every event is in: the trace's final line says how it ended.
|
|
926
|
+
Effect.onExit((exit) =>
|
|
927
|
+
recorder === undefined ? Effect.void : recorder.end(runOutcomeOf(exit))
|
|
899
928
|
)
|
|
900
929
|
)
|
|
901
930
|
})
|
|
902
931
|
|
|
932
|
+
const wantsAgentTree = (
|
|
933
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
934
|
+
output: { readonly isTTY?: boolean; readonly columns?: number },
|
|
935
|
+
input: { readonly isTTY?: boolean }
|
|
936
|
+
): boolean =>
|
|
937
|
+
environment.LLM4TS_UI === "tree" &&
|
|
938
|
+
environment.NO_COLOR === undefined &&
|
|
939
|
+
output.isTTY === true &&
|
|
940
|
+
input.isTTY === true &&
|
|
941
|
+
(output.columns ?? 0) >= 90
|
|
942
|
+
|
|
943
|
+
const runOutcomeOf = <A, E>(exit: Exit.Exit<A, E>): RunOutcome =>
|
|
944
|
+
Exit.isSuccess(exit)
|
|
945
|
+
? "completed"
|
|
946
|
+
: Cause.hasInterruptsOnly(exit.cause)
|
|
947
|
+
? "interrupted"
|
|
948
|
+
: "failed"
|
|
949
|
+
|
|
903
950
|
export const runEmbedded = Effect.fn("@llm4ts/runner/FlowRunner.runEmbedded")(function* <A, E, R>(
|
|
904
951
|
options: FlowRunnerOptions,
|
|
905
952
|
body: (context: FlowContextShape) => Effect.Effect<A, E, R>,
|
package/src/Terminal.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
awaitConsumed,
|
|
12
12
|
type FlowEvent,
|
|
13
13
|
type FlowEventHub,
|
|
14
|
+
rosterEventMessage,
|
|
14
15
|
type ReviewFindings
|
|
15
16
|
} from "@llm4ts/flow/FlowEvents"
|
|
16
17
|
|
|
@@ -33,11 +34,14 @@ export const parseVerbosity = (value: string | undefined): Verbosity => {
|
|
|
33
34
|
export const rendersEvent = (verbosity: Verbosity, event: FlowEvent): boolean => {
|
|
34
35
|
switch (event._tag) {
|
|
35
36
|
// Advise mode publishes a separate Info event for the operator; progress
|
|
36
|
-
// feeds the status rows; a blackboard run is for the trace file
|
|
37
|
-
//
|
|
37
|
+
// feeds the status rows; a blackboard run is for the trace file; a lease
|
|
38
|
+
// ending is routine; the story's outcome already says whether the judge
|
|
39
|
+
// cleared it. None is a line of its own.
|
|
38
40
|
case "JudgmentObserved":
|
|
39
41
|
case "BlackboardRun":
|
|
40
42
|
case "UsageProgress":
|
|
43
|
+
case "ExecutorReleased":
|
|
44
|
+
case "StoryJudged":
|
|
41
45
|
return false
|
|
42
46
|
case "StageStarted":
|
|
43
47
|
case "StageCompleted":
|
|
@@ -237,6 +241,7 @@ export const terminalLine = (
|
|
|
237
241
|
case "JudgmentObserved":
|
|
238
242
|
case "BlackboardRun":
|
|
239
243
|
case "UsageProgress":
|
|
244
|
+
case "StoryJudged":
|
|
240
245
|
return ""
|
|
241
246
|
case "StageStarted":
|
|
242
247
|
return palette.stageStart(safe(event.stage))
|
|
@@ -266,6 +271,14 @@ export const terminalLine = (
|
|
|
266
271
|
return palette.fail(`capability unenforceable: ${safe(event.detail)}`)
|
|
267
272
|
case "Declassified":
|
|
268
273
|
return palette.info(`declassified: ${safe(event.label)}`)
|
|
274
|
+
case "ExecutorLeased":
|
|
275
|
+
case "ExecutorReleased":
|
|
276
|
+
case "ExecutorExcluded":
|
|
277
|
+
case "ExecutorResumed":
|
|
278
|
+
case "ExecutorHandedOver": {
|
|
279
|
+
const message = rosterEventMessage(event)
|
|
280
|
+
return message === undefined ? "" : palette.info(safe(message))
|
|
281
|
+
}
|
|
269
282
|
}
|
|
270
283
|
}
|
|
271
284
|
|
package/src/Watch.ts
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { readdirSync, existsSync, statSync } from "node:fs"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
import * as Duration from "effect/Duration"
|
|
4
|
+
import * as Effect from "effect/Effect"
|
|
5
|
+
import * as Queue from "effect/Queue"
|
|
6
|
+
import * as Ref from "effect/Ref"
|
|
7
|
+
import * as Schema from "effect/Schema"
|
|
8
|
+
import * as Stream from "effect/Stream"
|
|
9
|
+
import { Board, BoardVersion } from "@llm4ts/flow/BoardSync"
|
|
10
|
+
import { readEpicRuns, type EpicRun } from "@llm4ts/flow/EpicRuns"
|
|
11
|
+
import type { FlowError } from "@llm4ts/flow/FlowError"
|
|
12
|
+
import { loadVersioned, type PlainFileStoreShape } from "@llm4ts/flow/Persistence"
|
|
13
|
+
import { readTrace } from "@llm4ts/flow/Replay"
|
|
14
|
+
import {
|
|
15
|
+
emptyTree,
|
|
16
|
+
initialView,
|
|
17
|
+
onTreeKey,
|
|
18
|
+
reduceTree,
|
|
19
|
+
renderTree,
|
|
20
|
+
treeInputsOfTrace,
|
|
21
|
+
type TreeInput,
|
|
22
|
+
type TreeState,
|
|
23
|
+
type TreeView
|
|
24
|
+
} from "./AgentTree.ts"
|
|
25
|
+
import { nodeTreeKeys } from "./AgentTreeSurface.ts"
|
|
26
|
+
import { nodeListTraces, TraceDirectoryName } from "./Costs.ts"
|
|
27
|
+
import { nodePlainFileStore } from "./NodePlainFileStore.ts"
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* `llm4ts watch` (ADR 0022): the agent tree over a trace file — a live run
|
|
31
|
+
* followed as it grows, a finished run's last frame, or a replay on the
|
|
32
|
+
* trace's own timestamps. The trace is the only source; the epic's board
|
|
33
|
+
* adds the stories that have not started.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
export interface WatchOptions {
|
|
37
|
+
readonly repo: string
|
|
38
|
+
/** A trace file; the newest in the repository's `.llm4ts/` when omitted. */
|
|
39
|
+
readonly trace?: string
|
|
40
|
+
/** An epic id: its latest recorded run. */
|
|
41
|
+
readonly epic?: string
|
|
42
|
+
readonly replay?: boolean
|
|
43
|
+
/** Replay speed-up (default 10). */
|
|
44
|
+
readonly speed?: number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface WatchOutput {
|
|
48
|
+
readonly write: (text: string) => Effect.Effect<void>
|
|
49
|
+
readonly columns: () => number | undefined
|
|
50
|
+
readonly rows: () => number | undefined
|
|
51
|
+
/** A terminal a person looks at: full-screen, keys, following. */
|
|
52
|
+
readonly interactive: boolean
|
|
53
|
+
readonly colour: boolean
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface WatchDependencies {
|
|
57
|
+
readonly files: PlainFileStoreShape
|
|
58
|
+
readonly listTraces: (directory: string) => ReadonlyArray<string>
|
|
59
|
+
/** Epic folder names under `.llm4ts/epics/`. */
|
|
60
|
+
readonly listEpics: (directory: string) => ReadonlyArray<string>
|
|
61
|
+
readonly output: WatchOutput
|
|
62
|
+
/** Key names as `onTreeKey` reads them: "up", "enter", "q", … */
|
|
63
|
+
readonly keys: Stream.Stream<string>
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export class WatchTargetMissing extends Schema.TaggedError<WatchTargetMissing>()(
|
|
67
|
+
"WatchTargetMissing",
|
|
68
|
+
{ message: Schema.String }
|
|
69
|
+
) {}
|
|
70
|
+
|
|
71
|
+
interface WatchTarget {
|
|
72
|
+
readonly tracePath: string
|
|
73
|
+
readonly epicDir: string | undefined
|
|
74
|
+
readonly run: EpicRun | undefined
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const epicsDirOf = (repo: string): string => join(repo, TraceDirectoryName, "epics")
|
|
78
|
+
|
|
79
|
+
/** The epic whose `runs.jsonl` names this trace, if one does. */
|
|
80
|
+
const epicOfTrace = (
|
|
81
|
+
tracePath: string,
|
|
82
|
+
repo: string,
|
|
83
|
+
dependencies: WatchDependencies
|
|
84
|
+
): Effect.Effect<{ readonly dir: string; readonly run: EpicRun } | undefined, FlowError> =>
|
|
85
|
+
Effect.gen(function* () {
|
|
86
|
+
const epicsDir = epicsDirOf(repo)
|
|
87
|
+
for (const epic of dependencies.listEpics(epicsDir)) {
|
|
88
|
+
const dir = join(epicsDir, epic)
|
|
89
|
+
const run = [...(yield* readEpicRuns(dependencies.files, dir))]
|
|
90
|
+
.reverse()
|
|
91
|
+
.find((candidate) => candidate.tracePath === tracePath)
|
|
92
|
+
if (run !== undefined) {
|
|
93
|
+
return { dir, run }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return undefined
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
const resolveTarget = (
|
|
100
|
+
options: WatchOptions,
|
|
101
|
+
dependencies: WatchDependencies
|
|
102
|
+
): Effect.Effect<WatchTarget, FlowError | WatchTargetMissing> =>
|
|
103
|
+
Effect.gen(function* () {
|
|
104
|
+
if (options.epic !== undefined) {
|
|
105
|
+
const dir = join(epicsDirOf(options.repo), options.epic)
|
|
106
|
+
const run = (yield* readEpicRuns(dependencies.files, dir)).at(-1)
|
|
107
|
+
if (run === undefined) {
|
|
108
|
+
return yield* WatchTargetMissing.make({
|
|
109
|
+
message: `epic ${options.epic} has no recorded run in ${dir}/runs.jsonl`
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
return { tracePath: run.tracePath, epicDir: dir, run }
|
|
113
|
+
}
|
|
114
|
+
const tracePath =
|
|
115
|
+
options.trace ?? dependencies.listTraces(join(options.repo, TraceDirectoryName)).at(-1)
|
|
116
|
+
if (tracePath === undefined) {
|
|
117
|
+
return yield* WatchTargetMissing.make({
|
|
118
|
+
message: `no trace in ${join(options.repo, TraceDirectoryName)}; pass a trace file or --epic`
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
const epic = yield* epicOfTrace(tracePath, options.repo, dependencies)
|
|
122
|
+
return { tracePath, epicDir: epic?.dir, run: epic?.run }
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
const actionWords = (run: EpicRun | undefined): string | undefined => {
|
|
126
|
+
if (run === undefined) {
|
|
127
|
+
return undefined
|
|
128
|
+
}
|
|
129
|
+
switch (run.action) {
|
|
130
|
+
case "RunPlan":
|
|
131
|
+
return "run plan"
|
|
132
|
+
case "RunRound":
|
|
133
|
+
return `round ${run.round ?? "?"}`
|
|
134
|
+
case "PlanRound":
|
|
135
|
+
return `plan round ${run.round ?? "?"}`
|
|
136
|
+
case "Land":
|
|
137
|
+
return "land"
|
|
138
|
+
default:
|
|
139
|
+
return run.action
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The tree before any event: the epic's board, when the trace belongs to one. */
|
|
144
|
+
const startingTree = (
|
|
145
|
+
target: WatchTarget,
|
|
146
|
+
files: PlainFileStoreShape
|
|
147
|
+
): Effect.Effect<TreeState, FlowError> =>
|
|
148
|
+
Effect.gen(function* () {
|
|
149
|
+
const action = actionWords(target.run)
|
|
150
|
+
if (target.epicDir === undefined) {
|
|
151
|
+
return emptyTree(action === undefined ? {} : { action })
|
|
152
|
+
}
|
|
153
|
+
const boardDir =
|
|
154
|
+
target.run?.round === undefined
|
|
155
|
+
? target.epicDir
|
|
156
|
+
: join(target.epicDir, "rounds", String(target.run.round))
|
|
157
|
+
const board = yield* loadVersioned(files, join(boardDir, "board.json"), BoardVersion, Board)
|
|
158
|
+
const epicId = target.epicDir.split("/").at(-1) ?? "epic"
|
|
159
|
+
return emptyTree({
|
|
160
|
+
title: board === undefined ? `epic ${epicId}` : board.title.replace(/^Epic:\s*/u, "epic "),
|
|
161
|
+
stories: (board?.items ?? []).map((item) => ({ id: item.id, status: item.status })),
|
|
162
|
+
...(action === undefined ? {} : { action })
|
|
163
|
+
})
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
const readInputs = (
|
|
167
|
+
target: WatchTarget,
|
|
168
|
+
files: PlainFileStoreShape
|
|
169
|
+
): Effect.Effect<ReadonlyArray<TreeInput>, FlowError> =>
|
|
170
|
+
Effect.map(readTrace(files, target.tracePath), treeInputsOfTrace)
|
|
171
|
+
|
|
172
|
+
const enterScreen = "\u001b[?1049h\u001b[?25l"
|
|
173
|
+
const leaveScreen = "\u001b[?25h\u001b[?1049l"
|
|
174
|
+
const home = "\u001b[H\u001b[2J"
|
|
175
|
+
const pollInterval = Duration.millis(500)
|
|
176
|
+
const replayGapCap = 2_000
|
|
177
|
+
|
|
178
|
+
const draw = (state: TreeState, view: TreeView, output: WatchOutput, height?: number): string =>
|
|
179
|
+
renderTree(state, {
|
|
180
|
+
width: Math.max(90, output.columns() ?? 90),
|
|
181
|
+
colour: output.colour,
|
|
182
|
+
view,
|
|
183
|
+
...(height === undefined ? {} : { height })
|
|
184
|
+
}).join("\n")
|
|
185
|
+
|
|
186
|
+
/** A full-screen frame: no taller than the terminal. */
|
|
187
|
+
const drawScreen = (state: TreeState, view: TreeView, output: WatchOutput): string =>
|
|
188
|
+
draw(state, view, output, output.rows())
|
|
189
|
+
|
|
190
|
+
export const makeWatchProgram = Effect.fn("@llm4ts/runner/Watch.make")(function* (
|
|
191
|
+
options: WatchOptions,
|
|
192
|
+
dependencies: WatchDependencies
|
|
193
|
+
): Effect.fn.Return<void, FlowError | WatchTargetMissing> {
|
|
194
|
+
const target = yield* resolveTarget(options, dependencies)
|
|
195
|
+
const { files, output } = dependencies
|
|
196
|
+
const fold = (inputs: ReadonlyArray<TreeInput>): Effect.Effect<TreeState, FlowError> =>
|
|
197
|
+
Effect.map(startingTree(target, files), (start) => inputs.reduce(reduceTree, start))
|
|
198
|
+
|
|
199
|
+
if (!output.interactive) {
|
|
200
|
+
const state = yield* Effect.flatMap(readInputs(target, files), fold)
|
|
201
|
+
return yield* output.write(`${draw(state, initialView, output)}\n`)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
yield* Effect.scoped(
|
|
205
|
+
Effect.gen(function* () {
|
|
206
|
+
const keys = yield* Queue.unbounded<string>()
|
|
207
|
+
yield* Effect.forkScoped(
|
|
208
|
+
Stream.runForEach(dependencies.keys, (key) => Queue.offer(keys, key))
|
|
209
|
+
)
|
|
210
|
+
const view = yield* Ref.make<TreeView>(initialView)
|
|
211
|
+
const all = yield* readInputs(target, files)
|
|
212
|
+
// Replay steps through the inputs on their timestamps; otherwise the
|
|
213
|
+
// whole trace is shown and re-read while the run goes on.
|
|
214
|
+
const shown = yield* Ref.make(options.replay === true ? Math.min(1, all.length) : all.length)
|
|
215
|
+
const inputs = yield* Ref.make(all)
|
|
216
|
+
const speed = Math.max(1, options.speed ?? 10)
|
|
217
|
+
|
|
218
|
+
/** Waits for a key or for `wait`; true when the key was `q`. */
|
|
219
|
+
const awaitKey = (wait: Duration.Duration): Effect.Effect<boolean> =>
|
|
220
|
+
Effect.raceFirst(
|
|
221
|
+
Effect.flatMap(Queue.take(keys), (key) =>
|
|
222
|
+
Effect.gen(function* () {
|
|
223
|
+
const state = yield* Effect.orDie(
|
|
224
|
+
Effect.flatMap(Ref.get(inputs), (current) =>
|
|
225
|
+
Effect.flatMap(Ref.get(shown), (count) => fold(current.slice(0, count)))
|
|
226
|
+
)
|
|
227
|
+
)
|
|
228
|
+
const next = onTreeKey(yield* Ref.get(view), key, state)
|
|
229
|
+
if (next === "quit") {
|
|
230
|
+
return true
|
|
231
|
+
}
|
|
232
|
+
yield* Ref.set(view, next)
|
|
233
|
+
return false
|
|
234
|
+
})
|
|
235
|
+
),
|
|
236
|
+
Effect.as(Effect.sleep(wait), false)
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
yield* output.write(enterScreen)
|
|
240
|
+
yield* Effect.gen(function* () {
|
|
241
|
+
while (true) {
|
|
242
|
+
const current = yield* Ref.get(inputs)
|
|
243
|
+
const count = yield* Ref.get(shown)
|
|
244
|
+
const state = yield* fold(current.slice(0, count))
|
|
245
|
+
yield* output.write(`${home}${drawScreen(state, yield* Ref.get(view), output)}`)
|
|
246
|
+
const replaying = options.replay === true && count < current.length
|
|
247
|
+
const gap = replaying
|
|
248
|
+
? Math.min(replayGapCap, (current[count]?.at ?? 0) - (current[count - 1]?.at ?? 0)) /
|
|
249
|
+
speed
|
|
250
|
+
: Duration.toMillis(pollInterval)
|
|
251
|
+
if (yield* awaitKey(Duration.millis(Math.max(0, gap)))) {
|
|
252
|
+
return
|
|
253
|
+
}
|
|
254
|
+
if (replaying) {
|
|
255
|
+
yield* Ref.update(shown, (shownCount) => shownCount + 1)
|
|
256
|
+
} else if (state.ended === undefined) {
|
|
257
|
+
const reread = yield* readInputs(target, files)
|
|
258
|
+
yield* Ref.set(inputs, reread)
|
|
259
|
+
yield* Ref.set(shown, reread.length)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}).pipe(Effect.ensuring(output.write(leaveScreen)))
|
|
263
|
+
})
|
|
264
|
+
)
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
// ── node wiring ─────────────────────────────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
const nodeListEpics = (directory: string): ReadonlyArray<string> =>
|
|
270
|
+
existsSync(directory)
|
|
271
|
+
? readdirSync(directory).filter((name) => statSync(join(directory, name)).isDirectory())
|
|
272
|
+
: []
|
|
273
|
+
|
|
274
|
+
export const nodeWatchDependencies = (
|
|
275
|
+
environment: Readonly<Record<string, string | undefined>> = process.env
|
|
276
|
+
): WatchDependencies => {
|
|
277
|
+
const interactive = process.stdout.isTTY === true && process.stdin.isTTY === true
|
|
278
|
+
return {
|
|
279
|
+
files: nodePlainFileStore,
|
|
280
|
+
listTraces: nodeListTraces,
|
|
281
|
+
listEpics: nodeListEpics,
|
|
282
|
+
output: {
|
|
283
|
+
write: (text) => Effect.sync(() => void process.stdout.write(text)),
|
|
284
|
+
columns: () => process.stdout.columns,
|
|
285
|
+
rows: () => process.stdout.rows,
|
|
286
|
+
interactive,
|
|
287
|
+
colour: process.stdout.isTTY === true && environment.NO_COLOR === undefined
|
|
288
|
+
},
|
|
289
|
+
keys: nodeTreeKeys("detach")
|
|
290
|
+
}
|
|
291
|
+
}
|