@llm4ts/runner 0.1.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.
Files changed (98) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +61 -0
  3. package/dist/Cli.d.ts +5 -0
  4. package/dist/Cli.d.ts.map +1 -0
  5. package/dist/Cli.js +36 -0
  6. package/dist/Cli.js.map +1 -0
  7. package/dist/CoderPolicy.d.ts +12 -0
  8. package/dist/CoderPolicy.d.ts.map +1 -0
  9. package/dist/CoderPolicy.js +59 -0
  10. package/dist/CoderPolicy.js.map +1 -0
  11. package/dist/Connectors.d.ts +28 -0
  12. package/dist/Connectors.d.ts.map +1 -0
  13. package/dist/Connectors.js +165 -0
  14. package/dist/Connectors.js.map +1 -0
  15. package/dist/Doctor.d.ts +4 -0
  16. package/dist/Doctor.d.ts.map +1 -0
  17. package/dist/Doctor.js +33 -0
  18. package/dist/Doctor.js.map +1 -0
  19. package/dist/FlowArgs.d.ts +34 -0
  20. package/dist/FlowArgs.d.ts.map +1 -0
  21. package/dist/FlowArgs.js +136 -0
  22. package/dist/FlowArgs.js.map +1 -0
  23. package/dist/FlowRunner.d.ts +48 -0
  24. package/dist/FlowRunner.d.ts.map +1 -0
  25. package/dist/FlowRunner.js +97 -0
  26. package/dist/FlowRunner.js.map +1 -0
  27. package/dist/McpStdio.d.ts +7 -0
  28. package/dist/McpStdio.d.ts.map +1 -0
  29. package/dist/McpStdio.js +19 -0
  30. package/dist/McpStdio.js.map +1 -0
  31. package/dist/NodeGeminiCliExecutor.d.ts +7 -0
  32. package/dist/NodeGeminiCliExecutor.d.ts.map +1 -0
  33. package/dist/NodeGeminiCliExecutor.js +26 -0
  34. package/dist/NodeGeminiCliExecutor.js.map +1 -0
  35. package/dist/NodeHttpClient.d.ts +5 -0
  36. package/dist/NodeHttpClient.d.ts.map +1 -0
  37. package/dist/NodeHttpClient.js +109 -0
  38. package/dist/NodeHttpClient.js.map +1 -0
  39. package/dist/NodePlainFileStore.d.ts +5 -0
  40. package/dist/NodePlainFileStore.d.ts.map +1 -0
  41. package/dist/NodePlainFileStore.js +64 -0
  42. package/dist/NodePlainFileStore.js.map +1 -0
  43. package/dist/NodeProcessExecutor.d.ts +5 -0
  44. package/dist/NodeProcessExecutor.d.ts.map +1 -0
  45. package/dist/NodeProcessExecutor.js +212 -0
  46. package/dist/NodeProcessExecutor.js.map +1 -0
  47. package/dist/NodeRuntime.d.ts +4 -0
  48. package/dist/NodeRuntime.d.ts.map +1 -0
  49. package/dist/NodeRuntime.js +11 -0
  50. package/dist/NodeRuntime.js.map +1 -0
  51. package/dist/NodeTemporaryFiles.d.ts +5 -0
  52. package/dist/NodeTemporaryFiles.d.ts.map +1 -0
  53. package/dist/NodeTemporaryFiles.js +30 -0
  54. package/dist/NodeTemporaryFiles.js.map +1 -0
  55. package/dist/NodeWorkspace.d.ts +6 -0
  56. package/dist/NodeWorkspace.d.ts.map +1 -0
  57. package/dist/NodeWorkspace.js +231 -0
  58. package/dist/NodeWorkspace.js.map +1 -0
  59. package/dist/Package.d.ts +2 -0
  60. package/dist/Package.d.ts.map +1 -0
  61. package/dist/Package.js +2 -0
  62. package/dist/Package.js.map +1 -0
  63. package/dist/ReviewFingerprint.d.ts +2 -0
  64. package/dist/ReviewFingerprint.d.ts.map +1 -0
  65. package/dist/ReviewFingerprint.js +13 -0
  66. package/dist/ReviewFingerprint.js.map +1 -0
  67. package/dist/Terminal.d.ts +46 -0
  68. package/dist/Terminal.d.ts.map +1 -0
  69. package/dist/Terminal.js +226 -0
  70. package/dist/Terminal.js.map +1 -0
  71. package/dist/TerminalInteraction.d.ts +4 -0
  72. package/dist/TerminalInteraction.d.ts.map +1 -0
  73. package/dist/TerminalInteraction.js +25 -0
  74. package/dist/TerminalInteraction.js.map +1 -0
  75. package/dist/cli-main.d.ts +3 -0
  76. package/dist/cli-main.d.ts.map +1 -0
  77. package/dist/cli-main.js +39 -0
  78. package/dist/cli-main.js.map +1 -0
  79. package/package.json +74 -0
  80. package/src/Cli.ts +51 -0
  81. package/src/CoderPolicy.ts +82 -0
  82. package/src/Connectors.ts +225 -0
  83. package/src/Doctor.ts +44 -0
  84. package/src/FlowArgs.ts +167 -0
  85. package/src/FlowRunner.ts +220 -0
  86. package/src/McpStdio.ts +41 -0
  87. package/src/NodeGeminiCliExecutor.ts +56 -0
  88. package/src/NodeHttpClient.ts +224 -0
  89. package/src/NodePlainFileStore.ts +75 -0
  90. package/src/NodeProcessExecutor.ts +331 -0
  91. package/src/NodeRuntime.ts +19 -0
  92. package/src/NodeTemporaryFiles.ts +46 -0
  93. package/src/NodeWorkspace.ts +282 -0
  94. package/src/Package.ts +1 -0
  95. package/src/ReviewFingerprint.ts +13 -0
  96. package/src/Terminal.ts +329 -0
  97. package/src/TerminalInteraction.ts +43 -0
  98. package/src/cli-main.ts +53 -0
@@ -0,0 +1,46 @@
1
+ import { mkdtemp, rm, writeFile } from "node:fs/promises"
2
+ import { tmpdir } from "node:os"
3
+ import { join } from "node:path"
4
+ import * as Effect from "effect/Effect"
5
+ import * as Layer from "effect/Layer"
6
+ import { TemporaryFiles, type TemporaryFilesShape } from "@llm4ts/core/TemporaryFiles"
7
+ import { ProviderError } from "@llm4ts/core/Errors"
8
+
9
+ interface TemporaryFile {
10
+ readonly directory: string
11
+ readonly path: string
12
+ }
13
+
14
+ const errorMessage = (error: unknown): string =>
15
+ error instanceof Error ? error.message : String(error)
16
+
17
+ export const nodeTemporaryFiles: TemporaryFilesShape = {
18
+ write: (prefix, suffix, contents) =>
19
+ Effect.acquireRelease(
20
+ Effect.tryPromise({
21
+ try: async (): Promise<TemporaryFile> => {
22
+ const directory = await mkdtemp(join(tmpdir(), prefix))
23
+ const path = join(directory, `value${suffix}`)
24
+ await writeFile(path, contents, "utf8")
25
+ return {
26
+ directory,
27
+ path
28
+ }
29
+ },
30
+ catch: (error) =>
31
+ ProviderError.make({
32
+ message: `Failed to create temporary file: ${errorMessage(error)}`,
33
+ cause: error
34
+ })
35
+ }),
36
+ ({ directory }) =>
37
+ Effect.promise(() =>
38
+ rm(directory, {
39
+ recursive: true,
40
+ force: true
41
+ })
42
+ ).pipe(Effect.catchCause(() => Effect.void))
43
+ ).pipe(Effect.map(({ path }) => path))
44
+ }
45
+
46
+ export const NodeTemporaryFilesLive = Layer.succeed(TemporaryFiles, nodeTemporaryFiles)
@@ -0,0 +1,282 @@
1
+ import {
2
+ lstat,
3
+ mkdir,
4
+ readFile,
5
+ readdir,
6
+ realpath,
7
+ stat,
8
+ writeFile,
9
+ appendFile
10
+ } from "node:fs/promises"
11
+ import { dirname, isAbsolute, relative, resolve, sep } from "node:path"
12
+ import * as Effect from "effect/Effect"
13
+ import * as Layer from "effect/Layer"
14
+ import {
15
+ SearchMatch,
16
+ Workspace,
17
+ defaultWorkspaceLimits,
18
+ type WorkspaceError,
19
+ type WorkspaceLimits,
20
+ type WorkspaceShape
21
+ } from "@llm4ts/flow/Workspace"
22
+ import { WorkspaceIoError, WorkspaceLimitError, WorkspacePathError } from "@llm4ts/flow/FlowError"
23
+
24
+ const errorMessage = (error: unknown): string =>
25
+ error instanceof Error ? error.message : String(error)
26
+
27
+ const isMissing = (error: unknown): boolean =>
28
+ error instanceof Error && "code" in error && error.code === "ENOENT"
29
+
30
+ const contains = (root: string, target: string): boolean => {
31
+ const path = relative(root, target)
32
+ return path === "" || (!path.startsWith(`..${sep}`) && path !== ".." && !isAbsolute(path))
33
+ }
34
+
35
+ const globRegex = (glob: string): RegExp => {
36
+ let source = ""
37
+ for (let index = 0; index < glob.length; index += 1) {
38
+ const character = glob.charAt(index)
39
+ if (character === "*" && glob.charAt(index + 1) === "*") {
40
+ source += ".*"
41
+ index += 1
42
+ } else if (character === "*") {
43
+ source += "[^/]*"
44
+ } else if (character === "?") {
45
+ source += "[^/]"
46
+ } else {
47
+ source += character.replace(/[\\^$.[\]{}()+|]/g, "\\$&")
48
+ }
49
+ }
50
+ return new RegExp(`^${source}$`)
51
+ }
52
+
53
+ const nearestExistingParent = async (path: string): Promise<string> => {
54
+ let current = path
55
+ while (true) {
56
+ try {
57
+ await lstat(current)
58
+ return current
59
+ } catch (error) {
60
+ if (!isMissing(error)) {
61
+ throw error
62
+ }
63
+ const parent = dirname(current)
64
+ if (parent === current) {
65
+ throw error
66
+ }
67
+ current = parent
68
+ }
69
+ }
70
+ }
71
+
72
+ export const makeNodeWorkspace = Effect.fn("@llm4ts/runner/NodeWorkspace.make")(function* (
73
+ configuredRoot: string,
74
+ limits: WorkspaceLimits = defaultWorkspaceLimits
75
+ ): Effect.fn.Return<WorkspaceShape, WorkspaceError> {
76
+ const root = yield* Effect.tryPromise({
77
+ try: () => realpath(resolve(configuredRoot)),
78
+ catch: (error) =>
79
+ WorkspaceIoError.make({
80
+ operation: "open workspace",
81
+ path: configuredRoot,
82
+ message: errorMessage(error),
83
+ cause: error
84
+ })
85
+ })
86
+
87
+ const resolveSafe = (input: string): Effect.Effect<string, WorkspaceError> =>
88
+ Effect.tryPromise({
89
+ try: async () => {
90
+ const lexical = resolve(root, input)
91
+ if (!contains(root, lexical)) {
92
+ throw WorkspacePathError.make({
93
+ path: input,
94
+ message: "path escapes the configured workspace"
95
+ })
96
+ }
97
+ const existing = await nearestExistingParent(lexical)
98
+ const physical = await realpath(existing)
99
+ if (!contains(root, physical)) {
100
+ throw WorkspacePathError.make({
101
+ path: input,
102
+ message: "path resolves through a symlink outside the configured workspace"
103
+ })
104
+ }
105
+ return lexical
106
+ },
107
+ catch: (error) =>
108
+ error instanceof WorkspacePathError
109
+ ? error
110
+ : WorkspaceIoError.make({
111
+ operation: "resolve",
112
+ path: input,
113
+ message: errorMessage(error),
114
+ cause: error
115
+ })
116
+ })
117
+
118
+ const read = Effect.fn("@llm4ts/runner/NodeWorkspace.read")(function* (
119
+ input: string
120
+ ): Effect.fn.Return<string, WorkspaceError> {
121
+ const path = yield* resolveSafe(input)
122
+ const info = yield* Effect.tryPromise({
123
+ try: () => stat(path),
124
+ catch: (error) =>
125
+ WorkspaceIoError.make({
126
+ operation: "read",
127
+ path: input,
128
+ message: errorMessage(error),
129
+ cause: error
130
+ })
131
+ })
132
+ if (info.size > limits.maxReadBytes) {
133
+ return yield* WorkspaceLimitError.make({
134
+ operation: "read bytes",
135
+ limit: limits.maxReadBytes,
136
+ actual: info.size
137
+ })
138
+ }
139
+ return yield* Effect.tryPromise({
140
+ try: () => readFile(path, "utf8"),
141
+ catch: (error) =>
142
+ WorkspaceIoError.make({
143
+ operation: "read",
144
+ path: input,
145
+ message: errorMessage(error),
146
+ cause: error
147
+ })
148
+ })
149
+ })
150
+
151
+ const writeBounded = (
152
+ operation: "write" | "append",
153
+ input: string,
154
+ contents: string
155
+ ): Effect.Effect<void, WorkspaceError> =>
156
+ Effect.gen(function* () {
157
+ const bytes = Buffer.byteLength(contents)
158
+ if (bytes > limits.maxWriteBytes) {
159
+ return yield* WorkspaceLimitError.make({
160
+ operation: `${operation} bytes`,
161
+ limit: limits.maxWriteBytes,
162
+ actual: bytes
163
+ })
164
+ }
165
+ const path = yield* resolveSafe(input)
166
+ yield* Effect.tryPromise({
167
+ try: async () => {
168
+ await mkdir(dirname(path), { recursive: true })
169
+ if (operation === "write") {
170
+ await writeFile(path, contents, "utf8")
171
+ } else {
172
+ await appendFile(path, contents, "utf8")
173
+ }
174
+ },
175
+ catch: (error) =>
176
+ WorkspaceIoError.make({
177
+ operation,
178
+ path: input,
179
+ message: errorMessage(error),
180
+ cause: error
181
+ })
182
+ })
183
+ })
184
+
185
+ const discover = (pattern = "**/*"): Effect.Effect<ReadonlyArray<string>, WorkspaceError> =>
186
+ Effect.tryPromise({
187
+ try: async () => {
188
+ const normalizedPattern = pattern.replaceAll("\\", "/")
189
+ const matcher = normalizedPattern === "**/*" ? /.*/ : globRegex(normalizedPattern)
190
+ const results: Array<string> = []
191
+ const walk = async (directory: string, depth: number): Promise<void> => {
192
+ if (depth > limits.maxDepth) {
193
+ throw WorkspaceLimitError.make({
194
+ operation: "discovery depth",
195
+ limit: limits.maxDepth,
196
+ actual: depth
197
+ })
198
+ }
199
+ const entries = await readdir(directory, { withFileTypes: true })
200
+ entries.sort((left, right) => left.name.localeCompare(right.name))
201
+ for (const entry of entries) {
202
+ if (entry.isSymbolicLink()) {
203
+ continue
204
+ }
205
+ const absolute = resolve(directory, entry.name)
206
+ const path = relative(root, absolute).split(sep).join("/")
207
+ if (entry.isDirectory()) {
208
+ await walk(absolute, depth + 1)
209
+ } else if (entry.isFile() && matcher.test(path)) {
210
+ results.push(path)
211
+ if (results.length > limits.maxResults) {
212
+ throw WorkspaceLimitError.make({
213
+ operation: "discovery results",
214
+ limit: limits.maxResults,
215
+ actual: results.length
216
+ })
217
+ }
218
+ }
219
+ }
220
+ }
221
+ await walk(root, 0)
222
+ return results
223
+ },
224
+ catch: (error) =>
225
+ error instanceof WorkspaceLimitError
226
+ ? error
227
+ : WorkspaceIoError.make({
228
+ operation: "discover",
229
+ path: root,
230
+ message: errorMessage(error),
231
+ cause: error
232
+ })
233
+ })
234
+
235
+ const search = Effect.fn("@llm4ts/runner/NodeWorkspace.search")(function* (
236
+ query: string,
237
+ pattern = "**/*"
238
+ ): Effect.fn.Return<ReadonlyArray<SearchMatch>, WorkspaceError> {
239
+ const paths = yield* discover(pattern)
240
+ const matches: Array<SearchMatch> = []
241
+ for (const path of paths) {
242
+ const text = yield* read(path)
243
+ const lines = text.split("\n")
244
+ for (let index = 0; index < lines.length; index += 1) {
245
+ const line = lines[index] ?? ""
246
+ if (line.includes(query)) {
247
+ matches.push(
248
+ SearchMatch.make({
249
+ path,
250
+ line: index + 1,
251
+ text: line
252
+ })
253
+ )
254
+ if (matches.length > limits.maxResults) {
255
+ return yield* WorkspaceLimitError.make({
256
+ operation: "search results",
257
+ limit: limits.maxResults,
258
+ actual: matches.length
259
+ })
260
+ }
261
+ }
262
+ }
263
+ }
264
+ return matches
265
+ })
266
+
267
+ return {
268
+ root,
269
+ resolve: resolveSafe,
270
+ read,
271
+ write: (path, contents) => writeBounded("write", path, contents),
272
+ append: (path, contents) => writeBounded("append", path, contents),
273
+ discover,
274
+ search
275
+ }
276
+ })
277
+
278
+ export const NodeWorkspaceLive = (
279
+ root: string,
280
+ limits: WorkspaceLimits = defaultWorkspaceLimits
281
+ ): Layer.Layer<Workspace, WorkspaceError> =>
282
+ Layer.effect(Workspace, makeNodeWorkspace(root, limits))
package/src/Package.ts ADDED
@@ -0,0 +1 @@
1
+ export const packageName = "@llm4ts/runner"
@@ -0,0 +1,13 @@
1
+ import { createHash } from "node:crypto"
2
+
3
+ export const reviewFingerprint = (...parts: ReadonlyArray<string>): string => {
4
+ const digest = createHash("sha256")
5
+ for (const part of parts) {
6
+ const bytes = Buffer.from(part, "utf8")
7
+ const length = Buffer.alloc(4)
8
+ length.writeUInt32BE(bytes.length)
9
+ digest.update(length)
10
+ digest.update(bytes)
11
+ }
12
+ return digest.digest("hex")
13
+ }
@@ -0,0 +1,329 @@
1
+ import type * as Duration from "effect/Duration"
2
+ import * as Effect from "effect/Effect"
3
+ import * as Ref from "effect/Ref"
4
+ import * as Semaphore from "effect/Semaphore"
5
+ import type * as Scope from "effect/Scope"
6
+ import * as Schema from "effect/Schema"
7
+ import * as Stream from "effect/Stream"
8
+ import type { FlowEvent, FlowEventHub } from "@llm4ts/flow/FlowEvents"
9
+
10
+ export const Verbosity = Schema.Literals(["Quiet", "Normal", "Verbose", "Debug"])
11
+ export type Verbosity = typeof Verbosity.Type
12
+
13
+ export const parseVerbosity = (value: string | undefined): Verbosity => {
14
+ switch (value?.trim().toLowerCase()) {
15
+ case "quiet":
16
+ return "Quiet"
17
+ case "verbose":
18
+ return "Verbose"
19
+ case "debug":
20
+ return "Debug"
21
+ default:
22
+ return "Normal"
23
+ }
24
+ }
25
+
26
+ export const rendersEvent = (verbosity: Verbosity, event: FlowEvent): boolean => {
27
+ switch (event._tag) {
28
+ case "StageStarted":
29
+ case "StageCompleted":
30
+ case "StageFailed":
31
+ case "Aborted":
32
+ case "CapabilityDenied":
33
+ case "CapabilityUnenforceable":
34
+ return true
35
+ case "TokensUsed":
36
+ return verbosity === "Verbose" || verbosity === "Debug"
37
+ default:
38
+ return verbosity !== "Quiet"
39
+ }
40
+ }
41
+
42
+ export const terminalSafe = (value: string): string => {
43
+ let safe = ""
44
+ for (let index = 0; index < value.length; index += 1) {
45
+ const code = value.charCodeAt(index)
46
+ const escapedCsi = code === 27 && value.charAt(index + 1) === "["
47
+ if (escapedCsi || code === 155) {
48
+ index += escapedCsi ? 2 : 1
49
+ while (index < value.length) {
50
+ const terminator = value.charCodeAt(index)
51
+ if (terminator >= 64 && terminator <= 126) {
52
+ break
53
+ }
54
+ index += 1
55
+ }
56
+ continue
57
+ }
58
+ const escapedOsc = code === 27 && value.charAt(index + 1) === "]"
59
+ if (escapedOsc || code === 157) {
60
+ index += escapedOsc ? 2 : 1
61
+ while (index < value.length) {
62
+ const current = value.charCodeAt(index)
63
+ if (current === 7) {
64
+ break
65
+ }
66
+ if (current === 27 && value.charAt(index + 1) === "\\") {
67
+ index += 1
68
+ break
69
+ }
70
+ index += 1
71
+ }
72
+ continue
73
+ }
74
+ const allowedWhitespace = code === 9 || code === 10 || code === 13
75
+ if (allowedWhitespace || (code >= 32 && !(code >= 127 && code <= 159))) {
76
+ safe += value.charAt(index)
77
+ }
78
+ }
79
+ return safe
80
+ }
81
+
82
+ const Ansi = Object.freeze({
83
+ reset: "\u001b[0m",
84
+ boldMagenta: "\u001b[1;35m",
85
+ green: "\u001b[32m",
86
+ red: "\u001b[31m",
87
+ yellowBold: "\u001b[1;33m",
88
+ darkGray: "\u001b[90m"
89
+ })
90
+
91
+ export interface TerminalPalette {
92
+ readonly enabled: boolean
93
+ readonly stageStart: (label: string) => string
94
+ readonly stageDone: (label: string) => string
95
+ readonly fail: (label: string) => string
96
+ readonly info: (label: string) => string
97
+ readonly assistant: (text: string) => string
98
+ readonly toolCall: (tool: string, args: string) => string
99
+ readonly status: (frame: string, label: string) => string
100
+ }
101
+
102
+ export const makeTerminalPalette = (enabled: boolean): TerminalPalette => {
103
+ const paint = (code: string, text: string): string =>
104
+ enabled ? `${code}${text}${Ansi.reset}` : text
105
+ return {
106
+ enabled,
107
+ stageStart: (label) => `${paint(Ansi.boldMagenta, "▶ ")}${label}`,
108
+ stageDone: (label) => `${paint(Ansi.green, "✔ ")}${label}`,
109
+ fail: (label) => `${paint(Ansi.red, "✖ ")}${label}`,
110
+ info: (label) => paint(Ansi.darkGray, `· ${label}`),
111
+ assistant: (text) => `${paint(Ansi.boldMagenta, "● ")}${text}`,
112
+ toolCall: (tool, args) => {
113
+ const head = paint(Ansi.yellowBold, `● ${tool}`)
114
+ return args.length === 0 ? head : `${head} ${paint(Ansi.darkGray, args)}`
115
+ },
116
+ status: (frame, label) => `${paint(Ansi.boldMagenta, frame)} ${label}`
117
+ }
118
+ }
119
+
120
+ export const plainTerminalPalette = makeTerminalPalette(false)
121
+
122
+ export const terminalLine = (
123
+ event: FlowEvent,
124
+ palette: TerminalPalette = plainTerminalPalette
125
+ ): string => {
126
+ const safe = terminalSafe
127
+ switch (event._tag) {
128
+ case "StageStarted":
129
+ return palette.stageStart(safe(event.stage))
130
+ case "StageCompleted":
131
+ return palette.stageDone(safe(event.stage))
132
+ case "StageFailed":
133
+ return palette.fail(`${safe(event.stage)} — ${safe(event.message)}`)
134
+ case "Aborted":
135
+ return palette.fail(`aborted: ${safe(event.message)}`)
136
+ case "Info":
137
+ return palette.info(safe(event.message))
138
+ case "ToolUse":
139
+ return palette.toolCall(safe(event.tool), safe(event.args))
140
+ case "AssistantMessage":
141
+ return palette.assistant(safe(event.text).trim())
142
+ case "TokensUsed":
143
+ return palette.info(
144
+ `tokens: ${safe(event.agent)} ${event.usage.prompt} in / ${event.usage.completion} out`
145
+ )
146
+ case "CapabilityUsed":
147
+ return palette.info(`capability ${safe(event.capability)}: ${safe(event.operation)}`)
148
+ case "CapabilityDenied":
149
+ return palette.fail(
150
+ `capability denied: ${safe(event.capability)} for ${safe(event.operation)}`
151
+ )
152
+ case "CapabilityUnenforceable":
153
+ return palette.fail(`capability unenforceable: ${safe(event.detail)}`)
154
+ case "Declassified":
155
+ return palette.info(`declassified: ${safe(event.label)}`)
156
+ }
157
+ }
158
+
159
+ export const indentBlock = (depth: number, rendered: string): string => {
160
+ const padding = " ".repeat(Math.max(0, depth))
161
+ const lines = rendered.split("\n")
162
+ return lines.map((line, index) => `${padding}${index === 0 ? "" : " "}${line}`).join("\n")
163
+ }
164
+
165
+ const closesChild = (event: FlowEvent): boolean =>
166
+ event._tag === "StageCompleted" || event._tag === "StageFailed" || event._tag === "Aborted"
167
+
168
+ export const indentDepths = (events: ReadonlyArray<FlowEvent>): ReadonlyArray<number> => {
169
+ let depth = 0
170
+ return events.map((event) => {
171
+ if (closesChild(event)) {
172
+ depth = Math.max(0, depth - 1)
173
+ return depth
174
+ }
175
+ const current = depth
176
+ if (event._tag === "StageStarted") {
177
+ depth += 1
178
+ }
179
+ return current
180
+ })
181
+ }
182
+
183
+ export interface TerminalSurface {
184
+ readonly palette: TerminalPalette
185
+ readonly log: (line: string) => Effect.Effect<void>
186
+ readonly setStatus: (label: string | undefined) => Effect.Effect<void>
187
+ readonly suspend: <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
188
+ }
189
+
190
+ export const makePlainTerminalSurface = (
191
+ write: (line: string) => void = (line) => process.stdout.write(`${line}\n`)
192
+ ): TerminalSurface => ({
193
+ palette: plainTerminalPalette,
194
+ log: (line) => Effect.sync(() => write(line)),
195
+ setStatus: (_label) => Effect.void,
196
+ suspend: (effect) => effect
197
+ })
198
+
199
+ const spinnerFrames = Object.freeze(["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"])
200
+ const clearLine = "\r\u001b[2K"
201
+
202
+ export interface TerminalOutput {
203
+ readonly isTTY?: boolean
204
+ readonly write: (text: string) => unknown
205
+ }
206
+
207
+ export const terminalSupportsColor = (
208
+ output: TerminalOutput,
209
+ environment: Readonly<Record<string, string | undefined>>
210
+ ): boolean => output.isTTY === true && environment.NO_COLOR === undefined
211
+
212
+ export const makeLiveTerminalSurface = Effect.fn("@llm4ts/runner/Terminal.makeLive")(function* (
213
+ write: (text: string) => void = (text) => {
214
+ process.stdout.write(text)
215
+ },
216
+ palette: TerminalPalette = makeTerminalPalette(true),
217
+ tick: Duration.Input = "100 millis"
218
+ ): Effect.fn.Return<TerminalSurface, never, Scope.Scope> {
219
+ const lock = yield* Semaphore.make(1)
220
+ const status = yield* Ref.make<string | undefined>(undefined)
221
+ const frame = yield* Ref.make(0)
222
+ const suspended = yield* Ref.make(false)
223
+ const emit = (text: string): Effect.Effect<void> => Effect.sync(() => write(text))
224
+ const drawStatus = Effect.gen(function* () {
225
+ if (yield* Ref.get(suspended)) {
226
+ return
227
+ }
228
+ const label = yield* Ref.get(status)
229
+ if (label !== undefined) {
230
+ const currentFrame = yield* Ref.get(frame)
231
+ yield* emit(
232
+ `${clearLine}${palette.status(spinnerFrames[currentFrame % spinnerFrames.length], label)}`
233
+ )
234
+ }
235
+ })
236
+ const surface: TerminalSurface = {
237
+ palette,
238
+ log: (line) =>
239
+ lock.withPermit(
240
+ emit(clearLine).pipe(Effect.andThen(emit(`${line}\n`)), Effect.andThen(drawStatus))
241
+ ),
242
+ setStatus: (label) =>
243
+ lock.withPermit(
244
+ Ref.set(status, label).pipe(Effect.andThen(emit(clearLine)), Effect.andThen(drawStatus))
245
+ ),
246
+ suspend: (effect) =>
247
+ lock
248
+ .withPermit(Ref.set(suspended, true).pipe(Effect.andThen(emit(clearLine))))
249
+ .pipe(
250
+ Effect.andThen(effect),
251
+ Effect.ensuring(
252
+ lock.withPermit(Ref.set(suspended, false).pipe(Effect.andThen(drawStatus)))
253
+ )
254
+ )
255
+ }
256
+ yield* Effect.sleep(tick).pipe(
257
+ Effect.andThen(Ref.update(frame, (current) => current + 1)),
258
+ Effect.andThen(lock.withPermit(drawStatus)),
259
+ Effect.forever,
260
+ Effect.forkScoped
261
+ )
262
+ yield* Effect.addFinalizer(() => lock.withPermit(emit(clearLine)))
263
+ return surface
264
+ })
265
+
266
+ export const makeTerminalSurface = (
267
+ environment: Readonly<Record<string, string | undefined>> = process.env,
268
+ output: TerminalOutput = process.stdout
269
+ ): Effect.Effect<TerminalSurface, never, Scope.Scope> => {
270
+ const write = (text: string): void => {
271
+ output.write(text)
272
+ }
273
+ return terminalSupportsColor(output, environment)
274
+ ? makeLiveTerminalSurface(write)
275
+ : Effect.succeed(makePlainTerminalSurface((line) => write(`${line}\n`)))
276
+ }
277
+
278
+ export interface TerminalConsumer {
279
+ readonly consumed: Ref.Ref<number>
280
+ readonly awaitDrained: (timeout?: Duration.Input) => Effect.Effect<void>
281
+ }
282
+
283
+ export const consumeTerminalEvents = Effect.fn("@llm4ts/runner/Terminal.consume")(function* (
284
+ events: FlowEventHub,
285
+ surface: TerminalSurface,
286
+ verbosity: Verbosity = "Normal"
287
+ ): Effect.fn.Return<TerminalConsumer, never, Scope.Scope> {
288
+ const depth = yield* Ref.make(0)
289
+ const stages = yield* Ref.make<ReadonlyArray<string>>([])
290
+ const consumed = yield* Ref.make(0)
291
+ const subscription = yield* events.subscribe
292
+ const palette = surface.palette
293
+ yield* Stream.fromSubscription(subscription).pipe(
294
+ Stream.runForEach((event) =>
295
+ Effect.gen(function* () {
296
+ const currentDepth = closesChild(event)
297
+ ? yield* Ref.updateAndGet(depth, (value) => Math.max(0, value - 1))
298
+ : yield* Ref.get(depth)
299
+ if (event._tag === "StageStarted") {
300
+ yield* Ref.update(depth, (value) => value + 1)
301
+ const active = terminalSafe(event.stage)
302
+ yield* Ref.update(stages, (current) => [...current, active])
303
+ yield* surface.setStatus(active)
304
+ } else if (closesChild(event)) {
305
+ const remaining = yield* Ref.updateAndGet(stages, (current) => current.slice(0, -1))
306
+ yield* surface.setStatus(remaining.at(-1))
307
+ }
308
+ if (rendersEvent(verbosity, event)) {
309
+ yield* surface.log(indentBlock(currentDepth, terminalLine(event, palette)))
310
+ }
311
+ yield* Ref.update(consumed, (count) => count + 1)
312
+ })
313
+ ),
314
+ Effect.forkScoped
315
+ )
316
+ const awaitDrained = (timeout: Duration.Input = "3 seconds"): Effect.Effect<void> =>
317
+ Effect.gen(function* () {
318
+ const target = yield* events.publishedCount
319
+ const drain: Effect.Effect<void> = Effect.suspend(() =>
320
+ Ref.get(consumed).pipe(
321
+ Effect.flatMap((count) =>
322
+ count >= target ? Effect.void : Effect.yieldNow.pipe(Effect.andThen(drain))
323
+ )
324
+ )
325
+ )
326
+ yield* Effect.timeoutOption(drain, timeout)
327
+ })
328
+ return { consumed, awaitDrained }
329
+ })