threadwire 0.1.5 → 0.1.8

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 (40) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +17 -4
  3. package/TELEGRAM-INGRESS.md +8 -2
  4. package/bin/isolated-runtime.js +5 -0
  5. package/bin/model-broker.js +5 -0
  6. package/docs/card-10520-plan.md +45 -0
  7. package/docs/container-runtime.md +3 -2
  8. package/docs/delegated-result-protocol.md +161 -0
  9. package/docs/evidence-artifacts.md +106 -0
  10. package/docs/isolated-provider-runtime.md +137 -0
  11. package/package.json +5 -1
  12. package/scripts/provider-shims/front-door.sh.template +18 -0
  13. package/scripts/verify-package.js +22 -1
  14. package/src/absolute-deadline.js +94 -0
  15. package/src/cli.js +362 -42
  16. package/src/context-budget-metrics.js +180 -0
  17. package/src/delegated-result-admission.js +377 -0
  18. package/src/docker-api.js +131 -0
  19. package/src/evidence-store.js +1472 -0
  20. package/src/isolated-runtime-client.js +149 -0
  21. package/src/isolated-runtime.js +982 -0
  22. package/src/isolated-state.js +409 -0
  23. package/src/isolated-worker.js +123 -0
  24. package/src/model-broker-policy.js +139 -0
  25. package/src/model-broker.js +313 -0
  26. package/src/mount-policy.js +28 -0
  27. package/src/normalized-output.js +68 -0
  28. package/src/notice-queue.js +22 -7
  29. package/src/providers/opencode.js +42 -18
  30. package/src/relay-write.js +44 -0
  31. package/src/relay.js +6 -6
  32. package/src/run-worker.js +158 -40
  33. package/src/telegram-ingress/command.js +16 -0
  34. package/src/telegram-ingress/config.js +96 -11
  35. package/src/telegram-ingress/core.js +201 -89
  36. package/src/telegram-ingress/http.js +17 -1
  37. package/src/telegram-webhook.js +22 -0
  38. package/src/types.js +2 -2
  39. package/src/worker-control.js +294 -0
  40. package/src/hermes-protocol.js +0 -126
package/src/cli.js CHANGED
@@ -1,28 +1,39 @@
1
1
  // @ts-check
2
2
 
3
3
  import {readFile} from "node:fs/promises"
4
- import {resolve} from "node:path"
4
+ import {randomUUID} from "node:crypto"
5
+ import {isAbsolute, normalize, resolve} from "node:path"
5
6
  import {stdin, stderr, stdout} from "node:process"
6
7
  import {createFetchTransport} from "./notifiers/fetch-transport.js"
7
8
  import {createTelegramSender, parseTelegramTarget} from "./notifiers/telegram.js"
8
- import {NoticeQueue} from "./notice-queue.js"
9
9
  import {createProvider, PROVIDERS} from "./providers/index.js"
10
- import {Relay} from "./relay.js"
11
10
  import {runWorker} from "./run-worker.js"
12
11
  import {ActivityLog} from "./activity-log.js"
13
- import {HermesProtocol} from "./hermes-protocol.js"
14
- import {buildProviderEnvironment, parseTelegramRequestTimeoutMs, resolveFileBackedSettings} from "./telegram-ingress/config.js"
12
+ import {DelegatedResultAdmission, validateContinuationHandle} from "./delegated-result-admission.js"
13
+ import {buildProviderEnvironment, collectEvidenceRedactions, parseTelegramRequestTimeoutMs, resolveFileBackedSettings} from "./telegram-ingress/config.js"
15
14
  import {resolveWorkspaceProfile} from "./workspace-profile.js"
15
+ import {WorkerControl} from "./worker-control.js"
16
+ import {EvidenceStore} from "./evidence-store.js"
17
+ import {ContextBudgetMetrics} from "./context-budget-metrics.js"
18
+ import {isolatedRuntimeClientFromEnvironment} from "./isolated-runtime-client.js"
19
+ import {validateRelayWriteProviderArguments} from "./relay-write.js"
20
+ import {abortable} from "./absolute-deadline.js"
21
+ import {NormalizedOutput} from "./normalized-output.js"
16
22
 
17
23
  const HELP = `Usage: threadwire run --provider <codex|claude|opencode> --target telegram:<chat-id> | telegram:<chat-id>:<thread-id>
18
24
  [--process-number <positive-integer>] [--cwd <directory>]
19
25
  [--workspace-profile <name>]
26
+ [--relay-write]
20
27
  [--tool-messages] [--max-output-length <positive-integer>]
21
- [--resume-session <provider-session-id>] [--activity-log <local-jsonl-path>]
28
+ [--resume-session <provider-session-id>] [--transcript <normalized-jsonl-path>]
29
+ [--activity-log <local-jsonl-path>]
22
30
  [--prompt <text> | --prompt-file <path> | stdin]
23
- [-- <provider arguments...>]`
31
+ [-- <provider arguments...>]
32
+ threadwire evidence read --handle <opaque-handle>
33
+ (--bytes <offset>:<limit> | --lines <start>:<limit> | --query <literal> --context-bytes <limit>)`
24
34
 
25
- /** @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 */
35
+ /** @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 */
36
+ /** @typedef {{evidenceRead: true, request: unknown}} EvidenceParsedArguments */
26
37
  /**
27
38
  * @typedef {{
28
39
  * env?: NodeJS.ProcessEnv,
@@ -31,7 +42,11 @@ const HELP = `Usage: threadwire run --provider <codex|claude|opencode> --target
31
42
  * errorOutput?: NodeJS.WritableStream,
32
43
  * transportFactory?: typeof createFetchTransport,
33
44
  * workerRunner?: typeof runWorker,
45
+ * evidenceStore?: EvidenceStore,
46
+ * evidenceOwnerScope?: {destinationId: string, runId: string},
47
+ * workerControlOptions?: Pick<ConstructorParameters<typeof WorkerControl>[0], "queueOptions">,
34
48
  * workspaceProfileOperations?: import("./workspace-profile.js").WorkspaceProfileOperations
49
+ * isolatedRuntimeClient?: Pick<import("./isolated-runtime-client.js").IsolatedRuntimeClient, "preflight" | "run">
35
50
  * }} MainDependencies
36
51
  */
37
52
 
@@ -44,8 +59,9 @@ export function parseArguments(arguments_) {
44
59
  const ownArguments = separator < 0 ? arguments_.slice(1) : arguments_.slice(1, separator)
45
60
  const providerArguments = separator < 0 ? [] : arguments_.slice(separator + 1)
46
61
  /** @type {Partial<ParsedArguments>} */
47
- const parsed = {providerArguments, cwd: process.cwd(), toolMessages: false}
62
+ const parsed = {providerArguments, cwd: process.cwd(), toolMessages: false, relayWrite: false}
48
63
  let cwdProvided = false
64
+ let transcriptProvided = false
49
65
  for (let index = 0; index < ownArguments.length;) {
50
66
  const option = ownArguments[index]
51
67
  if (option === "--tool-messages") {
@@ -53,7 +69,15 @@ export function parseArguments(arguments_) {
53
69
  index += 1
54
70
  continue
55
71
  }
72
+ if (option === "--relay-write") {
73
+ parsed.relayWrite = true
74
+ index += 1
75
+ continue
76
+ }
56
77
  const value = ownArguments[index + 1]
78
+ if (option === "--transcript" && (value === undefined || value.startsWith("--"))) {
79
+ throw new Error("Missing value for --transcript")
80
+ }
57
81
  if (!option || !value) throw new Error(`Missing value for ${option ?? "option"}`)
58
82
  if (option === "--provider") parsed.provider = value
59
83
  else if (option === "--target") parsed.target = value
@@ -67,7 +91,12 @@ export function parseArguments(arguments_) {
67
91
  else if (option === "--max-output-length") parsed.maxOutputLength = positiveInteger(value, "--max-output-length")
68
92
  else if (option === "--resume-session") parsed.resumeSession = sessionId(value)
69
93
  else if (option === "--activity-log") parsed.activityLog = resolve(value)
70
- else throw new Error(`Unknown option: ${option}`)
94
+ else if (option === "--transcript") {
95
+ if (transcriptProvided) throw new Error("Duplicate option: --transcript")
96
+ if (value.includes("\0")) throw new Error("--transcript path is invalid")
97
+ parsed.transcript = resolve(value)
98
+ transcriptProvided = true
99
+ } else throw new Error(`Unknown option: ${option}`)
71
100
  index += 2
72
101
  }
73
102
  if (!parsed.provider) throw new Error("--provider is required")
@@ -79,9 +108,54 @@ export function parseArguments(arguments_) {
79
108
  if (parsed.workspaceProfile !== undefined && cwdProvided) {
80
109
  throw new Error("--cwd and --workspace-profile are mutually exclusive")
81
110
  }
111
+ if (parsed.transcript !== undefined && parsed.transcript === parsed.activityLog) {
112
+ throw new Error("--transcript and --activity-log must resolve to different paths")
113
+ }
114
+ if (parsed.relayWrite && parsed.workspaceProfile === undefined) throw new Error("--relay-write requires --workspace-profile")
115
+ if (parsed.relayWrite && parsed.provider !== "codex") throw new Error("--relay-write is only supported for codex")
82
116
  return /** @type {ParsedArguments} */ (parsed)
83
117
  }
84
118
 
119
+ /** @param {string[]} arguments_ @returns {EvidenceParsedArguments} */
120
+ function parseEvidenceArguments(arguments_) {
121
+ if (arguments_[1] !== "read") throw new Error(HELP)
122
+ /** @type {Record<string, string>} */
123
+ const options = {}
124
+ for (let index = 2; index < arguments_.length; index += 2) {
125
+ const name = arguments_[index]
126
+ const value = arguments_[index + 1]
127
+ if (!name || !value || !["--handle", "--bytes", "--lines", "--query", "--context-bytes"].includes(name)) throw new Error(HELP)
128
+ if (options[name] !== undefined) throw new Error(`Duplicate evidence option: ${name}`)
129
+ options[name] = value
130
+ }
131
+ const handle = options["--handle"]
132
+ if (!handle) throw new Error("--handle is required")
133
+ const selectors = ["--bytes", "--lines", "--query"].filter((name) => options[name] !== undefined)
134
+ if (selectors.length !== 1) throw new Error("Exactly one bounded evidence selector is required")
135
+ let request
136
+ if (options["--bytes"]) request = {handle, bytes: rangePair(options["--bytes"], true)}
137
+ else if (options["--lines"]) request = {handle, lines: rangePair(options["--lines"], false)}
138
+ else {
139
+ const context = options["--context-bytes"]
140
+ if (!context) throw new Error("--context-bytes is required with --query")
141
+ request = {handle, query: {literal: options["--query"], limit: 1, contextBytes: positiveInteger(context, "--context-bytes")}}
142
+ }
143
+ if (options["--query"] === undefined && options["--context-bytes"] !== undefined) throw new Error("--context-bytes requires --query")
144
+ return {evidenceRead: true, request}
145
+ }
146
+
147
+ /** @param {string} value @param {boolean} zeroStart */
148
+ function rangePair(value, zeroStart) {
149
+ const match = /^(\d+):(\d+)$/u.exec(value)
150
+ if (!match) throw new Error("Evidence range must be <start>:<limit>")
151
+ const start = Number(match[1])
152
+ const limit = Number(match[2])
153
+ if (!Number.isSafeInteger(start) || (!zeroStart && start < 1) || (zeroStart && start < 0) || !Number.isSafeInteger(limit) || limit < 1) {
154
+ throw new Error("Evidence range must contain bounded safe integers")
155
+ }
156
+ return zeroStart ? {offset: start, limit} : {start, limit}
157
+ }
158
+
85
159
  /**
86
160
  * @param {string[]} arguments_
87
161
  * @param {MainDependencies} [dependencies]
@@ -92,13 +166,36 @@ export async function main(arguments_, dependencies = {}) {
92
166
  const sourceEnvironment = dependencies.env ?? process.env
93
167
  let environment = sourceEnvironment
94
168
  const validateOnly = environment.THREADWIRE_VALIDATE_ONLY === "1"
169
+ /** @type {NormalizedOutput | undefined} */
170
+ let normalizedOutput
171
+ /** @type {DelegatedResultAdmission | undefined} */
172
+ let runAdmission
95
173
  try {
96
- const parsed = parseArguments(arguments_)
174
+ const parsed = arguments_[0] === "evidence" ? parseEvidenceArguments(arguments_) : parseArguments(arguments_)
97
175
  if ("help" in parsed) {
98
176
  if (!validateOnly) output.write(`${HELP}\n`)
99
177
  return 0
100
178
  }
179
+ if ("evidenceRead" in parsed) {
180
+ const root = evidenceRoot(sourceEnvironment.THREADWIRE_EVIDENCE_ROOT)
181
+ if (!root) throw new Error("THREADWIRE_EVIDENCE_ROOT is required")
182
+ if (validateOnly) return 0
183
+ const store = await EvidenceStore.open({root})
184
+ try {
185
+ const result = await store.readBearer(parsed.request)
186
+ output.write(`${JSON.stringify(result)}\n`)
187
+ return 0
188
+ } finally {
189
+ await store.close()
190
+ }
191
+ }
192
+ if (!validateOnly) {
193
+ normalizedOutput = new NormalizedOutput(output, parsed.transcript)
194
+ }
195
+ const metrics = new ContextBudgetMetrics()
196
+ runAdmission = normalizedOutput === undefined ? undefined : new DelegatedResultAdmission({output: normalizedOutput, metrics})
101
197
  const target = parseTelegramTarget(parsed.target)
198
+ if (parsed.relayWrite) validateRelayWriteProviderArguments(parsed.providerArguments)
102
199
  const resolvedWorkspace = parsed.workspaceProfile === undefined
103
200
  ? undefined
104
201
  : await resolveWorkspaceProfile(
@@ -106,35 +203,76 @@ export async function main(arguments_, dependencies = {}) {
106
203
  dependencies.workspaceProfileOperations
107
204
  )
108
205
  if (validateOnly) return 0
109
- environment = await resolveFileBackedSettings(sourceEnvironment, ["THREADWIRE_TELEGRAM_BOT_TOKEN"])
110
- const hermes = new HermesProtocol({output})
206
+ const isolatedRuntimeClient = parsed.relayWrite
207
+ ? dependencies.isolatedRuntimeClient ?? isolatedRuntimeClientFromEnvironment(sourceEnvironment)
208
+ : undefined
209
+ const isolatedPreflight = parsed.relayWrite
210
+ ? await isolatedRuntimeClient?.preflight({
211
+ provider: parsed.provider,
212
+ profile: /** @type {string} */ (parsed.workspaceProfile),
213
+ repositoryRoot: /** @type {import("./workspace-profile.js").ResolvedWorkspaceProfile} */ (resolvedWorkspace).repositoryRoot,
214
+ cwd: /** @type {import("./workspace-profile.js").ResolvedWorkspaceProfile} */ (resolvedWorkspace).cwd,
215
+ providerArguments: parsed.providerArguments,
216
+ ...(parsed.resumeSession === undefined ? {} : {resumeSession: parsed.resumeSession})
217
+ })
218
+ : undefined
219
+ const admission = /** @type {DelegatedResultAdmission} */ (runAdmission)
111
220
  let terminalExitCode = 2
221
+ /** @type {unknown} */
222
+ let evidenceError
112
223
  /** @type {ActivityLog | undefined} */
113
224
  let activity
225
+ /** @type {Awaited<ReturnType<EvidenceStore["createArtifact"]>> | undefined} */
226
+ let evidence
227
+ /** @type {EvidenceStore | undefined} */
228
+ let ownedEvidenceStore
229
+ let evidencePayloadBytes = 0
230
+ const launchDeadline = isolatedPreflight?.deadline
114
231
  try {
115
- const prompt = await readPrompt(parsed, dependencies.input ?? stdin)
232
+ environment = await boundedLaunch(resolveFileBackedSettings(sourceEnvironment, ["THREADWIRE_TELEGRAM_BOT_TOKEN"]), launchDeadline)
233
+ const prompt = await readPrompt(parsed, dependencies.input ?? stdin, launchDeadline?.signal)
234
+ const configuredEvidenceRoot = evidenceRoot(sourceEnvironment.THREADWIRE_EVIDENCE_ROOT)
235
+ if (dependencies.evidenceStore === undefined && configuredEvidenceRoot !== undefined) {
236
+ ownedEvidenceStore = await boundedLaunch(EvidenceStore.open({root: configuredEvidenceRoot}), launchDeadline)
237
+ }
238
+ const evidenceStore = dependencies.evidenceStore ?? ownedEvidenceStore
239
+ if (evidenceStore !== undefined) {
240
+ const evidenceOwner = dependencies.evidenceOwnerScope ?? evidenceStore.createOwnerScope({
241
+ destinationId: `${target.chatId}:${target.threadId ?? "dm"}`,
242
+ runId: randomUUID()
243
+ })
244
+ evidence = await evidenceStore.createArtifact(evidenceOwner, {
245
+ contentType: "text/plain; charset=utf-8",
246
+ redactions: await boundedLaunch(collectEvidenceRedactions(environment), launchDeadline)
247
+ })
248
+ }
249
+ if (evidence !== undefined) {
250
+ const promptEvidence = `prompt\n${prompt}\nprovider-stream\n`
251
+ await boundedLaunch(evidence.append("prompt", promptEvidence), launchDeadline)
252
+ evidencePayloadBytes += Buffer.byteLength(promptEvidence, "utf8")
253
+ }
116
254
  const providerEnvironment = buildProviderEnvironment(environment)
117
- const provider = createProvider(parsed.provider, parsed.providerArguments, prompt, parsed.resumeSession, providerEnvironment)
255
+ const provider = createProvider(
256
+ parsed.provider,
257
+ parsed.providerArguments,
258
+ prompt,
259
+ parsed.resumeSession,
260
+ parsed.relayWrite ? {} : providerEnvironment
261
+ )
262
+ if (parsed.resumeSession !== undefined) admission.setContinuationHandle(parsed.resumeSession)
118
263
  if (parsed.workspaceProfile !== undefined) {
119
264
  if (resolvedWorkspace === undefined) throw new Error("Workspace profile resolution failed")
120
- hermes.write({
121
- type: "execution_environment",
122
- profile: resolvedWorkspace.profile,
123
- environment: "container",
124
- repositoryPath: resolvedWorkspace.cwd,
125
- revision: resolvedWorkspace.revision,
126
- sourceIdentity: resolvedWorkspace.sourceIdentity
127
- })
128
265
  }
129
266
  const token = environment.THREADWIRE_TELEGRAM_BOT_TOKEN
130
267
  if (!token) throw new Error("THREADWIRE_TELEGRAM_BOT_TOKEN is required")
131
268
  const transport = (dependencies.transportFactory ?? createFetchTransport)(token, undefined, parseTelegramRequestTimeoutMs(environment))
132
- const queue = new NoticeQueue({sender: createTelegramSender(target, transport)})
133
- const relay = new Relay({
134
- queue,
269
+ const control = new WorkerControl({
270
+ sender: createTelegramSender(target, transport),
135
271
  processNumber: parsed.processNumber ?? process.pid,
136
272
  toolMessages: parsed.toolMessages,
137
- ...(parsed.maxOutputLength === undefined ? {} : {maxOutputLength: parsed.maxOutputLength})
273
+ ...(dependencies.workerControlOptions ?? {}),
274
+ ...(parsed.maxOutputLength === undefined ? {} : {maxOutputLength: parsed.maxOutputLength}),
275
+ metrics
138
276
  })
139
277
  activity = parsed.activityLog === undefined ? undefined : new ActivityLog(parsed.activityLog)
140
278
  if (activity && resolvedWorkspace) {
@@ -145,52 +283,187 @@ export async function main(arguments_, dependencies = {}) {
145
283
  resolvedWorkspace.sourceIdentity
146
284
  )
147
285
  }
148
- const exitCode = await (dependencies.workerRunner ?? runWorker)({
286
+ /** @type {import("./run-worker.js").RunWorkerOptions} */
287
+ const workerOptions = {
149
288
  executable: provider.executable,
150
289
  arguments: provider.arguments,
151
290
  cwd: resolvedWorkspace?.cwd ?? parsed.cwd,
152
291
  environment: providerEnvironment,
153
292
  parse: provider.parse,
154
293
  onEvent: (event) => {
155
- relay.accept(event)
156
- hermes.accept(event)
294
+ validateNormalizedWorkerEvent(event)
295
+ if (event.type !== "text-delta") {
296
+ metrics.recordRejected(`${event.type}_progress`, Buffer.byteLength(JSON.stringify(event), "utf8"))
297
+ }
298
+ acceptAdmissionEvent(admission, event)
299
+ return control.accept(event)
157
300
  },
158
301
  onSpawn: (pid) => {
159
302
  if (activity && pid !== undefined) activity.recordStarted(provider.name, pid)
160
303
  },
161
304
  onRecord: (record) => {
305
+ metrics.recordParsedProviderRecord("provider_stdout")
162
306
  const id = provider.sessionId(record)
163
- if (id !== undefined) activity?.recordSession(provider.name, id)
307
+ if (id !== undefined) {
308
+ admission.setContinuationHandle(id)
309
+ activity?.recordSession(provider.name, id)
310
+ }
311
+ },
312
+ onStdoutChunk: (chunk) => {
313
+ metrics.recordRawChildChunk("provider_stdout", chunk.length)
314
+ metrics.recordRejected("provider_stream", chunk.length)
315
+ if (evidence === undefined) return undefined
316
+ return evidence.append("provider-stdout", chunk).then(() => { evidencePayloadBytes += chunk.length })
317
+ },
318
+ onStderrChunk: (chunk) => {
319
+ metrics.recordRawChildChunk("provider_stderr", chunk.length)
320
+ metrics.recordRejected("stderr", chunk.length)
321
+ if (evidence === undefined) return undefined
322
+ return evidence.append("provider-stderr", chunk).then(() => { evidencePayloadBytes += chunk.length })
164
323
  }
165
- })
166
- await relay.close()
324
+ }
325
+ const exitCode = parsed.relayWrite
326
+ ? await /** @type {NonNullable<typeof isolatedRuntimeClient>} */ (isolatedRuntimeClient).run({
327
+ preflightId: /** @type {{preflightId: string}} */ (isolatedPreflight).preflightId,
328
+ prompt,
329
+ providerArguments: parsed.providerArguments,
330
+ ...(parsed.resumeSession === undefined ? {} : {resumeSession: parsed.resumeSession}),
331
+ onEvent: workerOptions.onEvent,
332
+ onRecord: /** @type {NonNullable<typeof workerOptions.onRecord>} */ (workerOptions.onRecord),
333
+ ...(workerOptions.onStdoutChunk === undefined ? {} : {onStdoutChunk: workerOptions.onStdoutChunk}),
334
+ ...(workerOptions.onStderrChunk === undefined ? {} : {onStderrChunk: workerOptions.onStderrChunk}),
335
+ ...(launchDeadline === undefined ? {} : {deadline: launchDeadline})
336
+ })
337
+ : await (dependencies.workerRunner ?? runWorker)(workerOptions)
338
+ await boundedLaunch(control.close(), launchDeadline)
167
339
  terminalExitCode = exitCode
168
- return exitCode
169
340
  } finally {
170
- hermes.complete(terminalExitCode)
341
+ if (evidence !== undefined) {
342
+ try {
343
+ admission.addArtifactHandle(evidence.handle)
344
+ const terminal = {state: /** @type {"completed" | "failed"} */ (terminalExitCode === 0 ? "completed" : "failed"), exitCode: terminalExitCode}
345
+ metrics.recordAdmission(admission.preview(terminal))
346
+ const projectedArtifactBytes = metrics.projectSelfInclusiveArtifact(evidencePayloadBytes, 1)
347
+ await evidence.append("context-metrics", `${JSON.stringify(metrics.snapshot())}\n`)
348
+ const artifact = await evidence.finalize()
349
+ assertArtifactProjection(artifact.bytes, projectedArtifactBytes)
350
+ } catch (error) {
351
+ await evidence.abort()
352
+ admission.removeArtifactHandle(evidence.handle)
353
+ metrics.clearArtifact()
354
+ terminalExitCode = 2
355
+ evidenceError = error
356
+ }
357
+ }
358
+ admission.complete({state: terminalExitCode === 0 ? "completed" : "failed", exitCode: terminalExitCode})
359
+ errorOutput.write(`threadwire-context-metrics ${JSON.stringify(metrics.conciseDiagnostic())}\n`)
171
360
  activity?.close()
361
+ await boundedLaunch(ownedEvidenceStore?.close(), launchDeadline).catch(() => {})
362
+ launchDeadline?.close()
172
363
  }
364
+ if (evidenceError !== undefined) throw evidenceError
365
+ return terminalExitCode
173
366
  } catch (error) {
174
- errorOutput.write(`threadwire: ${safeMessage(error)}\n`)
367
+ let reportedError = error
368
+ if (runAdmission !== undefined && !runAdmission.completed) {
369
+ try {
370
+ runAdmission.complete({state: "failed", exitCode: 2})
371
+ } catch (transcriptError) {
372
+ reportedError = transcriptError
373
+ }
374
+ }
375
+ errorOutput.write(`threadwire: ${safeMessage(reportedError)}\n`)
175
376
  return 2
377
+ } finally {
378
+ try {
379
+ normalizedOutput?.close()
380
+ } catch (error) {
381
+ errorOutput.write(`threadwire: ${safeMessage(error)}\n`)
382
+ }
176
383
  }
177
384
  }
178
385
 
179
- /** @param {ParsedArguments} parsed @param {NodeJS.ReadableStream & {isTTY?: boolean}} input */
180
- async function readPrompt(parsed, input) {
386
+ /** @param {string | undefined} value */
387
+ function evidenceRoot(value) {
388
+ if (value === undefined) return undefined
389
+ if (!isAbsolute(value) || normalize(value) !== value) throw new Error("THREADWIRE_EVIDENCE_ROOT must be a normalized absolute path")
390
+ return value
391
+ }
392
+
393
+ /** @param {number} actual @param {number} projected */
394
+ function assertArtifactProjection(actual, projected) {
395
+ if (actual !== projected) throw new Error("Context metrics artifact projection mismatch")
396
+ }
397
+
398
+ /** @param {DelegatedResultAdmission} admission @param {import("./types.js").WorkerEvent} event */
399
+ function acceptAdmissionEvent(admission, event) {
400
+ if (event.type === "text-delta") admission.acceptConclusionEvent(event)
401
+ }
402
+
403
+ /** @param {unknown} event */
404
+ function validateNormalizedWorkerEvent(event) {
405
+ if (!isPlainObject(event) || typeof event.type !== "string") {
406
+ throw new Error("invalid normalized worker event")
407
+ }
408
+ if (event.type === "text-delta") {
409
+ if (!hasExactEnumerableFields(event, ["type", "text", "streamId"]) || typeof event.text !== "string" || typeof event.streamId !== "string") {
410
+ throw new Error("invalid normalized worker event")
411
+ }
412
+ return
413
+ }
414
+ if (event.type === "lifecycle") {
415
+ if (
416
+ !hasExactEnumerableFields(event, ["type", "phase", "summary"])
417
+ || (event.phase !== "started" && event.phase !== "completed" && event.phase !== "failed")
418
+ || typeof event.summary !== "string"
419
+ ) throw new Error("invalid normalized worker event")
420
+ return
421
+ }
422
+ if (event.type === "diagnostic") {
423
+ if (
424
+ !hasExactEnumerableFields(event, ["type", "level", "summary"])
425
+ || (event.level !== "warning" && event.level !== "error")
426
+ || typeof event.summary !== "string"
427
+ ) throw new Error("invalid normalized worker event")
428
+ return
429
+ }
430
+ if (event.type === "tool") {
431
+ if (!isToolEventShape(event)) throw new Error("invalid normalized worker event")
432
+ return
433
+ }
434
+ throw new Error("Worker emitted an unknown normalized event kind")
435
+ }
436
+
437
+ /** @param {ParsedArguments} parsed @param {NodeJS.ReadableStream & {isTTY?: boolean}} input @param {AbortSignal | undefined} signal */
438
+ async function readPrompt(parsed, input, signal) {
181
439
  let prompt
182
440
  if (parsed.prompt !== undefined) prompt = parsed.prompt
183
- else if (parsed.promptFile !== undefined) prompt = await readFile(parsed.promptFile, "utf8")
441
+ else if (parsed.promptFile !== undefined) prompt = await readFile(parsed.promptFile, {encoding: "utf8", signal})
184
442
  else {
185
443
  if (input.isTTY) throw new Error("A prompt is required via --prompt, --prompt-file, or piped stdin")
186
444
  prompt = ""
187
445
  input.setEncoding("utf8")
188
- for await (const chunk of input) prompt += chunk
446
+ const abort = () => /** @type {NodeJS.ReadableStream & {destroy?: (error?: unknown) => void}} */ (input).destroy?.(signal?.reason)
447
+ signal?.addEventListener("abort", abort, {once: true})
448
+ try {
449
+ for await (const chunk of input) {
450
+ if (signal?.aborted) throw signal.reason
451
+ prompt += chunk
452
+ }
453
+ } finally {
454
+ signal?.removeEventListener("abort", abort)
455
+ }
189
456
  }
190
457
  if (prompt.trim().length === 0) throw new Error("Prompt cannot be empty")
191
458
  return prompt
192
459
  }
193
460
 
461
+ /** @param {Promise<unknown> | undefined} promise @param {import("./absolute-deadline.js").AbsoluteDeadline | undefined} deadline */
462
+ function boundedLaunch(promise, deadline) {
463
+ deadline?.throwIfAborted()
464
+ return abortable(Promise.resolve(promise), deadline?.signal)
465
+ }
466
+
194
467
  /** @param {unknown} error */
195
468
  function safeMessage(error) {
196
469
  return error instanceof Error ? error.message : "Unknown error"
@@ -207,6 +480,53 @@ function positiveInteger(value, option) {
207
480
 
208
481
  /** @param {string} value */
209
482
  function sessionId(value) {
210
- if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,511}$/u.test(value)) throw new Error("--resume-session must be a valid provider session ID")
211
- return value
483
+ try {
484
+ return validateContinuationHandle(value)
485
+ } catch {
486
+ throw new Error("--resume-session must be a valid provider session ID")
487
+ }
488
+ }
489
+
490
+ /** @param {Record<string, unknown>} event */
491
+ function isToolEventShape(event) {
492
+ const allowed = event.phase === "finished"
493
+ ? ["type", "phase", "name", "key", "detail", "output"]
494
+ : ["type", "phase", "name", "key", "detail"]
495
+ if (
496
+ !hasOnlyAllowedEnumerableFields(event, allowed, ["type", "phase", "name", "key"])
497
+ || (event.phase !== "started" && event.phase !== "finished")
498
+ || typeof event.name !== "string"
499
+ || typeof event.key !== "string"
500
+ || (event.detail !== undefined && typeof event.detail !== "string")
501
+ || (event.output !== undefined && typeof event.output !== "string")
502
+ ) return false
503
+ if (event.phase === "started" && event.output !== undefined) return false
504
+ return true
505
+ }
506
+
507
+ /** @param {unknown} value @returns {value is Record<string, unknown>} */
508
+ function isPlainObject(value) {
509
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false
510
+ const prototype = Object.getPrototypeOf(value)
511
+ return prototype === Object.prototype || prototype === null
512
+ }
513
+
514
+ /** @param {Record<string, unknown>} value @param {string[]} expected */
515
+ function hasExactEnumerableFields(value, expected) {
516
+ const fields = Reflect.ownKeys(value)
517
+ return fields.length === expected.length && fields.every((field) => (
518
+ typeof field === "string"
519
+ && expected.includes(field)
520
+ && Object.prototype.propertyIsEnumerable.call(value, field)
521
+ ))
522
+ }
523
+
524
+ /** @param {Record<string, unknown>} value @param {string[]} allowed @param {string[]} required */
525
+ function hasOnlyAllowedEnumerableFields(value, allowed, required) {
526
+ const fields = Reflect.ownKeys(value)
527
+ return required.every((field) => Object.prototype.propertyIsEnumerable.call(value, field)) && fields.every((field) => (
528
+ typeof field === "string"
529
+ && allowed.includes(field)
530
+ && Object.prototype.propertyIsEnumerable.call(value, field)
531
+ ))
212
532
  }