threadwire 0.1.6 → 0.1.9

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,94 @@
1
+ // @ts-nocheck
2
+ /* eslint-disable jsdoc/require-jsdoc */
3
+
4
+ export class AbsoluteDeadline {
5
+ constructor(expiresAt, {signal, request, response, now = Date.now, timeoutMessage = "Operation timeout", disconnectMessage = "Caller disconnected"} = {}) {
6
+ if (!Number.isSafeInteger(expiresAt) || expiresAt <= now()) throw new Error(timeoutMessage)
7
+ this.expiresAt = expiresAt
8
+ this.now = now
9
+ this.controller = new AbortController()
10
+ this.signal = this.controller.signal
11
+ this.parentSignal = signal
12
+ this.request = request
13
+ this.response = response
14
+ this.socket = request?.socket
15
+ this.timeoutMessage = timeoutMessage
16
+ this.abortParent = () => this.controller.abort(signal.reason instanceof Error ? signal.reason : new Error("Operation aborted"))
17
+ this.abortRequest = () => this.controller.abort(new Error(disconnectMessage))
18
+ this.abortResponse = () => {
19
+ if (!response?.writableEnded) this.abortRequest()
20
+ }
21
+ this.abortSocket = () => {
22
+ if (!response?.writableEnded) this.abortRequest()
23
+ }
24
+ if (signal?.aborted) this.abortParent()
25
+ else signal?.addEventListener("abort", this.abortParent, {once: true})
26
+ request?.once?.("aborted", this.abortRequest)
27
+ response?.once?.("close", this.abortResponse)
28
+ this.socket?.once?.("close", this.abortSocket)
29
+ this.timer = setTimeout(() => this.controller.abort(new Error(timeoutMessage)), Math.max(1, expiresAt - now()))
30
+ this.timer.unref()
31
+ this.signal.addEventListener("abort", () => request?.destroy?.(this.signal.reason), {once: true})
32
+ if (request?.aborted || (response?.destroyed && !response.writableEnded)) this.abortRequest()
33
+ }
34
+ remaining() {
35
+ this.throwIfAborted()
36
+ const remaining = this.expiresAt - this.now()
37
+ if (remaining <= 0) {
38
+ this.controller.abort(new Error(this.timeoutMessage))
39
+ this.throwIfAborted()
40
+ }
41
+ return Math.max(1, remaining)
42
+ }
43
+ options() {
44
+ return {signal: this.signal, timeoutMs: this.remaining()}
45
+ }
46
+ throwIfAborted() {
47
+ if (this.signal.aborted) throw this.signal.reason instanceof Error ? this.signal.reason : new Error("Operation aborted")
48
+ }
49
+ close() {
50
+ clearTimeout(this.timer)
51
+ this.abortParent && this.parentSignal?.removeEventListener?.("abort", this.abortParent)
52
+ this.request?.off?.("aborted", this.abortRequest)
53
+ this.response?.off?.("close", this.abortResponse)
54
+ this.socket?.off?.("close", this.abortSocket)
55
+ }
56
+ }
57
+
58
+ export function deadlineAfter(timeoutMs, options = {}) {
59
+ const now = options.now ?? Date.now
60
+ return new AbsoluteDeadline(now() + timeoutMs, {...options, now})
61
+ }
62
+
63
+ export async function abortable(promise, signal) {
64
+ if (signal?.aborted) throw signal.reason
65
+ if (!signal) return promise
66
+ return new Promise((resolve, reject) => {
67
+ const abort = () => reject(signal.reason instanceof Error ? signal.reason : new Error("Operation aborted"))
68
+ signal.addEventListener("abort", abort, {once: true})
69
+ Promise.resolve(promise).then(resolve, reject).finally(() => signal.removeEventListener("abort", abort))
70
+ })
71
+ }
72
+
73
+ export async function readResponseCapped(response, capacity, signal) {
74
+ const reader = response.body?.getReader()
75
+ if (!reader) return Buffer.alloc(0)
76
+ const chunks = []
77
+ let size = 0
78
+ try {
79
+ while (true) {
80
+ const {done, value} = await abortable(reader.read(), signal)
81
+ if (done) break
82
+ const chunk = Buffer.from(value)
83
+ size += chunk.length
84
+ if (size > capacity) {
85
+ await reader.cancel(new Error("Response exceeded capacity")).catch(() => {})
86
+ throw new Error("Response exceeded capacity")
87
+ }
88
+ chunks.push(chunk)
89
+ }
90
+ return Buffer.concat(chunks, size)
91
+ } finally {
92
+ reader.releaseLock()
93
+ }
94
+ }
@@ -24,13 +24,13 @@ export class ActivityLog {
24
24
  this.write({type: "workspace-selected", profile, repositoryRoot, revision, sourceIdentity})
25
25
  }
26
26
 
27
- /** @param {"codex" | "claude" | "opencode"} provider @param {number} pid */
27
+ /** @param {"codex" | "claude" | "kimi" | "opencode"} provider @param {number} pid */
28
28
  recordStarted(provider, pid) {
29
29
  if (!Number.isSafeInteger(pid) || pid <= 0) throw new Error("Provider child PID is unavailable")
30
30
  this.write({type: "provider-started", provider, pid})
31
31
  }
32
32
 
33
- /** @param {"codex" | "claude" | "opencode"} provider @param {string} sessionId */
33
+ /** @param {"codex" | "claude" | "kimi" | "opencode"} provider @param {string} sessionId */
34
34
  recordSession(provider, sessionId) {
35
35
  if (!SESSION_ID_PATTERN.test(sessionId)) return
36
36
  this.write({type: "session-available", provider, sessionId})
@@ -42,7 +42,7 @@ export class ActivityLog {
42
42
  closeSync(this.fileDescriptor)
43
43
  }
44
44
 
45
- /** @param {{type: "workspace-selected", profile: string, repositoryRoot: string, revision: string, sourceIdentity: string} | {type: "provider-started", provider: "codex" | "claude" | "opencode", pid: number} | {type: "session-available", provider: "codex" | "claude" | "opencode", sessionId: string}} fact */
45
+ /** @param {{type: "workspace-selected", profile: string, repositoryRoot: string, revision: string, sourceIdentity: string} | {type: "provider-started", provider: "codex" | "claude" | "kimi" | "opencode", pid: number} | {type: "session-available", provider: "codex" | "claude" | "kimi" | "opencode", sessionId: string}} fact */
46
46
  write(fact) {
47
47
  if (this.closed) throw new Error("Activity log is closed")
48
48
  writeSync(this.fileDescriptor, `${JSON.stringify(fact)}\n`)
package/src/cli.js CHANGED
@@ -15,18 +15,25 @@ import {resolveWorkspaceProfile} from "./workspace-profile.js"
15
15
  import {WorkerControl} from "./worker-control.js"
16
16
  import {EvidenceStore} from "./evidence-store.js"
17
17
  import {ContextBudgetMetrics} from "./context-budget-metrics.js"
18
+ import {isolatedRuntimeClientFromEnvironment, kimiIsolatedRuntimeClientFromEnvironment} from "./isolated-runtime-client.js"
19
+ import {kimiSessionEnvelopeId} from "./providers/kimi.js"
20
+ import {validateRelayWriteProviderArguments} from "./relay-write.js"
21
+ import {abortable} from "./absolute-deadline.js"
22
+ import {NormalizedOutput} from "./normalized-output.js"
18
23
 
19
- const HELP = `Usage: threadwire run --provider <codex|claude|opencode> --target telegram:<chat-id> | telegram:<chat-id>:<thread-id>
24
+ const HELP = `Usage: threadwire run --provider <codex|claude|kimi|opencode> --target telegram:<chat-id> | telegram:<chat-id>:<thread-id>
20
25
  [--process-number <positive-integer>] [--cwd <directory>]
21
26
  [--workspace-profile <name>]
27
+ [--relay-write]
22
28
  [--tool-messages] [--max-output-length <positive-integer>]
23
- [--resume-session <provider-session-id>] [--activity-log <local-jsonl-path>]
29
+ [--resume-session <provider-session-id>] [--transcript <normalized-jsonl-path>]
30
+ [--activity-log <local-jsonl-path>]
24
31
  [--prompt <text> | --prompt-file <path> | stdin]
25
32
  [-- <provider arguments...>]
26
33
  threadwire evidence read --handle <opaque-handle>
27
34
  (--bytes <offset>:<limit> | --lines <start>:<limit> | --query <literal> --context-bytes <limit>)`
28
35
 
29
- /** @typedef {{provider: string, target: string, cwd: string, toolMessages: boolean, workspaceProfile?: string, prompt?: string, promptFile?: string, processNumber?: number, maxOutputLength?: number, resumeSession?: string, activityLog?: string, providerArguments: string[]}} ParsedArguments */
36
+ /** @typedef {{provider: string, target: string, cwd: string, toolMessages: boolean, relayWrite: boolean, workspaceProfile?: string, prompt?: string, promptFile?: string, processNumber?: number, maxOutputLength?: number, resumeSession?: string, transcript?: string, activityLog?: string, providerArguments: string[]}} ParsedArguments */
30
37
  /** @typedef {{evidenceRead: true, request: unknown}} EvidenceParsedArguments */
31
38
  /**
32
39
  * @typedef {{
@@ -39,7 +46,9 @@ const HELP = `Usage: threadwire run --provider <codex|claude|opencode> --target
39
46
  * evidenceStore?: EvidenceStore,
40
47
  * evidenceOwnerScope?: {destinationId: string, runId: string},
41
48
  * workerControlOptions?: Pick<ConstructorParameters<typeof WorkerControl>[0], "queueOptions">,
42
- * workspaceProfileOperations?: import("./workspace-profile.js").WorkspaceProfileOperations
49
+ * workspaceProfileOperations?: import("./workspace-profile.js").WorkspaceProfileOperations,
50
+ * isolatedRuntimeClient?: Pick<import("./isolated-runtime-client.js").IsolatedRuntimeClient, "preflight" | "run">,
51
+ * kimiIsolatedRuntimeClient?: Pick<import("./isolated-runtime-client.js").IsolatedRuntimeClient, "preflight" | "run">
43
52
  * }} MainDependencies
44
53
  */
45
54
 
@@ -52,8 +61,9 @@ export function parseArguments(arguments_) {
52
61
  const ownArguments = separator < 0 ? arguments_.slice(1) : arguments_.slice(1, separator)
53
62
  const providerArguments = separator < 0 ? [] : arguments_.slice(separator + 1)
54
63
  /** @type {Partial<ParsedArguments>} */
55
- const parsed = {providerArguments, cwd: process.cwd(), toolMessages: false}
64
+ const parsed = {providerArguments, cwd: process.cwd(), toolMessages: false, relayWrite: false}
56
65
  let cwdProvided = false
66
+ let transcriptProvided = false
57
67
  for (let index = 0; index < ownArguments.length;) {
58
68
  const option = ownArguments[index]
59
69
  if (option === "--tool-messages") {
@@ -61,7 +71,15 @@ export function parseArguments(arguments_) {
61
71
  index += 1
62
72
  continue
63
73
  }
74
+ if (option === "--relay-write") {
75
+ parsed.relayWrite = true
76
+ index += 1
77
+ continue
78
+ }
64
79
  const value = ownArguments[index + 1]
80
+ if (option === "--transcript" && (value === undefined || value.startsWith("--"))) {
81
+ throw new Error("Missing value for --transcript")
82
+ }
65
83
  if (!option || !value) throw new Error(`Missing value for ${option ?? "option"}`)
66
84
  if (option === "--provider") parsed.provider = value
67
85
  else if (option === "--target") parsed.target = value
@@ -75,7 +93,12 @@ export function parseArguments(arguments_) {
75
93
  else if (option === "--max-output-length") parsed.maxOutputLength = positiveInteger(value, "--max-output-length")
76
94
  else if (option === "--resume-session") parsed.resumeSession = sessionId(value)
77
95
  else if (option === "--activity-log") parsed.activityLog = resolve(value)
78
- else throw new Error(`Unknown option: ${option}`)
96
+ else if (option === "--transcript") {
97
+ if (transcriptProvided) throw new Error("Duplicate option: --transcript")
98
+ if (value.includes("\0")) throw new Error("--transcript path is invalid")
99
+ parsed.transcript = resolve(value)
100
+ transcriptProvided = true
101
+ } else throw new Error(`Unknown option: ${option}`)
79
102
  index += 2
80
103
  }
81
104
  if (!parsed.provider) throw new Error("--provider is required")
@@ -87,6 +110,12 @@ export function parseArguments(arguments_) {
87
110
  if (parsed.workspaceProfile !== undefined && cwdProvided) {
88
111
  throw new Error("--cwd and --workspace-profile are mutually exclusive")
89
112
  }
113
+ if (parsed.transcript !== undefined && parsed.transcript === parsed.activityLog) {
114
+ throw new Error("--transcript and --activity-log must resolve to different paths")
115
+ }
116
+ if (parsed.relayWrite && parsed.workspaceProfile === undefined) throw new Error("--relay-write requires --workspace-profile")
117
+ if (parsed.provider === "kimi" && parsed.workspaceProfile === undefined) throw new Error("Kimi requires --workspace-profile")
118
+ if (parsed.relayWrite && parsed.provider !== "codex") throw new Error("--relay-write is only supported for codex")
90
119
  return /** @type {ParsedArguments} */ (parsed)
91
120
  }
92
121
 
@@ -140,6 +169,10 @@ export async function main(arguments_, dependencies = {}) {
140
169
  const sourceEnvironment = dependencies.env ?? process.env
141
170
  let environment = sourceEnvironment
142
171
  const validateOnly = environment.THREADWIRE_VALIDATE_ONLY === "1"
172
+ /** @type {NormalizedOutput | undefined} */
173
+ let normalizedOutput
174
+ /** @type {DelegatedResultAdmission | undefined} */
175
+ let runAdmission
143
176
  try {
144
177
  const parsed = arguments_[0] === "evidence" ? parseEvidenceArguments(arguments_) : parseArguments(arguments_)
145
178
  if ("help" in parsed) {
@@ -159,16 +192,37 @@ export async function main(arguments_, dependencies = {}) {
159
192
  await store.close()
160
193
  }
161
194
  }
195
+ if (!validateOnly) {
196
+ normalizedOutput = new NormalizedOutput(output, parsed.transcript)
197
+ }
198
+ const metrics = new ContextBudgetMetrics()
199
+ runAdmission = normalizedOutput === undefined ? undefined : new DelegatedResultAdmission({output: normalizedOutput, metrics})
162
200
  const target = parseTelegramTarget(parsed.target)
201
+ if (parsed.relayWrite) validateRelayWriteProviderArguments(parsed.providerArguments)
163
202
  const resolvedWorkspace = parsed.workspaceProfile === undefined
164
203
  ? undefined
165
204
  : await resolveWorkspaceProfile(
166
- {provider: /** @type {"codex" | "claude" | "opencode"} */ (parsed.provider), profile: parsed.workspaceProfile},
205
+ {provider: /** @type {"codex" | "claude" | "kimi" | "opencode"} */ (parsed.provider), profile: parsed.workspaceProfile},
167
206
  dependencies.workspaceProfileOperations
168
207
  )
169
208
  if (validateOnly) return 0
170
- const metrics = new ContextBudgetMetrics()
171
- const admission = new DelegatedResultAdmission({output, metrics})
209
+ const usesIsolatedRuntime = parsed.relayWrite || parsed.provider === "kimi"
210
+ const isolatedRuntimeClient = usesIsolatedRuntime
211
+ ? parsed.provider === "kimi"
212
+ ? dependencies.kimiIsolatedRuntimeClient ?? kimiIsolatedRuntimeClientFromEnvironment(sourceEnvironment)
213
+ : dependencies.isolatedRuntimeClient ?? isolatedRuntimeClientFromEnvironment(sourceEnvironment)
214
+ : undefined
215
+ const isolatedPreflight = usesIsolatedRuntime
216
+ ? await /** @type {NonNullable<typeof isolatedRuntimeClient>} */ (isolatedRuntimeClient).preflight({
217
+ provider: parsed.provider,
218
+ profile: /** @type {string} */ (parsed.workspaceProfile),
219
+ repositoryRoot: /** @type {import("./workspace-profile.js").ResolvedWorkspaceProfile} */ (resolvedWorkspace).repositoryRoot,
220
+ cwd: /** @type {import("./workspace-profile.js").ResolvedWorkspaceProfile} */ (resolvedWorkspace).cwd,
221
+ providerArguments: parsed.providerArguments,
222
+ ...(parsed.resumeSession === undefined ? {} : {resumeSession: parsed.resumeSession})
223
+ })
224
+ : undefined
225
+ const admission = /** @type {DelegatedResultAdmission} */ (runAdmission)
172
226
  let terminalExitCode = 2
173
227
  /** @type {unknown} */
174
228
  let evidenceError
@@ -179,12 +233,13 @@ export async function main(arguments_, dependencies = {}) {
179
233
  /** @type {EvidenceStore | undefined} */
180
234
  let ownedEvidenceStore
181
235
  let evidencePayloadBytes = 0
236
+ const launchDeadline = isolatedPreflight?.deadline
182
237
  try {
183
- environment = await resolveFileBackedSettings(sourceEnvironment, ["THREADWIRE_TELEGRAM_BOT_TOKEN"])
184
- const prompt = await readPrompt(parsed, dependencies.input ?? stdin)
238
+ environment = await boundedLaunch(resolveFileBackedSettings(sourceEnvironment, ["THREADWIRE_TELEGRAM_BOT_TOKEN"]), launchDeadline)
239
+ const prompt = await readPrompt(parsed, dependencies.input ?? stdin, launchDeadline?.signal)
185
240
  const configuredEvidenceRoot = evidenceRoot(sourceEnvironment.THREADWIRE_EVIDENCE_ROOT)
186
241
  if (dependencies.evidenceStore === undefined && configuredEvidenceRoot !== undefined) {
187
- ownedEvidenceStore = await EvidenceStore.open({root: configuredEvidenceRoot})
242
+ ownedEvidenceStore = await boundedLaunch(EvidenceStore.open({root: configuredEvidenceRoot}), launchDeadline)
188
243
  }
189
244
  const evidenceStore = dependencies.evidenceStore ?? ownedEvidenceStore
190
245
  if (evidenceStore !== undefined) {
@@ -194,16 +249,22 @@ export async function main(arguments_, dependencies = {}) {
194
249
  })
195
250
  evidence = await evidenceStore.createArtifact(evidenceOwner, {
196
251
  contentType: "text/plain; charset=utf-8",
197
- redactions: await collectEvidenceRedactions(environment)
252
+ redactions: await boundedLaunch(collectEvidenceRedactions(environment), launchDeadline)
198
253
  })
199
254
  }
200
255
  if (evidence !== undefined) {
201
256
  const promptEvidence = `prompt\n${prompt}\nprovider-stream\n`
202
- await evidence.append("prompt", promptEvidence)
257
+ await boundedLaunch(evidence.append("prompt", promptEvidence), launchDeadline)
203
258
  evidencePayloadBytes += Buffer.byteLength(promptEvidence, "utf8")
204
259
  }
205
260
  const providerEnvironment = buildProviderEnvironment(environment)
206
- const provider = createProvider(parsed.provider, parsed.providerArguments, prompt, parsed.resumeSession, providerEnvironment)
261
+ const provider = createProvider(
262
+ parsed.provider,
263
+ parsed.providerArguments,
264
+ prompt,
265
+ parsed.resumeSession,
266
+ usesIsolatedRuntime ? {} : providerEnvironment
267
+ )
207
268
  if (parsed.resumeSession !== undefined) admission.setContinuationHandle(parsed.resumeSession)
208
269
  if (parsed.workspaceProfile !== undefined) {
209
270
  if (resolvedWorkspace === undefined) throw new Error("Workspace profile resolution failed")
@@ -228,7 +289,8 @@ export async function main(arguments_, dependencies = {}) {
228
289
  resolvedWorkspace.sourceIdentity
229
290
  )
230
291
  }
231
- const exitCode = await (dependencies.workerRunner ?? runWorker)({
292
+ /** @type {import("./run-worker.js").RunWorkerOptions} */
293
+ const workerOptions = {
232
294
  executable: provider.executable,
233
295
  arguments: provider.arguments,
234
296
  cwd: resolvedWorkspace?.cwd ?? parsed.cwd,
@@ -247,7 +309,7 @@ export async function main(arguments_, dependencies = {}) {
247
309
  },
248
310
  onRecord: (record) => {
249
311
  metrics.recordParsedProviderRecord("provider_stdout")
250
- const id = provider.sessionId(record)
312
+ const id = parsed.provider === "kimi" ? kimiSessionEnvelopeId(record) : provider.sessionId(record)
251
313
  if (id !== undefined) {
252
314
  admission.setContinuationHandle(id)
253
315
  activity?.recordSession(provider.name, id)
@@ -265,8 +327,21 @@ export async function main(arguments_, dependencies = {}) {
265
327
  if (evidence === undefined) return undefined
266
328
  return evidence.append("provider-stderr", chunk).then(() => { evidencePayloadBytes += chunk.length })
267
329
  }
268
- })
269
- await control.close()
330
+ }
331
+ const exitCode = usesIsolatedRuntime
332
+ ? await /** @type {NonNullable<typeof isolatedRuntimeClient>} */ (isolatedRuntimeClient).run({
333
+ preflightId: /** @type {{preflightId: string}} */ (isolatedPreflight).preflightId,
334
+ prompt,
335
+ providerArguments: parsed.providerArguments,
336
+ ...(parsed.resumeSession === undefined ? {} : {resumeSession: parsed.resumeSession}),
337
+ onEvent: workerOptions.onEvent,
338
+ onRecord: /** @type {NonNullable<typeof workerOptions.onRecord>} */ (workerOptions.onRecord),
339
+ ...(parsed.provider === "kimi" || workerOptions.onStdoutChunk === undefined ? {} : {onStdoutChunk: workerOptions.onStdoutChunk}),
340
+ ...(parsed.provider === "kimi" || workerOptions.onStderrChunk === undefined ? {} : {onStderrChunk: workerOptions.onStderrChunk}),
341
+ ...(launchDeadline === undefined ? {} : {deadline: launchDeadline})
342
+ })
343
+ : await (dependencies.workerRunner ?? runWorker)(workerOptions)
344
+ await boundedLaunch(control.close(), launchDeadline)
270
345
  terminalExitCode = exitCode
271
346
  } finally {
272
347
  if (evidence !== undefined) {
@@ -289,13 +364,28 @@ export async function main(arguments_, dependencies = {}) {
289
364
  admission.complete({state: terminalExitCode === 0 ? "completed" : "failed", exitCode: terminalExitCode})
290
365
  errorOutput.write(`threadwire-context-metrics ${JSON.stringify(metrics.conciseDiagnostic())}\n`)
291
366
  activity?.close()
292
- await ownedEvidenceStore?.close()
367
+ await boundedLaunch(ownedEvidenceStore?.close(), launchDeadline).catch(() => {})
368
+ launchDeadline?.close()
293
369
  }
294
370
  if (evidenceError !== undefined) throw evidenceError
295
371
  return terminalExitCode
296
372
  } catch (error) {
297
- errorOutput.write(`threadwire: ${safeMessage(error)}\n`)
373
+ let reportedError = error
374
+ if (runAdmission !== undefined && !runAdmission.completed) {
375
+ try {
376
+ runAdmission.complete({state: "failed", exitCode: 2})
377
+ } catch (transcriptError) {
378
+ reportedError = transcriptError
379
+ }
380
+ }
381
+ errorOutput.write(`threadwire: ${safeMessage(reportedError)}\n`)
298
382
  return 2
383
+ } finally {
384
+ try {
385
+ normalizedOutput?.close()
386
+ } catch (error) {
387
+ errorOutput.write(`threadwire: ${safeMessage(error)}\n`)
388
+ }
299
389
  }
300
390
  }
301
391
 
@@ -350,21 +440,36 @@ function validateNormalizedWorkerEvent(event) {
350
440
  throw new Error("Worker emitted an unknown normalized event kind")
351
441
  }
352
442
 
353
- /** @param {ParsedArguments} parsed @param {NodeJS.ReadableStream & {isTTY?: boolean}} input */
354
- async function readPrompt(parsed, input) {
443
+ /** @param {ParsedArguments} parsed @param {NodeJS.ReadableStream & {isTTY?: boolean}} input @param {AbortSignal | undefined} signal */
444
+ async function readPrompt(parsed, input, signal) {
355
445
  let prompt
356
446
  if (parsed.prompt !== undefined) prompt = parsed.prompt
357
- else if (parsed.promptFile !== undefined) prompt = await readFile(parsed.promptFile, "utf8")
447
+ else if (parsed.promptFile !== undefined) prompt = await readFile(parsed.promptFile, {encoding: "utf8", signal})
358
448
  else {
359
449
  if (input.isTTY) throw new Error("A prompt is required via --prompt, --prompt-file, or piped stdin")
360
450
  prompt = ""
361
451
  input.setEncoding("utf8")
362
- for await (const chunk of input) prompt += chunk
452
+ const abort = () => /** @type {NodeJS.ReadableStream & {destroy?: (error?: unknown) => void}} */ (input).destroy?.(signal?.reason)
453
+ signal?.addEventListener("abort", abort, {once: true})
454
+ try {
455
+ for await (const chunk of input) {
456
+ if (signal?.aborted) throw signal.reason
457
+ prompt += chunk
458
+ }
459
+ } finally {
460
+ signal?.removeEventListener("abort", abort)
461
+ }
363
462
  }
364
463
  if (prompt.trim().length === 0) throw new Error("Prompt cannot be empty")
365
464
  return prompt
366
465
  }
367
466
 
467
+ /** @param {Promise<unknown> | undefined} promise @param {import("./absolute-deadline.js").AbsoluteDeadline | undefined} deadline */
468
+ function boundedLaunch(promise, deadline) {
469
+ deadline?.throwIfAborted()
470
+ return abortable(Promise.resolve(promise), deadline?.signal)
471
+ }
472
+
368
473
  /** @param {unknown} error */
369
474
  function safeMessage(error) {
370
475
  return error instanceof Error ? error.message : "Unknown error"
@@ -45,7 +45,7 @@ const STATES = new Set(["completed", "failed", "blocked", "needs_decision"])
45
45
  */
46
46
 
47
47
  export class DelegatedResultAdmission {
48
- /** @param {{output: NodeJS.WritableStream, metrics?: import("./context-budget-metrics.js").ContextBudgetMetrics}} options */
48
+ /** @param {{output: {write: (chunk: string) => unknown}, metrics?: import("./context-budget-metrics.js").ContextBudgetMetrics}} options */
49
49
  constructor(options) {
50
50
  this.output = options.output
51
51
  this.metrics = options.metrics
@@ -0,0 +1,131 @@
1
+ // @ts-nocheck
2
+ /* eslint-disable jsdoc/require-jsdoc */
3
+
4
+ import {request as httpRequest} from "node:http"
5
+
6
+ export class DockerApi {
7
+ /** @param {{host?: string, requestImplementation?: typeof httpRequest}} [options] */
8
+ constructor(options = {}) {
9
+ this.host = options.host ?? process.env.DOCKER_HOST ?? "unix:///var/run/docker.sock"
10
+ this.requestImplementation = options.requestImplementation ?? httpRequest
11
+ }
12
+
13
+ /** @param {string} method @param {string} path @param {unknown} [body] @param {boolean} [raw] */
14
+ request(method, path, body, raw = false, options = {}) {
15
+ const timeoutMs = options.timeoutMs ?? 30_000
16
+ const payload = body === undefined ? undefined : Buffer.from(JSON.stringify(body))
17
+ const target = dockerTarget(this.host, path)
18
+ return new Promise((resolve, reject) => {
19
+ const request = this.requestImplementation({
20
+ ...target,
21
+ method,
22
+ headers: payload === undefined ? {} : {"content-type": "application/json", "content-length": payload.length}
23
+ }, (response) => {
24
+ /** @type {Buffer[]} */
25
+ const chunks = []
26
+ let bytes = 0
27
+ response.on("data", (chunk) => {
28
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
29
+ bytes += buffer.length
30
+ if (bytes > 8_388_608) {
31
+ request.destroy(new Error("Docker response exceeded capacity"))
32
+ return
33
+ }
34
+ chunks.push(buffer)
35
+ })
36
+ response.on("end", () => {
37
+ const combined = Buffer.concat(chunks)
38
+ const content = combined.toString("utf8")
39
+ const status = response.statusCode ?? 500
40
+ if (status < 200 || status >= 300) {
41
+ reject(new Error(`Docker API ${method} ${path} failed (${status})`))
42
+ return
43
+ }
44
+ if (combined.length === 0) {
45
+ resolve(undefined)
46
+ return
47
+ }
48
+ if (raw) {
49
+ resolve(combined)
50
+ return
51
+ }
52
+ try {
53
+ resolve(JSON.parse(content))
54
+ } catch {
55
+ resolve(content)
56
+ }
57
+ })
58
+ })
59
+ request.on("error", reject)
60
+ const abort = () => request.destroy(options.signal.reason instanceof Error ? options.signal.reason : new Error("Docker request aborted"))
61
+ if (options.signal?.aborted) abort()
62
+ else options.signal?.addEventListener("abort", abort, {once: true})
63
+ const deadline = setTimeout(
64
+ () => request.destroy(new Error(`Docker API ${method} ${path} timed out`)),
65
+ timeoutMs
66
+ )
67
+ deadline.unref()
68
+ request.once("close", () => {
69
+ clearTimeout(deadline)
70
+ options.signal?.removeEventListener("abort", abort)
71
+ })
72
+ if (payload !== undefined) request.end(payload)
73
+ else request.end()
74
+ })
75
+ }
76
+
77
+ version(options) { return this.request("GET", "/version", undefined, false, options) }
78
+ inspectImage(image, options) { return this.request("GET", `/images/${encodeURIComponent(image)}/json`, undefined, false, options) }
79
+ createNetwork(name, labels = {}, options) {
80
+ return this.request("POST", "/networks/create", {
81
+ Name: name,
82
+ Internal: true,
83
+ CheckDuplicate: true,
84
+ EnableIPv6: false,
85
+ Labels: {"org.threadwire.owner": "isolated-runtime", ...labels}
86
+ }, false, options)
87
+ }
88
+ removeNetwork(id, options) { return this.request("DELETE", `/networks/${encodeURIComponent(id)}`, undefined, false, options) }
89
+ inspectNetwork(id, options) { return this.request("GET", `/networks/${encodeURIComponent(id)}`, undefined, false, options) }
90
+ connectNetwork(id, container, aliases = [], options) {
91
+ return this.request("POST", `/networks/${encodeURIComponent(id)}/connect`, {
92
+ Container: container,
93
+ EndpointConfig: {Aliases: aliases}
94
+ }, false, options)
95
+ }
96
+ disconnectNetwork(id, container, options) {
97
+ return this.request("POST", `/networks/${encodeURIComponent(id)}/disconnect`, {Container: container, Force: true}, false, options)
98
+ }
99
+ createContainer(name, spec, options) {
100
+ return this.request("POST", `/containers/create?name=${encodeURIComponent(name)}`, spec, false, options)
101
+ }
102
+ listContainers(labels = {}, all = true, options) {
103
+ const filters = {label: Object.entries(labels).map(([key, value]) => `${key}=${value}`)}
104
+ return this.request("GET", `/containers/json?all=${all ? 1 : 0}&filters=${encodeURIComponent(JSON.stringify(filters))}`, undefined, false, options)
105
+ }
106
+ listNetworks(labels = {}, options) {
107
+ const filters = {label: Object.entries(labels).map(([key, value]) => `${key}=${value}`)}
108
+ return this.request("GET", `/networks?filters=${encodeURIComponent(JSON.stringify(filters))}`, undefined, false, options)
109
+ }
110
+ startContainer(id, options) { return this.request("POST", `/containers/${encodeURIComponent(id)}/start`, undefined, false, options) }
111
+ waitContainer(id, options) { return this.request("POST", `/containers/${encodeURIComponent(id)}/wait?condition=not-running`, undefined, false, options) }
112
+ logs(id, options) { return this.request("GET", `/containers/${encodeURIComponent(id)}/logs?stdout=1&stderr=1`, undefined, true, options) }
113
+ inspectContainer(id, options) { return this.request("GET", `/containers/${encodeURIComponent(id)}/json`, undefined, false, options) }
114
+ removeContainer(id, options) { return this.request("DELETE", `/containers/${encodeURIComponent(id)}?force=1&v=1`, undefined, false, options) }
115
+ createVolume(name, labels = {}, options) {
116
+ return this.request("POST", "/volumes/create", {Name: name, Labels: labels}, false, options)
117
+ }
118
+ listVolumes(labels = {}, options) {
119
+ const filters = {label: Object.entries(labels).map(([key, value]) => `${key}=${value}`)}
120
+ return this.request("GET", `/volumes?filters=${encodeURIComponent(JSON.stringify(filters))}`, undefined, false, options)
121
+ }
122
+ inspectVolume(name, options) { return this.request("GET", `/volumes/${encodeURIComponent(name)}`, undefined, false, options) }
123
+ removeVolume(name, options) { return this.request("DELETE", `/volumes/${encodeURIComponent(name)}?force=0`, undefined, false, options) }
124
+ }
125
+
126
+ function dockerTarget(host, path) {
127
+ const url = new URL(host)
128
+ if (url.protocol === "unix:") return {socketPath: url.pathname, path}
129
+ if (url.protocol !== "tcp:" && url.protocol !== "http:") throw new Error("DOCKER_HOST must use unix or tcp")
130
+ return {hostname: url.hostname, port: Number(url.port || "2375"), path}
131
+ }