@llm4ts/runner 2.3.0 → 2.4.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/src/Doctor.ts CHANGED
@@ -1,11 +1,13 @@
1
- import { readFileSync } from "node:fs"
2
- import { homedir } from "node:os"
3
- import { join } from "node:path"
4
1
  import * as Effect from "effect/Effect"
5
2
  import type { HealthStatus } from "@llm4ts/core/Models"
6
3
  import { ConnectorIds } from "@llm4ts/core/Models"
7
4
  import type { ConnectorRegistryShape } from "@llm4ts/core/ConnectorRegistry"
8
5
  import { nodeFlowRunnerDependencies } from "./FlowRunner.ts"
6
+ import {
7
+ defaultReadPiModelsJson as readPiModels,
8
+ geminiBridgePort as bridgePort,
9
+ piModelsConfig as parsePiModels
10
+ } from "./PiModels.ts"
9
11
 
10
12
  const credentialKeys = [
11
13
  "OPENAI_API_KEY",
@@ -31,6 +33,8 @@ export interface PrerequisiteReport {
31
33
  readonly satisfied: boolean
32
34
  readonly summary: string
33
35
  readonly hint?: string
36
+ /** Extra lines rendered under the summary, already indented relative to it. */
37
+ readonly detail?: ReadonlyArray<string>
34
38
  }
35
39
 
36
40
  /**
@@ -101,20 +105,22 @@ const wrap = (text: string, width: number, indent: string): ReadonlyArray<string
101
105
  const selectedCoder = (environment: Readonly<Record<string, string | undefined>>): string =>
102
106
  environment.LLM4TS_CODER ?? "claude (default)"
103
107
 
104
- export const defaultGeminiBridgePort = "8731"
105
-
106
- /**
107
- * `~/.pi/agent/models.json` (ADR 0016). Read for reporting only — this file
108
- * belongs to `pi`, outside llm4ts's package graph, and llm4ts never writes
109
- * it. Missing is not an error here: the file simply doesn't exist yet.
110
- */
111
- export const defaultReadPiModelsJson = (): string | undefined => {
112
- try {
113
- return readFileSync(join(homedir(), ".pi", "agent", "models.json"), "utf8")
114
- } catch {
115
- return undefined
116
- }
117
- }
108
+ // Re-exported so `@llm4ts/runner/Doctor` stays the one place callers look
109
+ // for pi-config questions, while the parsing itself has no dependency on
110
+ // this module (the flow runner needs it, and Doctor imports the runner).
111
+ export {
112
+ bridgeModelProblem,
113
+ bridgeModelRefs,
114
+ defaultGeminiBridgePort,
115
+ defaultReadPiModelsJson,
116
+ geminiBridgePort,
117
+ piModelRefs,
118
+ piModelsConfig,
119
+ resolveBridgeModel,
120
+ type BridgeModelResolution,
121
+ type PiModelRef,
122
+ type PiModelsConfig
123
+ } from "./PiModels.ts"
118
124
 
119
125
  /**
120
126
  * Whether pi's custom-provider config points at this machine's Gemini ACP
@@ -128,9 +134,7 @@ export const geminiBridgePrerequisites = (
128
134
  if (!truthy(environment.LLM4TS_GEMINI_BRIDGE)) {
129
135
  return undefined
130
136
  }
131
- const port = isSet(environment.LLM4TS_GEMINI_BRIDGE_PORT)
132
- ? environment.LLM4TS_GEMINI_BRIDGE_PORT.trim()
133
- : defaultGeminiBridgePort
137
+ const port = bridgePort(environment)
134
138
  const needle = `127.0.0.1:${port}`
135
139
  const hint =
136
140
  `add a custom provider to ~/.pi/agent/models.json with baseUrl ` +
@@ -139,22 +143,73 @@ export const geminiBridgePrerequisites = (
139
143
  if (modelsJson === undefined) {
140
144
  return { satisfied: false, summary: "~/.pi/agent/models.json not found", hint }
141
145
  }
142
- return modelsJson.includes(needle)
143
- ? {
144
- satisfied: true,
145
- summary: `a provider in ~/.pi/agent/models.json points at ${needle}`
146
- }
147
- : {
148
- satisfied: false,
149
- summary: `no provider in ~/.pi/agent/models.json points at ${needle}`,
150
- hint
151
- }
146
+ const { refs, problems } = parsePiModels(modelsJson, port)
147
+ // pi rejects the whole file on any schema error, so a well-formed bridge
148
+ // entry sitting beside a bad one is still not loaded. Report that first:
149
+ // every model below would otherwise look available while pi sees none.
150
+ if (problems.length > 0) {
151
+ return {
152
+ satisfied: false,
153
+ summary: "~/.pi/agent/models.json fails pi's schema, so pi loads none of it",
154
+ hint:
155
+ `fix the errors below and re-check with \`pi --list-models\`. Each models ` +
156
+ `entry must be an object with a string id ({ "id": "gemini-2.5-pro" }), not ` +
157
+ `a bare string.`,
158
+ detail: problems.map((problem) => ` ${problem}`)
159
+ }
160
+ }
161
+ const bridged = refs.filter((entry) => entry.bridged)
162
+ const usable = bridged.filter((entry) => entry.authConfigured).map((entry) => entry.ref)
163
+ if (usable.length > 0) {
164
+ return {
165
+ satisfied: true,
166
+ summary: `a provider in ~/.pi/agent/models.json points at ${needle}`,
167
+ detail: [
168
+ "bridge models — pass one as LLM4TS_GEMINI_BRIDGE_MODEL (pi's --model):",
169
+ ...usable.map((ref) => ` ${ref}`)
170
+ ]
171
+ }
172
+ }
173
+ // Loaded but hidden: pi keeps a keyless provider's models out of `--model`
174
+ // and `--list-models`. The bridge authenticates as gemini, not as pi, so
175
+ // any placeholder satisfies this.
176
+ if (bridged.length > 0) {
177
+ return {
178
+ satisfied: false,
179
+ summary: `the provider pointing at ${needle} configures no apiKey, so pi hides its models`,
180
+ hint:
181
+ `give that provider any placeholder apiKey (the bridge never checks it — ` +
182
+ `gemini's own OAuth session supplies the credential), or run \`pi /login\` ` +
183
+ `for it.`,
184
+ detail: bridged.map((entry) => ` ${entry.ref}`)
185
+ }
186
+ }
187
+ // A provider can point at the bridge and still list no models: pi resolves
188
+ // `--model` against that list, so the run fails with "Model not found"
189
+ // while a check that only greps for the port reports success.
190
+ const pointed = modelsJson.includes(needle)
191
+ const others = refs.map((entry) => entry.ref)
192
+ return {
193
+ satisfied: false,
194
+ summary: pointed
195
+ ? `a provider in ~/.pi/agent/models.json points at ${needle} but lists no models`
196
+ : `no provider in ~/.pi/agent/models.json points at ${needle}`,
197
+ hint,
198
+ ...(others.length === 0
199
+ ? {}
200
+ : {
201
+ detail: [
202
+ "models pi can resolve today, none of them bridged:",
203
+ ...others.map((ref) => ` ${ref}`)
204
+ ]
205
+ })
206
+ }
152
207
  }
153
208
 
154
209
  export const makeDoctorProgram = (
155
210
  registry: ConnectorRegistryShape = nodeFlowRunnerDependencies().registry,
156
211
  environment: Readonly<Record<string, string | undefined>> = process.env,
157
- readPiModelsJson: () => string | undefined = defaultReadPiModelsJson
212
+ readPiModelsJson: () => string | undefined = readPiModels
158
213
  ): Effect.Effect<string> =>
159
214
  Effect.map(registry.healthCheckAll, (statuses) => {
160
215
  const lines: Array<string> = []
@@ -186,18 +241,20 @@ export const makeDoctorProgram = (
186
241
  if (geminiRelevant || bridge !== undefined) {
187
242
  lines.push("")
188
243
  lines.push("prerequisites:")
189
- if (geminiRelevant) {
190
- const gemini = geminiPrerequisites(environment)
191
- lines.push(` ${gemini.satisfied ? "✔" : "?"} gemini: ${gemini.summary}`)
192
- if (gemini.hint !== undefined) {
193
- lines.push(...wrap(gemini.hint, 78, " "))
244
+ const pushReport = (label: string, report: PrerequisiteReport): void => {
245
+ lines.push(` ${report.satisfied ? "✔" : "?"} ${label}: ${report.summary}`)
246
+ if (report.hint !== undefined) {
247
+ lines.push(...wrap(report.hint, 78, " "))
248
+ }
249
+ for (const line of report.detail ?? []) {
250
+ lines.push(` ${line}`)
194
251
  }
195
252
  }
253
+ if (geminiRelevant) {
254
+ pushReport("gemini", geminiPrerequisites(environment))
255
+ }
196
256
  if (bridge !== undefined) {
197
- lines.push(` ${bridge.satisfied ? "✔" : "?"} pi-gemini-bridge: ${bridge.summary}`)
198
- if (bridge.hint !== undefined) {
199
- lines.push(...wrap(bridge.hint, 78, " "))
200
- }
257
+ pushReport("pi-gemini-bridge", bridge)
201
258
  }
202
259
  }
203
260
 
package/src/FlowRunner.ts CHANGED
@@ -11,7 +11,16 @@ import type { ProcessExecutorShape } from "@llm4ts/core/ProcessExecutor"
11
11
  import type { TemporaryFilesShape } from "@llm4ts/core/TemporaryFiles"
12
12
  import type { GeminiCliExecutorShape } from "@llm4ts/core/providers/GeminiCliProvider"
13
13
  import { createConnectorRegistry } from "@llm4ts/core/providers/ConnectorFactories"
14
+ import { ConnectorIds } from "@llm4ts/core/Models"
15
+ import { ProviderError } from "@llm4ts/core/Errors"
14
16
  import { makeCostTracker, type CostTracker } from "@llm4ts/flow/CostTracker"
17
+ import { runGeminiAcpBridge } from "./NodeGeminiAcpBridge.ts"
18
+ import {
19
+ bridgeModelProblem,
20
+ defaultReadPiModelsJson,
21
+ geminiBridgePort,
22
+ resolveBridgeModel
23
+ } from "./PiModels.ts"
15
24
  import { checkCostBudget, makeCostRecord, type CostBudget } from "@llm4ts/flow/CostLedger"
16
25
  import { FlowLlmError, describeFlowError, type FlowError } from "@llm4ts/flow/FlowError"
17
26
  import {
@@ -50,6 +59,8 @@ export interface FlowRunnerDependencies {
50
59
  readonly registry: ConnectorRegistryShape
51
60
  readonly process: ProcessExecutorShape
52
61
  readonly files: PlainFileStoreShape
62
+ /** pi's own model config (ADR 0016), injectable so a test never reads $HOME. */
63
+ readonly readPiModelsJson?: () => string | undefined
53
64
  }
54
65
 
55
66
  export interface NodeConnectorDependencies {
@@ -99,6 +110,68 @@ export interface FlowRunnerBundle {
99
110
  readonly tracker: CostTracker
100
111
  }
101
112
 
113
+ const truthy = (value: string | undefined): boolean =>
114
+ value !== undefined && ["1", "true", "yes"].includes(value.trim().toLowerCase())
115
+
116
+ const isPiSeat = (config: ConnectorConfig | undefined): config is CliConnectorConfig =>
117
+ config instanceof CliConnectorConfig && config.connectorId.value === ConnectorIds.Pi.value
118
+
119
+ /**
120
+ * Start the ADR 0016 bridge for a `pi`-coder run, scoped to that run, and
121
+ * return the mapping that points pi's seats at it.
122
+ *
123
+ * Opting in with `LLM4TS_GEMINI_BRIDGE` but leaving pi unable to reach the
124
+ * bridge is the failure this whole feature exists to avoid, so an
125
+ * unresolvable model fails the run here rather than surfacing as pi's own
126
+ * "No API key found" several layers down. A seat that already names a model
127
+ * is left alone: an explicit choice outranks the default.
128
+ */
129
+ const geminiBridgeSeats = Effect.fn("@llm4ts/runner/FlowRunner.geminiBridgeSeats")(function* (
130
+ options: FlowRunnerOptions,
131
+ dependencies: FlowRunnerDependencies,
132
+ environment: Readonly<Record<string, string | undefined>>
133
+ ): Effect.fn.Return<(config: ConnectorConfig) => ConnectorConfig, FlowError, Scope.Scope> {
134
+ const identity = (config: ConnectorConfig): ConnectorConfig => config
135
+ if (!truthy(environment.LLM4TS_GEMINI_BRIDGE)) {
136
+ return identity
137
+ }
138
+ // The reasoning seat defaults to the coder, so a pi coder makes this a pi
139
+ // run even when nothing else names pi.
140
+ const seats = [options.coder, options.reasoning, ...(options.reviewers ?? [])]
141
+ if (!seats.some(isPiSeat)) {
142
+ return identity
143
+ }
144
+
145
+ const port = geminiBridgePort(environment)
146
+ const readPiModelsJson = dependencies.readPiModelsJson ?? defaultReadPiModelsJson
147
+ const resolution = resolveBridgeModel(
148
+ readPiModelsJson(),
149
+ port,
150
+ environment.LLM4TS_GEMINI_BRIDGE_MODEL
151
+ )
152
+ const problem = bridgeModelProblem(resolution, port)
153
+ if (problem !== undefined || resolution._tag !== "Resolved") {
154
+ return yield* FlowLlmError.from(
155
+ ProviderError.make({ message: problem ?? "no bridge model available" })
156
+ )
157
+ }
158
+ const model = resolution.model
159
+
160
+ yield* runGeminiAcpBridge({
161
+ port: Number(port),
162
+ cwd: options.workDir,
163
+ executor: dependencies.process,
164
+ ...(environment.LLM4TS_GEMINI_MODEL === undefined
165
+ ? {}
166
+ : { model: environment.LLM4TS_GEMINI_MODEL })
167
+ }).pipe(Effect.mapError(FlowLlmError.from))
168
+
169
+ return (config: ConnectorConfig): ConnectorConfig =>
170
+ isPiSeat(config) && config.model === undefined
171
+ ? CliConnectorConfig.make({ ...config, model })
172
+ : config
173
+ })
174
+
102
175
  export const makeFlowRunnerContext = Effect.fn("@llm4ts/runner/FlowRunner.makeContext")(function* (
103
176
  options: FlowRunnerOptions,
104
177
  dependencies: FlowRunnerDependencies
@@ -107,6 +180,13 @@ export const makeFlowRunnerContext = Effect.fn("@llm4ts/runner/FlowRunner.makeCo
107
180
  const tracker = yield* makeCostTracker()
108
181
  yield* tracker.consume(events)
109
182
  const reasoning = defaultReasoningConfig(options.coder, options.reasoning)
183
+ // Bound to this run's scope: the bridge subprocess and its server go away
184
+ // with the run, matching every other long-lived subprocess here (ADR 0016).
185
+ const bridged = yield* geminiBridgeSeats(
186
+ options,
187
+ dependencies,
188
+ options.environment ?? process.env
189
+ )
110
190
  // Every seat retries transient provider failures and flaky streams (an
111
191
  // empty response or malformed tool call), announcing each attempt on the
112
192
  // run's events. Without the wrapper one hiccup from a CLI agent failed the
@@ -157,10 +237,12 @@ export const makeFlowRunnerContext = Effect.fn("@llm4ts/runner/FlowRunner.makeCo
157
237
  Scope.Scope
158
238
  > {
159
239
  const environment = options.environment ?? process.env
160
- const coder = yield* resolveSeat(prepareConnector(options.coder, workDir, environment))
161
- const reasoningService = yield* resolveSeat(prepareConnector(reasoning, workDir, environment))
240
+ const coder = yield* resolveSeat(prepareConnector(bridged(options.coder), workDir, environment))
241
+ const reasoningService = yield* resolveSeat(
242
+ prepareConnector(bridged(reasoning), workDir, environment)
243
+ )
162
244
  const reviewers = yield* Effect.forEach(options.reviewers ?? [], (configuration) =>
163
- resolveSeat(prepareConnector(configuration, workDir, environment))
245
+ resolveSeat(prepareConnector(bridged(configuration), workDir, environment))
164
246
  )
165
247
  return { coder, reasoning: reasoningService, reviewers }
166
248
  })
@@ -195,6 +195,17 @@ const toolResultInLastMessage = (
195
195
  const userTextInLastMessage = (body: JsonValue): string =>
196
196
  contentBlocksText(jsonField(lastMessage(body), "content"))
197
197
 
198
+ /**
199
+ * The route to match, without the query string. `request.url` carries it,
200
+ * and pi's bundled Anthropic SDK posts to `/v1/messages?beta=true` for
201
+ * tool-enabled requests — routing on the raw value 404s exactly the request
202
+ * this bridge exists to serve.
203
+ */
204
+ export const requestPath = (url: string | undefined): string => {
205
+ const path = (url ?? "/").split(/[?#]/)[0]
206
+ return path === undefined || path.length === 0 ? "/" : path
207
+ }
208
+
198
209
  const anthropicResponse = (model: string, outcome: TurnOutcome): JsonRecord => ({
199
210
  id: `msg_${Date.now()}`,
200
211
  type: "message",
@@ -211,9 +222,15 @@ const anthropicResponse = (model: string, outcome: TurnOutcome): JsonRecord => (
211
222
  usage: { input_tokens: 0, output_tokens: 0 }
212
223
  })
213
224
 
214
- export const handleMessagesRequest = Effect.fn(
215
- "@llm4ts/runner/NodeGeminiAcpBridge.handleMessagesRequest"
216
- )(function* (state: BridgeState, body: JsonValue): Effect.fn.Return<JsonRecord, LlmError> {
225
+ /**
226
+ * Drive one turn to its outcome. Split from the response rendering because a
227
+ * streaming request must write `message_start` before this resolves — pi
228
+ * hardcodes `stream: true`, and gemini can take a while to answer.
229
+ */
230
+ export const resolveTurn = Effect.fn("@llm4ts/runner/NodeGeminiAcpBridge.resolveTurn")(function* (
231
+ state: BridgeState,
232
+ body: JsonValue
233
+ ): Effect.fn.Return<TurnOutcome, LlmError> {
217
234
  const continuation = toolResultInLastMessage(body)
218
235
  let pendingToResolve: PendingToolCall | undefined
219
236
  if (continuation !== undefined) {
@@ -264,7 +281,123 @@ export const handleMessagesRequest = Effect.fn(
264
281
  const outcome = yield* Deferred.await(outcomeDeferred)
265
282
  yield* Ref.set(state.turnOutcome, Option.none())
266
283
 
267
- return anthropicResponse(jsonStringField(body, "model") ?? "gemini", outcome)
284
+ return outcome
285
+ })
286
+
287
+ export const requestModel = (body: JsonValue): string => jsonStringField(body, "model") ?? "gemini"
288
+
289
+ /**
290
+ * One Anthropic Messages streaming event. pi's bundled SDK hardcodes
291
+ * `stream: true` on every request and there is no compatibility flag to turn
292
+ * it off, so this transport — not the single-JSON one — is the live path.
293
+ *
294
+ * ACP hands this bridge a finished turn rather than a token stream, so each
295
+ * block is emitted as one delta. That is a well-formed event sequence the
296
+ * client reassembles correctly; it simply arrives in a single chunk instead
297
+ * of token by token.
298
+ */
299
+ export interface StreamEvent {
300
+ readonly event: string
301
+ readonly data: JsonRecord
302
+ }
303
+
304
+ const emptyUsage: JsonRecord = { input_tokens: 0, output_tokens: 0 }
305
+
306
+ /** Sent before the turn resolves, so the client sees a live response. */
307
+ export const turnStartEvents = (id: string, model: string): ReadonlyArray<StreamEvent> => [
308
+ {
309
+ event: "message_start",
310
+ data: {
311
+ type: "message_start",
312
+ message: {
313
+ id,
314
+ type: "message",
315
+ role: "assistant",
316
+ model,
317
+ content: [],
318
+ stop_reason: null,
319
+ stop_sequence: null,
320
+ usage: emptyUsage
321
+ }
322
+ }
323
+ }
324
+ ]
325
+
326
+ /**
327
+ * The blocks and stop events for a resolved turn. A `tool_use` block carries
328
+ * an empty `input` and supplies the real arguments through `input_json_delta`,
329
+ * which is the shape the Anthropic client expects to accumulate.
330
+ */
331
+ export const turnOutcomeEvents = (outcome: TurnOutcome): ReadonlyArray<StreamEvent> => {
332
+ const blocks: ReadonlyArray<StreamEvent> =
333
+ outcome._tag === "Text"
334
+ ? [
335
+ {
336
+ event: "content_block_start",
337
+ data: {
338
+ type: "content_block_start",
339
+ index: 0,
340
+ content_block: { type: "text", text: "" }
341
+ }
342
+ },
343
+ {
344
+ event: "content_block_delta",
345
+ data: {
346
+ type: "content_block_delta",
347
+ index: 0,
348
+ delta: { type: "text_delta", text: outcome.text }
349
+ }
350
+ }
351
+ ]
352
+ : [
353
+ {
354
+ event: "content_block_start",
355
+ data: {
356
+ type: "content_block_start",
357
+ index: 0,
358
+ content_block: { type: "tool_use", id: outcome.id, name: outcome.name, input: {} }
359
+ }
360
+ },
361
+ {
362
+ event: "content_block_delta",
363
+ data: {
364
+ type: "content_block_delta",
365
+ index: 0,
366
+ delta: { type: "input_json_delta", partial_json: JSON.stringify(outcome.input) }
367
+ }
368
+ }
369
+ ]
370
+ return [
371
+ ...blocks,
372
+ { event: "content_block_stop", data: { type: "content_block_stop", index: 0 } },
373
+ {
374
+ event: "message_delta",
375
+ data: {
376
+ type: "message_delta",
377
+ delta: {
378
+ stop_reason: outcome._tag === "Text" ? "end_turn" : "tool_use",
379
+ stop_sequence: null
380
+ },
381
+ usage: { output_tokens: 0 }
382
+ }
383
+ },
384
+ { event: "message_stop", data: { type: "message_stop" } }
385
+ ]
386
+ }
387
+
388
+ /** A failure after `message_start` can only be reported in-band. */
389
+ export const turnErrorEvent = (message: string): StreamEvent => ({
390
+ event: "error",
391
+ data: { type: "error", error: { type: "api_error", message } }
392
+ })
393
+
394
+ export const messageId = (): string => `msg_${Date.now()}`
395
+
396
+ export const handleMessagesRequest = Effect.fn(
397
+ "@llm4ts/runner/NodeGeminiAcpBridge.handleMessagesRequest"
398
+ )(function* (state: BridgeState, body: JsonValue): Effect.fn.Return<JsonRecord, LlmError> {
399
+ const outcome = yield* resolveTurn(state, body)
400
+ return anthropicResponse(requestModel(body), outcome)
268
401
  })
269
402
 
270
403
  const readBody = (request: IncomingMessage): Effect.Effect<string, ProviderError> =>
@@ -285,6 +418,47 @@ const parseJsonBody = (raw: string): Effect.Effect<JsonValue, InvalidRequestErro
285
418
  catch: () => InvalidRequestError.make({ message: "request body is not valid JSON" })
286
419
  })
287
420
 
421
+ const writeStreamEvent = (response: ServerResponse, event: StreamEvent): void => {
422
+ response.write(`event: ${event.event}\ndata: ${JSON.stringify(event.data)}\n\n`)
423
+ }
424
+
425
+ /**
426
+ * Stream one turn. The head goes out before the turn resolves so the client
427
+ * has a live response while gemini works; after that point a failure can only
428
+ * be reported as an in-band `error` event, never as a status code.
429
+ */
430
+ const respondStreaming = (
431
+ response: ServerResponse,
432
+ state: BridgeState,
433
+ body: JsonValue
434
+ ): Effect.Effect<void> =>
435
+ Effect.sync(() => {
436
+ response.writeHead(200, {
437
+ "Content-Type": "text/event-stream",
438
+ "Cache-Control": "no-cache",
439
+ Connection: "keep-alive"
440
+ })
441
+ for (const event of turnStartEvents(messageId(), requestModel(body))) {
442
+ writeStreamEvent(response, event)
443
+ }
444
+ }).pipe(
445
+ Effect.andThen(resolveTurn(state, body)),
446
+ Effect.matchEffect({
447
+ onSuccess: (outcome) =>
448
+ Effect.sync(() => {
449
+ for (const event of turnOutcomeEvents(outcome)) {
450
+ writeStreamEvent(response, event)
451
+ }
452
+ response.end()
453
+ }),
454
+ onFailure: (error) =>
455
+ Effect.sync(() => {
456
+ writeStreamEvent(response, turnErrorEvent(error.message))
457
+ response.end()
458
+ })
459
+ })
460
+ )
461
+
288
462
  const writeJson = (response: ServerResponse, status: number, body: JsonValue): void => {
289
463
  const text = JSON.stringify(body)
290
464
  response.writeHead(status, { "Content-Type": "application/json" })
@@ -356,7 +530,7 @@ export const runGeminiAcpBridge = Effect.fn("@llm4ts/runner/NodeGeminiAcpBridge.
356
530
  const state = yield* makeBridgeState(session, config.cwd, mcpServerUrl, forkBackground)
357
531
 
358
532
  const requestListener = (request: IncomingMessage, response: ServerResponse): void => {
359
- const path = request.url ?? "/"
533
+ const path = requestPath(request.url)
360
534
  if (request.method !== "POST" || (path !== "/v1/messages" && path !== "/mcp")) {
361
535
  writeJson(response, 404, { error: { message: "not found" } })
362
536
  return
@@ -369,23 +543,30 @@ export const runGeminiAcpBridge = Effect.fn("@llm4ts/runner/NodeGeminiAcpBridge.
369
543
  const tools = yield* Ref.get(state.tools)
370
544
  const mcpTools = tools.map((tool) => anthropicToolToMcpTool(state, tool))
371
545
  const reply = yield* handleMcpRequest(body, mcpTools)
372
- return { status: 200, body: reply ?? {} }
546
+ return yield* Effect.sync(() => writeJson(response, 200, reply ?? {}))
547
+ }
548
+ if (jsonBooleanField(body, "stream") === true) {
549
+ return yield* respondStreaming(response, state, body)
373
550
  }
374
551
  const reply = yield* handleMessagesRequest(state, body)
375
- return { status: 200, body: reply }
552
+ return yield* Effect.sync(() => writeJson(response, 200, reply))
376
553
  }).pipe(
554
+ // Only a failure before the head went out can still choose a status;
555
+ // `respondStreaming` owns everything after that and never fails.
377
556
  Effect.catch((error) =>
378
- Effect.succeed({
379
- status: errorStatus(error),
380
- body: { error: { message: error.message } }
557
+ Effect.sync(() => {
558
+ if (!response.headersSent) {
559
+ writeJson(response, errorStatus(error), { error: { message: error.message } })
560
+ }
381
561
  })
382
562
  )
383
563
  )
384
564
 
385
- Effect.runPromise(handled).then(
386
- ({ status, body }: { status: number; body: JsonValue }) => writeJson(response, status, body),
387
- (defect: unknown) => writeJson(response, 500, { error: { message: String(defect) } })
388
- )
565
+ Effect.runPromise(handled).catch((defect: unknown) => {
566
+ if (!response.headersSent) {
567
+ writeJson(response, 500, { error: { message: String(defect) } })
568
+ }
569
+ })
389
570
  }
390
571
 
391
572
  const server = yield* acquireServer(config.port, requestListener)