@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,56 @@
1
+ import * as Effect from "effect/Effect"
2
+ import * as Layer from "effect/Layer"
3
+ import * as Stream from "effect/Stream"
4
+ import { ProviderError } from "@llm4ts/core/Errors"
5
+ import type { ProcessExecutorShape } from "@llm4ts/core/ProcessExecutor"
6
+ import {
7
+ GeminiCliExecutor,
8
+ buildGeminiArgs,
9
+ extractGeminiCliResponse,
10
+ geminiProcessEnv,
11
+ parseGeminiCliStreamEvent,
12
+ validateGeminiExitCode,
13
+ type GeminiCliExecutorShape
14
+ } from "@llm4ts/core/providers/GeminiCliProvider"
15
+ import { nodeProcessExecutor } from "./NodeProcessExecutor.ts"
16
+
17
+ const failure = (message: string): ProviderError => ProviderError.make({ message })
18
+
19
+ export const makeNodeGeminiCliExecutor = (
20
+ executor: ProcessExecutorShape
21
+ ): GeminiCliExecutorShape => ({
22
+ checkGeminiInstalled: executor
23
+ .run(["gemini", "--version"], process.cwd(), {})
24
+ .pipe(
25
+ Effect.flatMap((result) =>
26
+ result.exitCode === 0
27
+ ? Effect.void
28
+ : Effect.fail(failure(`Gemini CLI is unavailable: ${result.stderr.join("\n")}`))
29
+ )
30
+ ),
31
+ runGeminiProcess: (prompt, config, context) =>
32
+ Effect.gen(function* () {
33
+ const result = yield* executor.runWithStdin(
34
+ ["gemini", ...buildGeminiArgs(config, context, "json")],
35
+ context.cwd ?? process.cwd(),
36
+ geminiProcessEnv(context),
37
+ prompt
38
+ )
39
+ yield* validateGeminiExitCode(result.exitCode, result.stderr.join("\n"), context.turnLimit)
40
+ const extracted = extractGeminiCliResponse(result.stdout.join("\n"))
41
+ return extracted.ok ? extracted.value : yield* failure(extracted.error)
42
+ }),
43
+ runGeminiProcessStream: (prompt, config, context) =>
44
+ executor
45
+ .runStreamingWithStdin(
46
+ ["gemini", ...buildGeminiArgs(config, context, "stream-json")],
47
+ context.cwd ?? process.cwd(),
48
+ geminiProcessEnv(context),
49
+ prompt
50
+ )
51
+ .pipe(Stream.map(parseGeminiCliStreamEvent))
52
+ })
53
+
54
+ export const nodeGeminiCliExecutor = makeNodeGeminiCliExecutor(nodeProcessExecutor)
55
+
56
+ export const NodeGeminiCliExecutorLive = Layer.succeed(GeminiCliExecutor, nodeGeminiCliExecutor)
@@ -0,0 +1,224 @@
1
+ import * as Duration from "effect/Duration"
2
+ import * as Effect from "effect/Effect"
3
+ import * as Layer from "effect/Layer"
4
+ import * as Stream from "effect/Stream"
5
+ import { HttpClient, makeHttpClient, type HttpClientShape } from "@llm4ts/core/HttpClient"
6
+ import {
7
+ AuthenticationError,
8
+ InvalidRequestError,
9
+ ParseError,
10
+ ProviderError,
11
+ RateLimitError,
12
+ TimeoutError,
13
+ type LlmError
14
+ } from "@llm4ts/core/Errors"
15
+
16
+ const errorMessage = (error: unknown): string =>
17
+ error instanceof Error ? error.message : String(error)
18
+
19
+ const checkedUrl = (raw: string): Effect.Effect<string, InvalidRequestError> =>
20
+ Effect.try({
21
+ try: () => new URL(raw).toString(),
22
+ catch: (error) =>
23
+ InvalidRequestError.make({
24
+ message: `Invalid URL '${raw}': ${errorMessage(error)}`
25
+ })
26
+ })
27
+
28
+ const fetchResponse = (
29
+ rawUrl: string,
30
+ init: RequestInit,
31
+ timeout: Duration.Duration
32
+ ): Effect.Effect<Response, LlmError> =>
33
+ Effect.flatMap(checkedUrl(rawUrl), (url) =>
34
+ Effect.tryPromise({
35
+ try: (signal) =>
36
+ fetch(url, {
37
+ ...init,
38
+ signal
39
+ }),
40
+ catch: (error) =>
41
+ ProviderError.make({
42
+ message: `Provider unavailable: ${rawUrl}`,
43
+ cause: error
44
+ })
45
+ }).pipe(
46
+ Effect.timeoutOrElse({
47
+ duration: timeout,
48
+ orElse: () =>
49
+ Effect.fail(
50
+ TimeoutError.make({
51
+ duration: timeout
52
+ })
53
+ )
54
+ })
55
+ )
56
+ )
57
+
58
+ const responseText = (response: Response): Effect.Effect<string, ParseError> =>
59
+ Effect.tryPromise({
60
+ try: () => response.text(),
61
+ catch: (error) =>
62
+ ParseError.make({
63
+ message: errorMessage(error),
64
+ raw: ""
65
+ })
66
+ })
67
+
68
+ const retryAfter = (response: Response, fallback: Duration.Duration): Duration.Duration => {
69
+ const value = response.headers.get("Retry-After")?.trim()
70
+ return value !== undefined && /^\d+$/.test(value) ? Duration.seconds(Number(value)) : fallback
71
+ }
72
+
73
+ const classifyBodyResponse = (
74
+ response: Response,
75
+ url: string,
76
+ timeout: Duration.Duration,
77
+ accepts: (status: number) => boolean
78
+ ): Effect.Effect<string, LlmError> =>
79
+ Effect.flatMap(responseText(response), (body): Effect.Effect<string, LlmError> => {
80
+ const status = response.status
81
+ if (accepts(status)) {
82
+ return Effect.succeed(body)
83
+ }
84
+ if (status === 401 || status === 403) {
85
+ return Effect.fail(AuthenticationError.make({ message: url }))
86
+ }
87
+ if (status === 429) {
88
+ return Effect.fail(
89
+ RateLimitError.make({
90
+ retryAfter: retryAfter(response, timeout)
91
+ })
92
+ )
93
+ }
94
+ if (status >= 400 && status < 500) {
95
+ return Effect.fail(
96
+ InvalidRequestError.make({
97
+ message: `HTTP ${status}: ${body}`
98
+ })
99
+ )
100
+ }
101
+ return Effect.fail(
102
+ ProviderError.make({
103
+ message: `HTTP ${status}: ${body}`
104
+ })
105
+ )
106
+ })
107
+
108
+ const requestHeaders = (
109
+ headers: Readonly<Record<string, string>>,
110
+ contentType: string | undefined
111
+ ): Headers => {
112
+ const result = new Headers(headers)
113
+ if (contentType !== undefined && !result.has("Content-Type")) {
114
+ result.set("Content-Type", contentType)
115
+ }
116
+ return result
117
+ }
118
+
119
+ const requestBody = (
120
+ method: string,
121
+ url: string,
122
+ body: string | undefined,
123
+ headers: Readonly<Record<string, string>>,
124
+ contentType: string | undefined,
125
+ timeout: Duration.Duration,
126
+ accepts: (status: number) => boolean
127
+ ): Effect.Effect<string, LlmError> =>
128
+ Effect.flatMap(
129
+ fetchResponse(
130
+ url,
131
+ {
132
+ method,
133
+ headers: requestHeaders(headers, contentType),
134
+ ...(body === undefined ? {} : { body })
135
+ },
136
+ timeout
137
+ ),
138
+ (response) => classifyBodyResponse(response, url, timeout, accepts)
139
+ )
140
+
141
+ const streamBody = (
142
+ response: Response,
143
+ url: string,
144
+ timeout: Duration.Duration
145
+ ): Stream.Stream<string, LlmError> => {
146
+ if (response.status !== 200) {
147
+ return Stream.fromEffect(
148
+ classifyBodyResponse(response, url, timeout, (status) => status === 200)
149
+ )
150
+ }
151
+ const body = response.body
152
+ if (body === null) {
153
+ return Stream.fail(
154
+ ProviderError.make({
155
+ message: `Provider returned no streaming body: ${url}`
156
+ })
157
+ )
158
+ }
159
+
160
+ return Stream.fromReadableStream({
161
+ evaluate: () => body,
162
+ onError: (error) =>
163
+ ProviderError.make({
164
+ message: `Failed to read streaming response from ${url}`,
165
+ cause: error
166
+ })
167
+ }).pipe(
168
+ Stream.decodeText,
169
+ Stream.splitLines,
170
+ Stream.timeoutOrElse({
171
+ duration: timeout,
172
+ orElse: () =>
173
+ Stream.fail(
174
+ TimeoutError.make({
175
+ duration: timeout
176
+ })
177
+ )
178
+ })
179
+ )
180
+ }
181
+
182
+ const postJsonStream: HttpClientShape["postJsonStream"] = (url, body, headers, timeout) =>
183
+ Stream.unwrap(
184
+ Effect.map(
185
+ fetchResponse(
186
+ url,
187
+ {
188
+ method: "POST",
189
+ headers: requestHeaders(headers, "application/json"),
190
+ body
191
+ },
192
+ timeout
193
+ ),
194
+ (response) => streamBody(response, url, timeout)
195
+ )
196
+ )
197
+
198
+ export const nodeHttpClient: HttpClientShape = makeHttpClient({
199
+ get: (url, headers, timeout) =>
200
+ requestBody("GET", url, undefined, headers, undefined, timeout, (status) => status === 200),
201
+ postJson: (url, body, headers, timeout) =>
202
+ requestBody(
203
+ "POST",
204
+ url,
205
+ body,
206
+ headers,
207
+ "application/json",
208
+ timeout,
209
+ (status) => status === 200
210
+ ),
211
+ send: (method, url, body, headers, contentType, timeout) =>
212
+ requestBody(
213
+ method,
214
+ url,
215
+ body,
216
+ headers,
217
+ contentType,
218
+ timeout,
219
+ (status) => status >= 200 && status < 300
220
+ ),
221
+ postJsonStream
222
+ })
223
+
224
+ export const NodeHttpClientLive = Layer.succeed(HttpClient)(nodeHttpClient)
@@ -0,0 +1,75 @@
1
+ import { createHash, randomUUID } from "node:crypto"
2
+ import { appendFile, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"
3
+ import { dirname, join } from "node:path"
4
+ import * as Effect from "effect/Effect"
5
+ import * as Layer from "effect/Layer"
6
+ import { PlainFileStore, type PlainFileStoreShape } from "@llm4ts/flow/Persistence"
7
+ import { PersistenceError } from "@llm4ts/flow/FlowError"
8
+
9
+ const message = (error: unknown): string => (error instanceof Error ? error.message : String(error))
10
+
11
+ const persistenceFailure =
12
+ (operation: string, path: string) =>
13
+ (error: unknown): PersistenceError =>
14
+ PersistenceError.make({
15
+ message: `failed to ${operation} ${path}: ${message(error)}`,
16
+ cause: error
17
+ })
18
+
19
+ const ensureParent = async (path: string): Promise<void> => {
20
+ await mkdir(dirname(path), { recursive: true })
21
+ }
22
+
23
+ export const nodePlainFileStore: PlainFileStoreShape = {
24
+ read: (path) =>
25
+ Effect.tryPromise({
26
+ try: async () => {
27
+ try {
28
+ return await readFile(path, "utf8")
29
+ } catch (error) {
30
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
31
+ return undefined
32
+ }
33
+ throw error
34
+ }
35
+ },
36
+ catch: persistenceFailure("read", path)
37
+ }),
38
+ writeAtomic: (path, contents) =>
39
+ Effect.tryPromise({
40
+ try: async () => {
41
+ await ensureParent(path)
42
+ const temporary = join(dirname(path), `.${randomUUID()}.llm4ts-tmp`)
43
+ try {
44
+ await writeFile(temporary, contents, "utf8")
45
+ await rename(temporary, path)
46
+ } finally {
47
+ await rm(temporary, { force: true })
48
+ }
49
+ },
50
+ catch: persistenceFailure("write", path)
51
+ }),
52
+ append: (path, contents) =>
53
+ Effect.tryPromise({
54
+ try: async () => {
55
+ await ensureParent(path)
56
+ await appendFile(path, contents, "utf8")
57
+ },
58
+ catch: persistenceFailure("append", path)
59
+ }),
60
+ remove: (path) =>
61
+ Effect.tryPromise({
62
+ try: () => rm(path, { force: true }),
63
+ catch: persistenceFailure("remove", path)
64
+ }),
65
+ hashSha256: (path) =>
66
+ Effect.tryPromise({
67
+ try: async () =>
68
+ createHash("sha256")
69
+ .update(await readFile(path))
70
+ .digest("hex"),
71
+ catch: persistenceFailure("hash", path)
72
+ })
73
+ }
74
+
75
+ export const NodePlainFileStoreLive = Layer.succeed(PlainFileStore)(nodePlainFileStore)
@@ -0,0 +1,331 @@
1
+ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"
2
+ import * as readline from "node:readline"
3
+ import * as Cause from "effect/Cause"
4
+ import * as Effect from "effect/Effect"
5
+ import * as Layer from "effect/Layer"
6
+ import * as Queue from "effect/Queue"
7
+ import * as Stream from "effect/Stream"
8
+ import {
9
+ ProcessExecutor,
10
+ ProcessResult,
11
+ makeProcessExecutor,
12
+ type BidirectionalProcess,
13
+ type ProcessExecutorShape
14
+ } from "@llm4ts/core/ProcessExecutor"
15
+ import { InvalidRequestError, ProviderError, type LlmError } from "@llm4ts/core/Errors"
16
+
17
+ const maxStderrLines = 256
18
+
19
+ const errorMessage = (error: unknown): string =>
20
+ error instanceof Error ? error.message : String(error)
21
+
22
+ const commandName = (argv: ReadonlyArray<string>): string => argv[0] ?? "process"
23
+
24
+ const invalidArgv = (): InvalidRequestError => InvalidRequestError.make({ message: "empty argv" })
25
+
26
+ const processFailure = (argv: ReadonlyArray<string>, detail: string): ProviderError =>
27
+ ProviderError.make({
28
+ message: detail.includes("ENOENT")
29
+ ? `${commandName(argv)} failed: command not found. Install '${commandName(argv)}' and make sure it is on your PATH` +
30
+ ` (coding-agent CLIs can be switched with LLM4TS_CODER)`
31
+ : `${commandName(argv)} failed: ${detail}`
32
+ })
33
+
34
+ const spawnChild = (
35
+ argv: ReadonlyArray<string>,
36
+ cwd: string,
37
+ envVars: Readonly<Record<string, string>>
38
+ ): Effect.Effect<ChildProcessWithoutNullStreams, ProviderError> => {
39
+ const [command, ...args] = argv
40
+ if (command === undefined) {
41
+ return Effect.fail(
42
+ ProviderError.make({
43
+ message: "process failed: empty argv"
44
+ })
45
+ )
46
+ }
47
+
48
+ return Effect.try({
49
+ try: () =>
50
+ spawn(command, args, {
51
+ cwd,
52
+ env: {
53
+ ...process.env,
54
+ ...envVars
55
+ },
56
+ stdio: "pipe"
57
+ }),
58
+ catch: (error) => processFailure(argv, errorMessage(error))
59
+ })
60
+ }
61
+
62
+ const killChild = (child: ChildProcessWithoutNullStreams): Effect.Effect<void> =>
63
+ Effect.sync(() => {
64
+ if (child.exitCode === null && child.signalCode === null) {
65
+ child.kill("SIGKILL")
66
+ }
67
+ })
68
+
69
+ const writeAndCloseStdin = (
70
+ child: ChildProcessWithoutNullStreams,
71
+ stdin: string | undefined
72
+ ): void => {
73
+ if (stdin === undefined) {
74
+ child.stdin.end()
75
+ } else {
76
+ child.stdin.end(stdin)
77
+ }
78
+ }
79
+
80
+ const awaitCapturedProcess = (
81
+ child: ChildProcessWithoutNullStreams,
82
+ argv: ReadonlyArray<string>,
83
+ stdin: string | undefined
84
+ ): Effect.Effect<ProcessResult, ProviderError> =>
85
+ Effect.callback((resume) => {
86
+ const stdout: Array<string> = []
87
+ const stderr: Array<string> = []
88
+ const outLines = readline.createInterface({ input: child.stdout })
89
+ const errLines = readline.createInterface({ input: child.stderr })
90
+
91
+ const onStdout = (line: string): void => {
92
+ stdout.push(line)
93
+ }
94
+ const onStderr = (line: string): void => {
95
+ stderr.push(line)
96
+ }
97
+ const onError = (error: Error): void => {
98
+ resume(Effect.fail(processFailure(argv, error.message)))
99
+ }
100
+ const onClose = (exitCode: number | null): void => {
101
+ resume(
102
+ Effect.succeed(
103
+ ProcessResult.make({
104
+ stdout,
105
+ stderr,
106
+ exitCode: exitCode ?? -1
107
+ })
108
+ )
109
+ )
110
+ }
111
+
112
+ outLines.on("line", onStdout)
113
+ errLines.on("line", onStderr)
114
+ child.once("error", onError)
115
+ child.once("close", onClose)
116
+ writeAndCloseStdin(child, stdin)
117
+
118
+ return Effect.sync(() => {
119
+ child.off("error", onError)
120
+ child.off("close", onClose)
121
+ outLines.close()
122
+ errLines.close()
123
+ })
124
+ })
125
+
126
+ const runCaptured = (
127
+ argv: ReadonlyArray<string>,
128
+ cwd: string,
129
+ envVars: Readonly<Record<string, string>>,
130
+ stdin: string | undefined
131
+ ): Effect.Effect<ProcessResult, LlmError> => {
132
+ if (argv.length === 0) {
133
+ return Effect.fail(invalidArgv())
134
+ }
135
+
136
+ return Effect.acquireUseRelease(
137
+ spawnChild(argv, cwd, envVars),
138
+ (child) => awaitCapturedProcess(child, argv, stdin),
139
+ (child) => killChild(child)
140
+ )
141
+ }
142
+
143
+ interface StreamOptions {
144
+ readonly stdin: string | undefined
145
+ readonly killOnClose: boolean
146
+ readonly failOnNonZero: boolean
147
+ }
148
+
149
+ const childLines = (
150
+ child: ChildProcessWithoutNullStreams,
151
+ argv: ReadonlyArray<string>,
152
+ options: StreamOptions
153
+ ): Stream.Stream<string, LlmError> =>
154
+ Stream.callback<string, LlmError>((queue) =>
155
+ Effect.gen(function* () {
156
+ const stderr: Array<string> = []
157
+ const outLines = readline.createInterface({ input: child.stdout })
158
+ const errLines = readline.createInterface({ input: child.stderr })
159
+
160
+ const onStdout = (line: string): void => {
161
+ Queue.offerUnsafe(queue, line)
162
+ }
163
+ const onStderr = (line: string): void => {
164
+ if (stderr.length < maxStderrLines) {
165
+ stderr.push(line)
166
+ }
167
+ }
168
+ const onError = (error: Error): void => {
169
+ Queue.failCauseUnsafe(queue, Cause.fail(processFailure(argv, error.message)))
170
+ }
171
+ const onClose = (exitCode: number | null): void => {
172
+ if (options.failOnNonZero && exitCode !== 0) {
173
+ const detail = `${commandName(argv)} exited with code ${exitCode ?? -1}${
174
+ stderr.length === 0 ? "" : `: ${stderr.join("\n")}`
175
+ }`
176
+ Queue.failCauseUnsafe(queue, Cause.fail(processFailure(argv, detail)))
177
+ } else {
178
+ Queue.endUnsafe(queue)
179
+ }
180
+ }
181
+
182
+ outLines.on("line", onStdout)
183
+ errLines.on("line", onStderr)
184
+ child.once("error", onError)
185
+ child.once("close", onClose)
186
+
187
+ yield* Effect.addFinalizer(() =>
188
+ Effect.sync(() => {
189
+ child.off("error", onError)
190
+ child.off("close", onClose)
191
+ outLines.close()
192
+ errLines.close()
193
+ }).pipe(Effect.andThen(options.killOnClose ? killChild(child) : Effect.void))
194
+ )
195
+ })
196
+ )
197
+
198
+ const runStreaming = (
199
+ argv: ReadonlyArray<string>,
200
+ cwd: string,
201
+ envVars: Readonly<Record<string, string>>,
202
+ stdin: string | undefined
203
+ ): Stream.Stream<string, LlmError> => {
204
+ if (argv.length === 0) {
205
+ return Stream.fail(invalidArgv())
206
+ }
207
+
208
+ return Stream.callback<string, LlmError>((queue) =>
209
+ Effect.gen(function* () {
210
+ const child = yield* Effect.acquireRelease(spawnChild(argv, cwd, envVars), (process) =>
211
+ killChild(process)
212
+ )
213
+ const stderr: Array<string> = []
214
+ const outLines = readline.createInterface({ input: child.stdout })
215
+ const errLines = readline.createInterface({ input: child.stderr })
216
+
217
+ const onStdout = (line: string): void => {
218
+ Queue.offerUnsafe(queue, line)
219
+ }
220
+ const onStderr = (line: string): void => {
221
+ if (stderr.length < maxStderrLines) {
222
+ stderr.push(line)
223
+ }
224
+ }
225
+ const onError = (error: Error): void => {
226
+ Queue.failCauseUnsafe(queue, Cause.fail(processFailure(argv, error.message)))
227
+ }
228
+ const onClose = (exitCode: number | null): void => {
229
+ if (exitCode === 0) {
230
+ Queue.endUnsafe(queue)
231
+ } else {
232
+ const detail = `${commandName(argv)} exited with code ${exitCode ?? -1}${
233
+ stderr.length === 0 ? "" : `: ${stderr.join("\n")}`
234
+ }`
235
+ Queue.failCauseUnsafe(queue, Cause.fail(processFailure(argv, detail)))
236
+ }
237
+ }
238
+
239
+ outLines.on("line", onStdout)
240
+ errLines.on("line", onStderr)
241
+ child.once("error", onError)
242
+ child.once("close", onClose)
243
+ writeAndCloseStdin(child, stdin)
244
+
245
+ yield* Effect.addFinalizer(() =>
246
+ Effect.sync(() => {
247
+ child.off("error", onError)
248
+ child.off("close", onClose)
249
+ outLines.close()
250
+ errLines.close()
251
+ })
252
+ )
253
+ })
254
+ )
255
+ }
256
+
257
+ const writeLine = (
258
+ child: ChildProcessWithoutNullStreams,
259
+ argv: ReadonlyArray<string>,
260
+ line: string
261
+ ): Effect.Effect<void, ProviderError> =>
262
+ Effect.callback((resume) => {
263
+ const onError = (error: Error): void => {
264
+ resume(Effect.fail(processFailure(argv, error.message)))
265
+ }
266
+ child.stdin.once("error", onError)
267
+ child.stdin.write(`${line}\n`, (error) => {
268
+ child.stdin.off("error", onError)
269
+ resume(
270
+ error === null || error === undefined
271
+ ? Effect.void
272
+ : Effect.fail(processFailure(argv, error.message))
273
+ )
274
+ })
275
+ return Effect.sync(() => {
276
+ child.stdin.off("error", onError)
277
+ })
278
+ })
279
+
280
+ const bidirectionalBundle = (
281
+ stdin: Queue.Queue<string>,
282
+ stdout: Stream.Stream<string, LlmError>
283
+ ): BidirectionalProcess => [stdin, stdout]
284
+
285
+ const runBidirectional: ProcessExecutorShape["runBidirectional"] = (argv, cwd, envVars) => {
286
+ if (argv.length === 0) {
287
+ return Effect.fail(invalidArgv())
288
+ }
289
+
290
+ return Effect.gen(function* () {
291
+ const input = yield* Effect.acquireRelease(Queue.unbounded<string>(), (queue) =>
292
+ Queue.shutdown(queue).pipe(Effect.asVoid)
293
+ )
294
+ const child = yield* Effect.acquireRelease(spawnChild(argv, cwd, envVars), (process) =>
295
+ killChild(process)
296
+ )
297
+
298
+ const pump = Effect.suspend(function loop(): Effect.Effect<void, ProviderError> {
299
+ return Effect.flatMap(Queue.take(input), (line) =>
300
+ writeLine(child, argv, line).pipe(Effect.andThen(Effect.suspend(loop)))
301
+ )
302
+ }).pipe(
303
+ Effect.ensuring(
304
+ Effect.sync(() => {
305
+ child.stdin.end()
306
+ })
307
+ ),
308
+ Effect.catchCause(() => Effect.void)
309
+ )
310
+ yield* Effect.forkScoped(pump)
311
+
312
+ return bidirectionalBundle(
313
+ input,
314
+ childLines(child, argv, {
315
+ stdin: undefined,
316
+ killOnClose: false,
317
+ failOnNonZero: false
318
+ })
319
+ )
320
+ })
321
+ }
322
+
323
+ export const nodeProcessExecutor: ProcessExecutorShape = makeProcessExecutor({
324
+ run: (argv, cwd, envVars) => runCaptured(argv, cwd, envVars, undefined),
325
+ runStreaming: (argv, cwd, envVars) => runStreaming(argv, cwd, envVars, undefined),
326
+ runWithStdin: (argv, cwd, envVars, stdin) => runCaptured(argv, cwd, envVars, stdin),
327
+ runStreamingWithStdin: (argv, cwd, envVars, stdin) => runStreaming(argv, cwd, envVars, stdin),
328
+ runBidirectional
329
+ })
330
+
331
+ export const NodeProcessExecutorLive = Layer.succeed(ProcessExecutor)(nodeProcessExecutor)
@@ -0,0 +1,19 @@
1
+ import * as Layer from "effect/Layer"
2
+ import { ConnectorRegistryLive } from "@llm4ts/core/providers/ConnectorFactories"
3
+ import { NodeHttpClientLive } from "./NodeHttpClient.ts"
4
+ import { NodeGeminiCliExecutorLive } from "./NodeGeminiCliExecutor.ts"
5
+ import { NodePlainFileStoreLive } from "./NodePlainFileStore.ts"
6
+ import { NodeProcessExecutorLive } from "./NodeProcessExecutor.ts"
7
+ import { NodeTemporaryFilesLive } from "./NodeTemporaryFiles.ts"
8
+
9
+ export const NodeBoundaryLayers = Layer.mergeAll(
10
+ NodeHttpClientLive,
11
+ NodeProcessExecutorLive,
12
+ NodeTemporaryFilesLive,
13
+ NodeGeminiCliExecutorLive,
14
+ NodePlainFileStoreLive
15
+ )
16
+
17
+ const NodeConnectorRegistryLive = ConnectorRegistryLive.pipe(Layer.provide(NodeBoundaryLayers))
18
+
19
+ export const NodeRuntimeLive = Layer.merge(NodeBoundaryLayers, NodeConnectorRegistryLive)