threadwire 0.1.14 → 0.1.17

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.
@@ -4,11 +4,38 @@ import {closeSync, openSync, writeSync} from "node:fs"
4
4
 
5
5
  const SESSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,511}$/u
6
6
 
7
+ const ACTIVITY_KINDS = new Set(["delta", "tool", "lifecycle"])
8
+ const HEALTH_DISPOSITIONS = new Set(["retrying", "blocked"])
9
+ const HEALTH_CATEGORIES = new Set([
10
+ "authentication", "permission", "rate-limit", "quota", "billing",
11
+ "model", "network", "protocol", "unknown"
12
+ ])
13
+ const TERMINAL_STATES = new Set(["completed", "failed", "cancelled"])
14
+
15
+ const SIGNAL_CANCELLED_EXIT_CODES = new Set([130, 143])
16
+
17
+ const ACTIVITY_THROTTLE_MS = 5000
18
+
7
19
  export class ActivityLog {
8
- /** @param {string} path */
9
- constructor(path) {
20
+ /**
21
+ * @param {string} path
22
+ * @param {{now?: () => number}} [options]
23
+ */
24
+ constructor(path, options = {}) {
10
25
  this.fileDescriptor = openSync(path, "a", 0o600)
11
26
  this.closed = false
27
+ this.now = options.now ?? (() => Date.now())
28
+ this.terminalWritten = false
29
+ /** @type {string | null} */
30
+ this.lastHealthFingerprint = null
31
+ this.lastActivityAt = -ACTIVITY_THROTTLE_MS
32
+ this.activityCount = 0
33
+ }
34
+
35
+ /** @param {number} pid */
36
+ recordController(pid) {
37
+ if (!Number.isSafeInteger(pid) || pid <= 0) throw new Error("Controller PID is unavailable")
38
+ this.write({type: "controller-started", pid})
12
39
  }
13
40
 
14
41
  /** @param {"codex" | "claude" | "kimi" | "opencode"} provider @param {number} pid */
@@ -23,15 +50,76 @@ export class ActivityLog {
23
50
  this.write({type: "session-available", provider, sessionId})
24
51
  }
25
52
 
53
+ /**
54
+ * @param {"codex" | "claude" | "kimi" | "opencode"} provider
55
+ * @param {"delta" | "tool" | "lifecycle"} kind
56
+ */
57
+ recordActivity(provider, kind) {
58
+ if (!ACTIVITY_KINDS.has(kind)) throw new Error(`Unknown activity kind: ${kind}`)
59
+ const now = this.now()
60
+ if (now - this.lastActivityAt < ACTIVITY_THROTTLE_MS) return
61
+ this.lastActivityAt = now
62
+ this.activityCount += 1
63
+ this.write({type: "activity", provider, kind})
64
+ }
65
+
66
+ /**
67
+ * @param {"codex" | "claude" | "kimi" | "opencode"} provider
68
+ * @param {{disposition: "retrying" | "blocked", category: "authentication" | "permission" | "rate-limit" | "quota" | "billing" | "model" | "network" | "protocol" | "unknown", retryAfterMs?: number}} health
69
+ */
70
+ recordHealth(provider, health) {
71
+ if (!HEALTH_DISPOSITIONS.has(health.disposition)) throw new Error("Unknown health disposition")
72
+ if (!HEALTH_CATEGORIES.has(health.category)) throw new Error("Unknown health category")
73
+ if (health.retryAfterMs !== undefined) {
74
+ if (!Number.isSafeInteger(health.retryAfterMs) || health.retryAfterMs <= 0) throw new Error("retryAfterMs must be a positive safe integer")
75
+ if (health.disposition !== "retrying") throw new Error("retryAfterMs is only valid for retrying disposition")
76
+ }
77
+ const fingerprint = `${health.disposition}\0${health.category}\0${health.retryAfterMs ?? ""}`
78
+ if (fingerprint === this.lastHealthFingerprint) return
79
+ this.lastHealthFingerprint = fingerprint
80
+ /** @type {{type: string, provider: string, disposition: string, category: string, retryAfterMs?: number}} */
81
+ const fact = {type: "health", provider, disposition: health.disposition, category: health.category}
82
+ if (health.retryAfterMs !== undefined) fact.retryAfterMs = health.retryAfterMs
83
+ this.write(fact)
84
+ }
85
+
86
+ /**
87
+ * @param {"codex" | "claude" | "kimi" | "opencode"} provider
88
+ * @param {"completed" | "failed" | "cancelled"} state
89
+ * @param {number} exitCode
90
+ */
91
+ recordTerminal(provider, state, exitCode) {
92
+ if (this.terminalWritten) return
93
+ if (!TERMINAL_STATES.has(state)) throw new Error("Unknown terminal state")
94
+ if (!Number.isSafeInteger(exitCode) || exitCode < 0 || exitCode > 255) throw new Error("exitCode must be an integer between 0 and 255")
95
+ this.terminalWritten = true
96
+ this.write({type: "terminal", provider, state, exitCode})
97
+ }
98
+
26
99
  close() {
27
100
  if (this.closed) return
28
101
  this.closed = true
29
102
  closeSync(this.fileDescriptor)
30
103
  }
31
104
 
32
- /** @param {{type: "provider-started", provider: "codex" | "claude" | "kimi" | "opencode", pid: number} | {type: "session-available", provider: "codex" | "claude" | "kimi" | "opencode", sessionId: string}} fact */
105
+ /**
106
+ * @param {Record<string, unknown>} fact
107
+ */
33
108
  write(fact) {
34
109
  if (this.closed) throw new Error("Activity log is closed")
110
+ fact.at = this.now()
35
111
  writeSync(this.fileDescriptor, `${JSON.stringify(fact)}\n`)
36
112
  }
37
113
  }
114
+
115
+ /**
116
+ * Classify a terminal exit code. Known signal-derived codes (130 = SIGINT,
117
+ * 143 = SIGTERM) map to cancelled; all others map to completed (0) or failed
118
+ * (nonzero). This is the only place that picks the terminal state constant.
119
+ * @param {number} exitCode
120
+ * @returns {"completed" | "failed" | "cancelled"}
121
+ */
122
+ export function terminalState(exitCode) {
123
+ if (SIGNAL_CANCELLED_EXIT_CODES.has(exitCode)) return "cancelled"
124
+ return exitCode === 0 ? "completed" : "failed"
125
+ }
package/src/cli.js CHANGED
@@ -1,14 +1,15 @@
1
1
  // @ts-check
2
2
 
3
- import {readFile} from "node:fs/promises"
3
+ import {lstat, readFile, readlink, realpath} from "node:fs/promises"
4
4
  import {randomUUID} from "node:crypto"
5
- import {isAbsolute, normalize, resolve} from "node:path"
5
+ import {basename, dirname, isAbsolute, join, normalize, resolve} from "node:path"
6
6
  import {stdin, stderr, stdout} from "node:process"
7
7
  import {createFetchTransport} from "./notifiers/fetch-transport.js"
8
8
  import {createTelegramSender, parseTelegramTarget} from "./notifiers/telegram.js"
9
9
  import {createProvider, PROVIDERS} from "./providers/index.js"
10
10
  import {runWorker} from "./run-worker.js"
11
- import {ActivityLog} from "./activity-log.js"
11
+ import {ActivityLog, terminalState} from "./activity-log.js"
12
+ import {computeStatus, parseStatusArguments, readActivityLog} from "./activity-log-status.js"
12
13
  import {DelegatedResultAdmission, validateContinuationHandle} from "./delegated-result-admission.js"
13
14
  import {buildProviderEnvironment, collectEvidenceRedactions, parseTelegramRequestTimeoutMs, resolveFileBackedSettings} from "./telegram-ingress/config.js"
14
15
  import {WorkerControl} from "./worker-control.js"
@@ -29,7 +30,7 @@ import {
29
30
  import {probeCodexCapacity} from "./provider-capacity-codex.js"
30
31
  import {probeKimiCapacity} from "./provider-capacity-kimi.js"
31
32
 
32
- const HELP = `Usage: threadwire run --provider <codex|claude|kimi|opencode> --target telegram:<chat-id> | telegram:<chat-id>:<thread-id>
33
+ const HELP = `Usage: threadwire run --provider <codex|claude|kimi|opencode> --target telegram:<chat-id> | telegram:<chat-id>:<thread-id> | file:<absolute-path>
33
34
  [--process-number <positive-integer>] [--cwd <directory>]
34
35
  [--relay-write]
35
36
  [--tool-messages] [--max-output-length <positive-integer>]
@@ -41,7 +42,9 @@ const HELP = `Usage: threadwire run --provider <codex|claude|kimi|opencode> --ta
41
42
  (--bytes <offset>:<limit> | --lines <start>:<limit> | --query <literal> --context-bytes <limit>)
42
43
  threadwire capacity [--provider <codex|kimi>]...
43
44
  [--short-reserve-percent <0-100>] [--long-reserve-percent <0-100>]
44
- [--timeout-ms <positive-integer>]`
45
+ [--timeout-ms <positive-integer>]
46
+ threadwire status --activity-log <absolute-path>
47
+ (Emits one closed versioned JSON status document from the activity log.)`
45
48
 
46
49
  /** @typedef {{provider: string, target: string, cwd: string, toolMessages: boolean, relayWrite: boolean, prompt?: string, promptFile?: string, processNumber?: number, maxOutputLength?: number, resumeSession?: string, transcript?: string, activityLog?: string, providerArguments: string[]}} ParsedArguments */
47
50
  /** @typedef {{evidenceRead: true, request: unknown}} EvidenceParsedArguments */
@@ -244,6 +247,16 @@ export async function main(arguments_, dependencies = {}) {
244
247
  /** @type {DelegatedResultAdmission | undefined} */
245
248
  let runAdmission
246
249
  try {
250
+ if (arguments_[0] === "status") {
251
+ const statusParsed = parseStatusArguments(arguments_)
252
+ if (validateOnly) return 0
253
+ const records = await readActivityLog(statusParsed.activityLog)
254
+ const status = computeStatus(records, {
255
+ ...(dependencies.now === undefined ? {} : {now: dependencies.now})
256
+ })
257
+ output.write(`${JSON.stringify(status)}\n`)
258
+ return 0
259
+ }
247
260
  const parsed = arguments_[0] === "evidence"
248
261
  ? parseEvidenceArguments(arguments_)
249
262
  : arguments_[0] === "capacity" ? parseCapacityArguments(arguments_) : parseArguments(arguments_)
@@ -292,12 +305,20 @@ export async function main(arguments_, dependencies = {}) {
292
305
  await store.close()
293
306
  }
294
307
  }
295
- if (!validateOnly) {
296
- normalizedOutput = new NormalizedOutput(output, parsed.transcript)
308
+ const destination = parseDeliveryTarget(parsed.target)
309
+ if (destination.type === "file" && parsed.transcript !== undefined) {
310
+ throw new Error("--transcript cannot be combined with a file target")
297
311
  }
312
+ const transcriptPath = destination.type === "file" ? destination.path : parsed.transcript
313
+ if (
314
+ transcriptPath !== undefined && parsed.activityLog !== undefined
315
+ && await canonicalFilesystemPath(transcriptPath) === await canonicalFilesystemPath(parsed.activityLog)
316
+ ) {
317
+ throw new Error("Result and activity log paths must resolve to different filesystem paths")
318
+ }
319
+ if (!validateOnly) normalizedOutput = new NormalizedOutput(output, transcriptPath)
298
320
  const metrics = new ContextBudgetMetrics()
299
321
  runAdmission = normalizedOutput === undefined ? undefined : new DelegatedResultAdmission({output: normalizedOutput, metrics})
300
- const target = parseTelegramTarget(parsed.target)
301
322
  if (parsed.relayWrite) validateRelayWriteProviderArguments(parsed.providerArguments)
302
323
  if (validateOnly) return 0
303
324
  const usesIsolatedRuntime = parsed.relayWrite
@@ -322,27 +343,63 @@ export async function main(arguments_, dependencies = {}) {
322
343
  let activity
323
344
  /** @type {Awaited<ReturnType<EvidenceStore["createArtifact"]>> | undefined} */
324
345
  let evidence
346
+ /** @type {Awaited<ReturnType<EvidenceStore["createArtifact"]>> | undefined} */
347
+ let resultEvidence
325
348
  /** @type {EvidenceStore | undefined} */
326
349
  let ownedEvidenceStore
350
+ /** @type {EvidenceStore | undefined} */
351
+ let evidenceStore
352
+ /** @type {{destinationId: string, runId: string} | undefined} */
353
+ let evidenceOwner
354
+ /** @type {WorkerControl | undefined} */
355
+ let control
356
+ /** @type {unknown} */
357
+ let deliveryError
358
+ let resultEvidenceFinalized = false
327
359
  let evidencePayloadBytes = 0
360
+ let resultEvidenceBytes = 0
361
+ const deliveryIdentity = randomUUID()
328
362
  const launchDeadline = isolatedPreflight?.deadline
329
363
  try {
330
- environment = await boundedLaunch(resolveFileBackedSettings(sourceEnvironment, ["THREADWIRE_TELEGRAM_BOT_TOKEN"]), launchDeadline)
364
+ environment = await boundedLaunch(resolveFileBackedSettings(
365
+ sourceEnvironment,
366
+ destination.type === "telegram" ? ["THREADWIRE_TELEGRAM_BOT_TOKEN"] : []
367
+ ), launchDeadline)
368
+ if (destination.type === "telegram" && !environment.THREADWIRE_TELEGRAM_BOT_TOKEN) {
369
+ throw new Error("THREADWIRE_TELEGRAM_BOT_TOKEN is required")
370
+ }
331
371
  const prompt = await readPrompt(parsed, dependencies.input ?? stdin, launchDeadline?.signal)
332
372
  const configuredEvidenceRoot = evidenceRoot(sourceEnvironment.THREADWIRE_EVIDENCE_ROOT)
333
373
  if (dependencies.evidenceStore === undefined && configuredEvidenceRoot !== undefined) {
334
374
  ownedEvidenceStore = await boundedLaunch(EvidenceStore.open({root: configuredEvidenceRoot}), launchDeadline)
335
375
  }
336
- const evidenceStore = dependencies.evidenceStore ?? ownedEvidenceStore
376
+ evidenceStore = dependencies.evidenceStore ?? ownedEvidenceStore
337
377
  if (evidenceStore !== undefined) {
338
- const evidenceOwner = dependencies.evidenceOwnerScope ?? evidenceStore.createOwnerScope({
339
- destinationId: `${target.chatId}:${target.threadId ?? "dm"}`,
340
- runId: randomUUID()
378
+ evidenceOwner = dependencies.evidenceOwnerScope ?? evidenceStore.createOwnerScope({
379
+ destinationId: destination.type === "telegram"
380
+ ? `${destination.target.chatId}:${destination.target.threadId ?? "dm"}`
381
+ : "file",
382
+ runId: deliveryIdentity
341
383
  })
384
+ const redactions = await boundedLaunch(collectEvidenceRedactions(environment), launchDeadline)
342
385
  evidence = await evidenceStore.createArtifact(evidenceOwner, {
343
386
  contentType: "text/plain; charset=utf-8",
344
- redactions: await boundedLaunch(collectEvidenceRedactions(environment), launchDeadline)
387
+ redactions
345
388
  })
389
+ try {
390
+ resultEvidence = await evidenceStore.createArtifact(evidenceOwner, {
391
+ contentType: "application/json",
392
+ redactions,
393
+ delivery: {
394
+ identity: deliveryIdentity,
395
+ state: destination.type === "telegram" ? "delivery_pending" : "delivery_not_requested"
396
+ }
397
+ })
398
+ } catch (error) {
399
+ await evidence.abort()
400
+ evidence = undefined
401
+ throw error
402
+ }
346
403
  }
347
404
  if (evidence !== undefined) {
348
405
  const promptEvidence = `prompt\n${prompt}\nprovider-stream\n`
@@ -361,18 +418,23 @@ export async function main(arguments_, dependencies = {}) {
361
418
  providerEnvironment
362
419
  )
363
420
  if (parsed.resumeSession !== undefined) admission.setContinuationHandle(parsed.resumeSession)
364
- const token = environment.THREADWIRE_TELEGRAM_BOT_TOKEN
365
- if (!token) throw new Error("THREADWIRE_TELEGRAM_BOT_TOKEN is required")
366
- const transport = (dependencies.transportFactory ?? createFetchTransport)(token, undefined, parseTelegramRequestTimeoutMs(environment))
367
- const control = new WorkerControl({
368
- sender: createTelegramSender(target, transport),
369
- processNumber: parsed.processNumber ?? process.pid,
370
- toolMessages: parsed.toolMessages,
371
- ...(dependencies.workerControlOptions ?? {}),
372
- ...(parsed.maxOutputLength === undefined ? {} : {maxOutputLength: parsed.maxOutputLength}),
373
- metrics
374
- })
421
+ if (destination.type === "telegram") {
422
+ const transport = (dependencies.transportFactory ?? createFetchTransport)(
423
+ /** @type {string} */ (environment.THREADWIRE_TELEGRAM_BOT_TOKEN),
424
+ undefined,
425
+ parseTelegramRequestTimeoutMs(environment)
426
+ )
427
+ control = new WorkerControl({
428
+ sender: createTelegramSender(destination.target, transport),
429
+ processNumber: parsed.processNumber ?? process.pid,
430
+ toolMessages: parsed.toolMessages,
431
+ ...(dependencies.workerControlOptions ?? {}),
432
+ ...(parsed.maxOutputLength === undefined ? {} : {maxOutputLength: parsed.maxOutputLength}),
433
+ metrics
434
+ })
435
+ }
375
436
  activity = parsed.activityLog === undefined ? undefined : new ActivityLog(parsed.activityLog)
437
+ activity?.recordController(process.pid)
376
438
  /** @type {import("./run-worker.js").RunWorkerOptions} */
377
439
  const workerOptions = {
378
440
  executable: provider.executable,
@@ -383,13 +445,22 @@ export async function main(arguments_, dependencies = {}) {
383
445
  parse: provider.parse,
384
446
  ...(provider.completion === undefined ? {} : {completion: provider.completion}),
385
447
  ...(launchDeadline === undefined ? {} : {signal: launchDeadline.signal}),
386
- onEvent: (event) => {
448
+ onEvent: async (event) => {
387
449
  validateNormalizedWorkerEvent(event)
388
450
  if (event.type !== "text-delta") {
389
451
  metrics.recordRejected(`${event.type}_progress`, Buffer.byteLength(JSON.stringify(event), "utf8"))
390
452
  }
453
+ if (event.type === "text-delta") activity?.recordActivity(provider.name, "delta")
454
+ else if (event.type === "tool") activity?.recordActivity(provider.name, "tool")
455
+ else if (event.type === "lifecycle") activity?.recordActivity(provider.name, "lifecycle")
391
456
  acceptAdmissionEvent(admission, event)
392
- return control.accept(event)
457
+ if (control !== undefined) {
458
+ try {
459
+ await control.accept(event)
460
+ } catch (error) {
461
+ deliveryError ??= error
462
+ }
463
+ }
393
464
  },
394
465
  onSpawn: (pid) => {
395
466
  if (activity && pid !== undefined) activity.recordStarted(provider.name, pid)
@@ -401,6 +472,8 @@ export async function main(arguments_, dependencies = {}) {
401
472
  admission.setContinuationHandle(id)
402
473
  activity?.recordSession(provider.name, id)
403
474
  }
475
+ const health = provider.health(record)
476
+ if (health !== undefined) activity?.recordHealth(provider.name, health)
404
477
  },
405
478
  onStdoutChunk: (chunk) => {
406
479
  metrics.recordRawChildChunk("provider_stdout", chunk.length)
@@ -437,33 +510,83 @@ export async function main(arguments_, dependencies = {}) {
437
510
  } else {
438
511
  exitCode = await (dependencies.workerRunner ?? runWorker)(workerOptions)
439
512
  }
440
- await boundedLaunch(control.close(), launchDeadline)
441
513
  terminalExitCode = exitCode
442
514
  } finally {
443
- if (evidence !== undefined) {
515
+ const terminal = {state: /** @type {"completed" | "failed"} */ (terminalExitCode === 0 ? "completed" : "failed"), exitCode: terminalExitCode}
516
+ if (evidence !== undefined && resultEvidence !== undefined) {
517
+ try {
518
+ admission.addArtifactHandle(resultEvidence.handle)
519
+ const providerResult = admission.createProviderResult(terminal)
520
+ const serializedProviderResult = `${JSON.stringify(providerResult)}\n`
521
+ await resultEvidence.append("provider-result", serializedProviderResult)
522
+ const resultArtifact = await resultEvidence.finalize()
523
+ resultEvidenceBytes = resultArtifact.bytes
524
+ resultEvidenceFinalized = true
525
+ } catch (error) {
526
+ await evidence.abort()
527
+ await resultEvidence.abort()
528
+ admission.removeArtifactHandle(resultEvidence.handle)
529
+ metrics.clearArtifact()
530
+ terminalExitCode = 2
531
+ evidenceError = error
532
+ }
533
+ } else {
534
+ await evidence?.abort()
535
+ await resultEvidence?.abort()
536
+ }
537
+ if (control !== undefined) {
538
+ try {
539
+ await boundedLaunch(control.close(), launchDeadline)
540
+ } catch (error) {
541
+ deliveryError ??= error
542
+ }
543
+ }
544
+ if (evidence !== undefined && resultEvidenceFinalized) {
545
+ admission.addArtifactHandle(evidence.handle)
444
546
  try {
445
- admission.addArtifactHandle(evidence.handle)
446
- const terminal = {state: /** @type {"completed" | "failed"} */ (terminalExitCode === 0 ? "completed" : "failed"), exitCode: terminalExitCode}
447
547
  metrics.recordAdmission(admission.preview(terminal))
448
- const projectedArtifactBytes = metrics.projectSelfInclusiveArtifact(evidencePayloadBytes, 1)
548
+ const projectedArtifactBytes = metrics.projectSelfInclusiveArtifact(
549
+ evidencePayloadBytes + resultEvidenceBytes,
550
+ 2
551
+ )
449
552
  await evidence.append("context-metrics", `${JSON.stringify(metrics.snapshot())}\n`)
450
553
  const artifact = await evidence.finalize()
451
- assertArtifactProjection(artifact.bytes, projectedArtifactBytes)
554
+ assertArtifactProjection(artifact.bytes + resultEvidenceBytes, projectedArtifactBytes)
452
555
  } catch (error) {
453
- await evidence.abort()
454
556
  admission.removeArtifactHandle(evidence.handle)
557
+ await evidence.abort()
455
558
  metrics.clearArtifact()
456
- terminalExitCode = 2
457
- evidenceError = error
559
+ metrics.recordArtifact(resultEvidenceBytes, 1)
560
+ evidenceError ??= error
458
561
  }
562
+ } else if (resultEvidenceFinalized) {
563
+ metrics.recordArtifact(resultEvidenceBytes, 1)
459
564
  }
460
565
  admission.complete({state: terminalExitCode === 0 ? "completed" : "failed", exitCode: terminalExitCode})
566
+ if (
567
+ destination.type === "telegram" && resultEvidenceFinalized
568
+ && evidenceStore !== undefined && evidenceOwner !== undefined && resultEvidence !== undefined
569
+ ) {
570
+ try {
571
+ await evidenceStore.recordDelivery(
572
+ evidenceOwner,
573
+ resultEvidence.handle,
574
+ control !== undefined && deliveryError === undefined ? "delivery_succeeded" : "delivery_failed"
575
+ )
576
+ } catch (error) {
577
+ evidenceError ??= error
578
+ }
579
+ }
461
580
  errorOutput.write(`threadwire-context-metrics ${JSON.stringify(metrics.conciseDiagnostic())}\n`)
462
- activity?.close()
581
+ if (activity !== undefined) {
582
+ activity.recordTerminal(/** @type {"codex" | "claude" | "kimi" | "opencode"} */ (parsed.provider), terminalState(terminalExitCode), terminalExitCode)
583
+ activity.close()
584
+ }
463
585
  await boundedLaunch(ownedEvidenceStore?.close(), launchDeadline).catch(() => {})
464
586
  launchDeadline?.close()
465
587
  }
466
588
  if (evidenceError !== undefined) throw evidenceError
589
+ if (deliveryError !== undefined) throw deliveryError
467
590
  return terminalExitCode
468
591
  } catch (error) {
469
592
  let reportedError = error
@@ -485,6 +608,44 @@ export async function main(arguments_, dependencies = {}) {
485
608
  }
486
609
  }
487
610
 
611
+ /** @param {string} value @returns {{type: "telegram", target: import("./types.js").TelegramTarget} | {type: "file", path: string}} */
612
+ function parseDeliveryTarget(value) {
613
+ if (!value.startsWith("file:")) return {type: "telegram", target: parseTelegramTarget(value)}
614
+ const path = value.slice("file:".length)
615
+ if (!isAbsolute(path) || normalize(path) !== path || path.includes("\0") || path === "/") {
616
+ throw new Error("File target must contain a normalized absolute file path")
617
+ }
618
+ return {type: "file", path}
619
+ }
620
+
621
+ /** @param {string} path @param {Set<string>} [seen] @returns {Promise<string>} */
622
+ async function canonicalFilesystemPath(path, seen = new Set()) {
623
+ if (seen.has(path)) throw new Error("Result or activity log path contains a filesystem alias cycle")
624
+ seen.add(path)
625
+ try {
626
+ return await realpath(path)
627
+ } catch (error) {
628
+ if (!missingPath(error)) throw error
629
+ }
630
+ try {
631
+ const metadata = await lstat(path)
632
+ if (metadata.isSymbolicLink()) {
633
+ return canonicalFilesystemPath(resolve(dirname(path), await readlink(path)), seen)
634
+ }
635
+ } catch (error) {
636
+ if (!missingPath(error)) throw error
637
+ }
638
+ const parent = dirname(path)
639
+ if (parent === path) return path
640
+ return join(await canonicalFilesystemPath(parent, seen), basename(path))
641
+ }
642
+
643
+ /** @param {unknown} error */
644
+ function missingPath(error) {
645
+ return typeof error === "object" && error !== null
646
+ && /** @type {{code?: unknown}} */ (error).code === "ENOENT"
647
+ }
648
+
488
649
  /** @param {string | undefined} value */
489
650
  function evidenceRoot(value) {
490
651
  if (value === undefined) return undefined
@@ -105,6 +105,19 @@ export class DelegatedResultAdmission {
105
105
  return this.createEnvelope(terminal)
106
106
  }
107
107
 
108
+ /** @param {{state: TerminalState, exitCode: number}} terminal */
109
+ createProviderResult(terminal) {
110
+ const result = this.createEnvelope(terminal)
111
+ const assistantContent = truncateBoundedString(sanitizeOutput(this.conclusion), CONCLUSION_LIMIT)
112
+ return {
113
+ version: 1,
114
+ type: "provider_result",
115
+ providerState: terminal.state === "completed" ? "provider_completed" : "provider_failed",
116
+ ...(assistantContent === undefined ? {} : {assistantContent}),
117
+ result
118
+ }
119
+ }
120
+
108
121
  /** @param {AdmissionReference} reference */
109
122
  addReference(reference) {
110
123
  this.references = /** @type {AdmissionReference[]} */ (optionalReferences([...this.references, reference]) ?? [])