@llm4ts/runner 2.19.0 → 2.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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 surface = options.surface ?? (yield* makeTerminalSurface(environment))
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
- return yield* body(bundle.context).pipe(
831
- Effect.provideService(FlowContext, bundle.context),
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. None is
37
- // a line of its own.
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
+ }