threadwire 0.1.5 → 0.1.6

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,7 +4,7 @@
4
4
  * A single unit of work handed to the transport: either a batched/chunked text
5
5
  * message, or a tool-activity message that must be correlated by `activity.key`
6
6
  * so its "finished" phase edits the exact Telegram message its "started" sent.
7
- * @typedef {{message: string | import("./types.js").OutgoingMessage, activity?: {key: string, phase: "started" | "finished"}}} Delivery
7
+ * @typedef {{message: string | import("./types.js").OutgoingMessage, metricClass?: string, activity?: {key: string, phase: "started" | "finished"}}} Delivery
8
8
  */
9
9
 
10
10
  const DEFAULT_MAX_LENGTH = 3500
@@ -13,10 +13,11 @@ const DEFAULT_MAX_PENDING_BYTES = 262_144
13
13
 
14
14
  export class NoticeQueue {
15
15
  /**
16
- * @param {{sender: import("./types.js").NoticeSender, minimumIntervalMs?: number, batchWindowMs?: number, maxLength?: number, maxPendingNotices?: number, maxPendingBytes?: number, sleep?: (milliseconds: number) => Promise<void>, now?: () => number, random?: () => number}} options
16
+ * @param {{sender: import("./types.js").NoticeSender, onDelivered?: (metricClass: string, bytes: number) => void, minimumIntervalMs?: number, batchWindowMs?: number, maxLength?: number, maxPendingNotices?: number, maxPendingBytes?: number, sleep?: (milliseconds: number) => Promise<void>, now?: () => number, random?: () => number}} options
17
17
  */
18
18
  constructor(options) {
19
19
  this.sender = options.sender
20
+ this.onDelivered = options.onDelivered
20
21
  this.minimumIntervalMs = nonnegativeSafeInteger(options.minimumIntervalMs ?? 1000, "minimumIntervalMs")
21
22
  this.batchWindowMs = nonnegativeSafeInteger(options.batchWindowMs ?? 0, "batchWindowMs")
22
23
  this.maxLength = positiveSafeInteger(options.maxLength ?? DEFAULT_MAX_LENGTH, "maxLength")
@@ -85,19 +86,19 @@ export class NoticeQueue {
85
86
  takeBatch() {
86
87
  const first = this.shiftPending()
87
88
  if (!first) return {message: ""}
88
- if ("activity" in first) return {message: this.capActivity(first), activity: first.activity}
89
- if (first.format === "javascript-code") return {message: this.takeCodeBatch(first)}
89
+ if ("activity" in first) return {message: this.capActivity(first), activity: first.activity, ...metricClass(first)}
90
+ if (first.format === "javascript-code") return {message: this.takeCodeBatch(first), ...metricClass(first)}
90
91
  let text = first.text
91
92
  const batchSeparator = first.batchSeparator ?? "\n"
92
93
  while (this.pending.length > 0) {
93
94
  const next = this.pending[0]
94
- if (!next || "activity" in next || next.format !== undefined || next.label !== first.label || (next.batchSeparator ?? "\n") !== batchSeparator) break
95
+ if (!next || "activity" in next || next.metricClass !== first.metricClass || next.format !== undefined || next.label !== first.label || (next.batchSeparator ?? "\n") !== batchSeparator) break
95
96
  const combined = `${text}${batchSeparator}${next.text}`
96
97
  if (combined.length > this.maxLength) break
97
98
  this.shiftPending()
98
99
  text = combined
99
100
  }
100
- if (text.length <= this.maxLength) return {message: text}
101
+ if (text.length <= this.maxLength) return {message: text, ...metricClass(first)}
101
102
  const label = first.label ?? /^\[P\d+\] /u.exec(text)?.[0] ?? ""
102
103
  if (label.length >= this.maxLength) throw new NoticeQueueChunkingError()
103
104
  let end = safeChunkEnd(text, this.maxLength)
@@ -110,7 +111,7 @@ export class NoticeQueue {
110
111
  if (end <= label.length) throw new NoticeQueueChunkingError()
111
112
  const remainder = `${label}${text.slice(remainderStart)}`
112
113
  this.unshiftPending({...first, text: remainder})
113
- return {message: text.slice(0, end)}
114
+ return {message: text.slice(0, end), ...metricClass(first)}
114
115
  }
115
116
 
116
117
  /**
@@ -196,10 +197,12 @@ export class NoticeQueue {
196
197
  async deliver(delivery) {
197
198
  if (delivery.activity === undefined) {
198
199
  await this.withRetry(() => this.sender.send(delivery.message))
200
+ this.noteDelivered(delivery)
199
201
  return
200
202
  }
201
203
  if (delivery.activity.phase === "started") {
202
204
  const sent = await this.withRetry(() => this.sender.send(delivery.message))
205
+ this.noteDelivered(delivery)
203
206
  const messageId = deliveredMessageId(sent)
204
207
  if (messageId !== undefined) this.activityMessageIds.set(delivery.activity.key, messageId)
205
208
  return
@@ -219,11 +222,18 @@ export class NoticeQueue {
219
222
  // swallow, so only an unrecoverable edit is dropped.
220
223
  try {
221
224
  await this.withRetry(() => edit(messageId, delivery.message))
225
+ this.noteDelivered(delivery)
222
226
  } catch {
223
227
  // Intentionally non-fatal: a failed completion edit never poisons the queue.
224
228
  }
225
229
  }
226
230
 
231
+ /** @param {Delivery} delivery */
232
+ noteDelivered(delivery) {
233
+ if (delivery.metricClass === undefined) return
234
+ this.onDelivered?.(delivery.metricClass, Buffer.byteLength(JSON.stringify(delivery.message), "utf8"))
235
+ }
236
+
227
237
  /** @template T @param {() => Promise<T>} operation @returns {Promise<T>} */
228
238
  async withRetry(operation) {
229
239
  for (let attempt = 0; attempt < 4; attempt += 1) {
@@ -241,6 +251,11 @@ export class NoticeQueue {
241
251
  }
242
252
  }
243
253
 
254
+ /** @param {import("./types.js").RenderedNotice} notice */
255
+ function metricClass(notice) {
256
+ return notice.metricClass === undefined ? {} : {metricClass: notice.metricClass}
257
+ }
258
+
244
259
  /** @param {import("./types.js").SentMessage | void} sent @returns {number | undefined} */
245
260
  function deliveredMessageId(sent) {
246
261
  if (sent !== undefined && Number.isSafeInteger(sent.messageId)) return sent.messageId
@@ -18,12 +18,11 @@ export function buildOpenCodeCommand(providerArguments, prompt, resumeSession, e
18
18
  export function createOpenCodeSessionId() {
19
19
  let emitted = false
20
20
  return (record) => {
21
- if (emitted || !isRecord(record)) return undefined
22
- const id = typeof record.sessionID === "string"
23
- ? record.sessionID
24
- : isRecord(record.part) && typeof record.part.sessionID === "string" ? record.part.sessionID : undefined
25
- if (id !== undefined) emitted = true
26
- return id
21
+ if (emitted) return undefined
22
+ const {sessionId, trusted} = recognizeOpenCodeRecord(record, () => [])
23
+ if (!trusted || sessionId === undefined) return undefined
24
+ emitted = true
25
+ return sessionId
27
26
  }
28
27
  }
29
28
 
@@ -31,29 +30,47 @@ export function createOpenCodeSessionId() {
31
30
  export function createOpenCodeParser() {
32
31
  let started = false
33
32
  const activeToolKeys = new Set()
34
- return (record) => filterRepeatedToolStarts(parseOpenCodeRecord(record, () => {
33
+ return (record) => filterRepeatedToolStarts(recognizeOpenCodeRecord(record, () => {
35
34
  if (started) return []
36
35
  started = true
37
36
  return [{type: "lifecycle", phase: "started", summary: "OpenCode worker started"}]
38
- }), activeToolKeys)
37
+ }).events, activeToolKeys)
39
38
  }
40
39
 
41
40
  /** @param {unknown} record @returns {import("../types.js").WorkerEvent[]} */
42
41
  export function parseOpenCodeEvent(record) {
43
- return parseOpenCodeRecord(record, () => [{type: "lifecycle", phase: "started", summary: "OpenCode worker started"}])
42
+ return recognizeOpenCodeRecord(record, () => [{type: "lifecycle", phase: "started", summary: "OpenCode worker started"}]).events
44
43
  }
45
44
 
46
- /** @param {unknown} record @param {() => import("../types.js").WorkerEvent[]} start @returns {import("../types.js").WorkerEvent[]} */
47
- function parseOpenCodeRecord(record, start) {
48
- if (!isRecord(record) || typeof record.type !== "string") return []
49
- if (record.type === "step_start") return start()
50
- if (record.type === "step_finish") return []
51
- if (record.type === "error") return [{type: "lifecycle", phase: "failed", summary: "OpenCode worker failed"}]
45
+ /**
46
+ * @param {unknown} record
47
+ * @param {() => import("../types.js").WorkerEvent[]} start
48
+ * @returns {{trusted: boolean, sessionId: string | undefined, events: import("../types.js").WorkerEvent[]}}
49
+ */
50
+ function recognizeOpenCodeRecord(record, start) {
51
+ if (!isRecord(record) || typeof record.type !== "string") return {trusted: false, sessionId: undefined, events: []}
52
+ const part = isRecord(record.part) ? record.part : undefined
53
+ if (record.type === "step_start" && part?.type === "step-start") {
54
+ return {trusted: true, sessionId: sessionIdForKnownRecord(record, part), events: start()}
55
+ }
56
+ if (record.type === "step_finish" && part?.type === "step-finish") {
57
+ return {trusted: true, sessionId: sessionIdForKnownRecord(record, part), events: []}
58
+ }
59
+ if (record.type === "error" && part?.type === "error") {
60
+ return {trusted: true, sessionId: sessionIdForKnownRecord(record, part), events: [{type: "lifecycle", phase: "failed", summary: "OpenCode worker failed"}]}
61
+ }
52
62
  if (record.type === "text" && isRecord(record.part) && typeof record.part.text === "string") {
53
- return [{type: "text-delta", text: record.part.text, streamId: stringValue(record.part.id, "opencode:assistant")}]
63
+ return {
64
+ trusted: true,
65
+ sessionId: sessionIdForKnownRecord(record, record.part),
66
+ events: [{type: "text-delta", text: record.part.text, streamId: stringValue(record.part.id, "opencode:assistant")}]
67
+ }
54
68
  }
55
- if (record.type === "tool_use" && isRecord(record.part)) return parseTool(record.part)
56
- return []
69
+ if (record.type === "tool_use" && isRecord(record.part)) {
70
+ const events = parseTool(record.part)
71
+ if (events.length > 0) return {trusted: true, sessionId: sessionIdForKnownRecord(record, record.part), events}
72
+ }
73
+ return {trusted: false, sessionId: undefined, events: []}
57
74
  }
58
75
 
59
76
  /** @param {Record<string, unknown>} part @returns {import("../types.js").WorkerEvent[]} */
@@ -104,6 +121,13 @@ function stringValue(value, fallback) {
104
121
  return typeof value === "string" ? value : fallback
105
122
  }
106
123
 
124
+ /** @param {Record<string, unknown>} record @param {Record<string, unknown> | undefined} [part] */
125
+ function sessionIdForKnownRecord(record, part) {
126
+ if (typeof record.sessionID === "string") return record.sessionID
127
+ if (part !== undefined && typeof part.sessionID === "string") return part.sessionID
128
+ return undefined
129
+ }
130
+
107
131
  /** @param {unknown} value @returns {value is Record<string, unknown>} */
108
132
  function isRecord(value) {
109
133
  return typeof value === "object" && value !== null && !Array.isArray(value)
package/src/relay.js CHANGED
@@ -45,7 +45,7 @@ export class Relay {
45
45
  return
46
46
  }
47
47
  const text = this.renderActivity(event)
48
- if (text) this.queue.enqueue({text, label: `${this.label} `})
48
+ if (text) this.queue.enqueue({text, label: `${this.label} `, metricClass: event.type})
49
49
  }
50
50
 
51
51
  /**
@@ -60,7 +60,7 @@ export class Relay {
60
60
  if (event.phase === "started") {
61
61
  const description = this.toolDescription(event)
62
62
  this.activeToolDescriptions.set(event.key, {description, hasDetail: event.detail !== undefined && event.detail.length > 0})
63
- this.queue.enqueue({activity: {key: event.key, phase: "started"}, text: `🛠 ${this.label} Tool: ${description}`})
63
+ this.queue.enqueue({activity: {key: event.key, phase: "started"}, text: `🛠 ${this.label} Tool: ${description}`, metricClass: "tool"})
64
64
  return
65
65
  }
66
66
  const stored = this.activeToolDescriptions.get(event.key)
@@ -71,10 +71,10 @@ export class Relay {
71
71
  const status = `✅ ${this.label} Tool: ${description}`
72
72
  const html = renderCompletedOutput(status, event.output, this.outputMessageLimit())
73
73
  if (html === undefined) {
74
- this.queue.enqueue({activity: {key: event.key, phase: "finished"}, text: status})
74
+ this.queue.enqueue({activity: {key: event.key, phase: "finished"}, text: status, metricClass: "tool"})
75
75
  return
76
76
  }
77
- this.queue.enqueue({activity: {key: event.key, phase: "finished"}, text: html, parseMode: "HTML"})
77
+ this.queue.enqueue({activity: {key: event.key, phase: "finished"}, text: html, parseMode: "HTML", metricClass: "tool"})
78
78
  }
79
79
 
80
80
  /**
@@ -165,10 +165,10 @@ export class Relay {
165
165
 
166
166
  /** @param {string} text */
167
167
  enqueueAssistant(text) {
168
- if (this.inJavaScriptFence) this.queue.enqueue({text, format: "javascript-code", label: `${this.label} Assistant:`})
168
+ if (this.inJavaScriptFence) this.queue.enqueue({text, format: "javascript-code", label: `${this.label} Assistant:`, metricClass: "assistant"})
169
169
  else {
170
170
  const label = `${this.label} Assistant: `
171
- this.queue.enqueue({text: `${label}${text.replaceAll(/(\r\n|[\n\r\u2028\u2029])(?=.)/gu, `$1${label}`)}`, label, batchSeparator: ""})
171
+ this.queue.enqueue({text: `${label}${text.replaceAll(/(\r\n|[\n\r\u2028\u2029])(?=.)/gu, `$1${label}`)}`, label, batchSeparator: "", metricClass: "assistant"})
172
172
  }
173
173
  }
174
174
 
package/src/run-worker.js CHANGED
@@ -6,17 +6,25 @@ import {StringDecoder} from "node:string_decoder"
6
6
  import {buildProviderEnvironment} from "./telegram-ingress/config.js"
7
7
 
8
8
  const DEFAULT_MAX_STDOUT_RECORD_BYTES = 1_048_576
9
+ const DEFAULT_TERMINATION_GRACE_PERIOD_MS = 5_000
9
10
 
10
11
  /**
11
- * @typedef {{executable: string, arguments: string[], cwd: string, environment?: NodeJS.ProcessEnv, parse: (record: unknown) => import("./types.js").WorkerEvent[], onEvent: (event: import("./types.js").WorkerEvent) => void, onSpawn?: (pid: number) => void, onRecord?: (record: unknown) => void, spawnImplementation?: typeof nodeSpawn, maxStdoutRecordBytes?: number}} RunWorkerOptions
12
+ * @typedef {{executable: string, arguments: string[], cwd: string, environment?: NodeJS.ProcessEnv, parse: (record: unknown) => import("./types.js").WorkerEvent[], onEvent: (event: import("./types.js").WorkerEvent) => void | Promise<void>, onSpawn?: (pid: number) => void, onRecord?: (record: unknown) => void | Promise<void>, onStdoutChunk?: (chunk: Buffer) => void | Promise<void>, onStderrChunk?: (chunk: Buffer) => void | Promise<void>, spawnImplementation?: typeof nodeSpawn, maxStdoutRecordBytes?: number, terminationGracePeriodMs?: number, setTimer?: (callback: () => void, milliseconds: number) => unknown, clearTimer?: (handle: unknown) => void, platform?: NodeJS.Platform, killProcess?: (pid: number, signal: NodeJS.Signals) => boolean}} RunWorkerOptions
12
13
  */
13
14
 
14
15
  /** @param {RunWorkerOptions} options @returns {Promise<number>} */
15
16
  export function runWorker(options) {
16
17
  const maxStdoutRecordBytes = positiveSafeInteger(options.maxStdoutRecordBytes ?? DEFAULT_MAX_STDOUT_RECORD_BYTES, "maxStdoutRecordBytes")
18
+ const terminationGracePeriodMs = positiveSafeInteger(options.terminationGracePeriodMs ?? DEFAULT_TERMINATION_GRACE_PERIOD_MS, "terminationGracePeriodMs")
19
+ const platform = options.platform ?? process.platform
17
20
  const spawnImplementation = options.spawnImplementation ?? nodeSpawn
21
+ const killProcess = options.killProcess ?? ((pid, signal) => process.kill(pid, signal))
22
+ const setTimer = options.setTimer ?? ((callback, milliseconds) => setTimeout(callback, milliseconds))
23
+ const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(/** @type {ReturnType<typeof setTimeout>} */ (handle)))
24
+ const useDetachedProcessGroup = platform !== "win32"
18
25
  const child = spawnImplementation(options.executable, options.arguments, {
19
26
  cwd: options.cwd,
27
+ detached: useDetachedProcessGroup,
20
28
  env: childEnvironment(options.environment ?? process.env),
21
29
  shell: false,
22
30
  stdio: ["ignore", "pipe", "pipe"]
@@ -28,48 +36,124 @@ export function runWorker(options) {
28
36
 
29
37
  return new Promise((resolve, reject) => {
30
38
  let settled = false
39
+ /** @type {number | null} */
40
+ let closeCode = null
41
+ /** @type {NodeJS.Signals | null} */
42
+ let closeSignal = null
43
+ let closeSeen = false
44
+ /** @type {Promise<void>} */
45
+ let consumer = Promise.resolve()
46
+ /** @type {Error | null} */
47
+ let shutdownError = null
48
+ /** @type {unknown | null} */
49
+ let terminationTimer = null
50
+ const processGroupPid = useDetachedProcessGroup && isPositiveSafeInteger(child.pid) ? child.pid : null
31
51
  /** @param {NodeJS.Signals} signal */
32
- const forwardSignal = (signal) => child.kill(signal)
52
+ const forwardSignal = (signal) => signalChildTree(child, processGroupPid, signal, killProcess)
33
53
  const onInterrupt = () => forwardSignal("SIGINT")
34
54
  const onTerminate = () => forwardSignal("SIGTERM")
35
- /** @param {Error} error @param {boolean} [terminateChild] */
36
- const fail = (error, terminateChild = true) => {
55
+ const clearTerminationTimer = () => {
56
+ if (terminationTimer === null) return
57
+ clearTimer(terminationTimer)
58
+ terminationTimer = null
59
+ }
60
+ const detachForShutdown = () => {
61
+ child.stdout?.removeListener("data", onStdout)
62
+ child.stderr?.removeListener("data", onStderr)
63
+ child.removeListener("spawn", onSpawn)
64
+ child.removeListener("error", onError)
65
+ removeSignalHandlers(onInterrupt, onTerminate)
66
+ }
67
+ const cleanup = () => {
68
+ detachForShutdown()
69
+ clearTerminationTimer()
70
+ child.removeListener("close", onClose)
71
+ }
72
+ /** @param {Error} error */
73
+ const rejectAndCleanup = (error) => {
37
74
  if (settled) return
38
75
  settled = true
39
76
  cleanup()
40
- if (terminateChild) {
41
- try { child.kill("SIGTERM") } catch { /* Child may already be gone. */ }
42
- }
43
77
  reject(error)
44
78
  }
79
+ /** @param {Error} error @param {boolean} [terminateChild] */
80
+ const fail = (error, terminateChild = true) => {
81
+ if (settled || shutdownError) return
82
+ if (!terminateChild) {
83
+ rejectAndCleanup(error)
84
+ return
85
+ }
86
+ if (closeSeen) {
87
+ if (processGroupPid === null) {
88
+ try {
89
+ forwardSignal("SIGTERM")
90
+ } catch {
91
+ /* Child may already be gone. */
92
+ }
93
+ }
94
+ rejectAndCleanup(error)
95
+ return
96
+ }
97
+ shutdownError = error
98
+ detachForShutdown()
99
+ try {
100
+ forwardSignal("SIGTERM")
101
+ } catch {
102
+ /* Child may already be gone. */
103
+ }
104
+ terminationTimer = setTimer(() => {
105
+ if (settled || shutdownError === null) return
106
+ terminationTimer = null
107
+ try {
108
+ forwardSignal("SIGKILL")
109
+ } catch {
110
+ /* Child may already be gone. */
111
+ }
112
+ rejectAndCleanup(shutdownError)
113
+ }, terminationGracePeriodMs)
114
+ }
115
+ /**
116
+ * @param {() => Promise<void>} task
117
+ * @param {NodeJS.ReadableStream | undefined} stream
118
+ */
119
+ const enqueueConsumer = (task, stream) => {
120
+ stream?.pause()
121
+ consumer = consumer
122
+ .then(async () => {
123
+ if (settled || shutdownError) return
124
+ await task()
125
+ })
126
+ .then(() => {
127
+ stream?.resume()
128
+ if (closeSeen) void finalizeClose()
129
+ })
130
+ .catch((error) => {
131
+ fail(toError(error))
132
+ })
133
+ }
45
134
  /** @param {Buffer | string} chunk */
46
135
  const onStdout = (chunk) => {
47
- if (settled) return
48
- try {
136
+ if (settled || shutdownError) return
137
+ enqueueConsumer(async () => {
49
138
  const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
139
+ await options.onStdoutChunk?.(bytes)
50
140
  stdoutRecordBytes = nextRecordByteCount(bytes, stdoutRecordBytes, maxStdoutRecordBytes)
51
141
  stdoutBuffer += stdoutDecoder.write(bytes)
52
- stdoutBuffer = drainLines(stdoutBuffer, options.parse, options.onEvent, options.onRecord)
53
- } catch (error) {
54
- fail(toError(error))
55
- }
142
+ stdoutBuffer = await drainLines(stdoutBuffer, options.parse, options.onEvent, options.onRecord)
143
+ }, child.stdout)
56
144
  }
57
- const onStderr = () => {
58
- if (settled || stderrSeen) return
59
- stderrSeen = true
60
- try {
61
- options.onEvent({type: "diagnostic", level: "warning", summary: "Worker wrote diagnostics to stderr"})
62
- } catch (error) {
63
- fail(toError(error))
64
- }
65
- }
66
- const cleanup = () => {
67
- child.stdout?.removeListener("data", onStdout)
68
- child.stderr?.removeListener("data", onStderr)
69
- child.removeListener("spawn", onSpawn)
70
- child.removeListener("error", onError)
71
- child.removeListener("close", onClose)
72
- removeSignalHandlers(onInterrupt, onTerminate)
145
+ /** @param {Buffer | string} chunk */
146
+ const onStderr = (chunk) => {
147
+ if (settled || shutdownError) return
148
+ enqueueConsumer(
149
+ async () => {
150
+ await options.onStderrChunk?.(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
151
+ if (stderrSeen) return
152
+ stderrSeen = true
153
+ await options.onEvent({type: "diagnostic", level: "warning", summary: "Worker wrote diagnostics to stderr"})
154
+ },
155
+ child.stderr
156
+ )
73
157
  }
74
158
  /** @param {Error} error */
75
159
  const onError = (error) => fail(error, false)
@@ -82,19 +166,36 @@ export function runWorker(options) {
82
166
  fail(toError(error))
83
167
  }
84
168
  }
85
- /** @param {number | null} code @param {NodeJS.Signals | null} signal */
86
- const onClose = (code, signal) => {
169
+ const finalizeClose = async () => {
87
170
  if (settled) return
171
+ if (shutdownError) {
172
+ settled = true
173
+ cleanup()
174
+ reject(shutdownError)
175
+ return
176
+ }
88
177
  try {
178
+ await consumer
179
+ if (settled) return
89
180
  stdoutBuffer += stdoutDecoder.end()
90
- if (stdoutBuffer.trim().length > 0) parseLine(stdoutBuffer, options.parse, options.onEvent, options.onRecord)
181
+ if (stdoutBuffer.trim().length > 0) await parseLine(stdoutBuffer, options.parse, options.onEvent, options.onRecord)
91
182
  } catch (error) {
92
183
  fail(toError(error), false)
93
184
  return
94
185
  }
95
186
  settled = true
96
187
  cleanup()
97
- resolve(code ?? signalExitCode(signal))
188
+ resolve(closeCode ?? signalExitCode(closeSignal))
189
+ }
190
+ /** @param {number | null} code @param {NodeJS.Signals | null} signal */
191
+ const onClose = (code, signal) => {
192
+ if (settled) return
193
+ closeSeen = true
194
+ removeSignalHandlers(onInterrupt, onTerminate)
195
+ closeCode = code
196
+ closeSignal = signal
197
+ clearTerminationTimer()
198
+ void finalizeClose()
98
199
  }
99
200
  child.stdout?.on("data", onStdout)
100
201
  child.stderr?.on("data", onStderr)
@@ -125,26 +226,32 @@ export function childEnvironment(environment = process.env) {
125
226
  }
126
227
 
127
228
  /** @param {string} buffer @param {RunWorkerOptions["parse"]} parse @param {RunWorkerOptions["onEvent"]} onEvent @param {RunWorkerOptions["onRecord"]} onRecord */
128
- function drainLines(buffer, parse, onEvent, onRecord) {
229
+ async function drainLines(buffer, parse, onEvent, onRecord) {
129
230
  const lines = buffer.split("\n")
130
231
  const tail = lines.pop() ?? ""
131
- for (const line of lines) parseLine(line, parse, onEvent, onRecord)
232
+ for (const line of lines) await parseLine(line, parse, onEvent, onRecord)
132
233
  return tail
133
234
  }
134
235
 
135
236
  /** @param {string} line @param {RunWorkerOptions["parse"]} parse @param {RunWorkerOptions["onEvent"]} onEvent @param {RunWorkerOptions["onRecord"]} onRecord */
136
- function parseLine(line, parse, onEvent, onRecord) {
237
+ async function parseLine(line, parse, onEvent, onRecord) {
137
238
  if (line.trim().length === 0) return
239
+ let record
240
+ try {
241
+ record = JSON.parse(line)
242
+ } catch {
243
+ await onEvent({type: "diagnostic", level: "warning", summary: "Worker emitted an unreadable event"})
244
+ return
245
+ }
246
+ await onRecord?.(record)
138
247
  let events
139
248
  try {
140
- const record = JSON.parse(line)
141
- onRecord?.(record)
142
249
  events = parse(record)
143
250
  } catch {
144
- onEvent({type: "diagnostic", level: "warning", summary: "Worker emitted an unreadable event"})
251
+ await onEvent({type: "diagnostic", level: "warning", summary: "Worker emitted an unreadable event"})
145
252
  return
146
253
  }
147
- for (const event of events) onEvent(event)
254
+ for (const event of events) await onEvent(event)
148
255
  }
149
256
 
150
257
  /** @param {NodeJS.Signals | null} signal */
@@ -153,6 +260,17 @@ function signalExitCode(signal) {
153
260
  return 128 + (osConstants.signals[signal] ?? 0)
154
261
  }
155
262
 
263
+ /** @param {unknown} value @returns {value is number} */
264
+ function isPositiveSafeInteger(value) {
265
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0
266
+ }
267
+
268
+ /** @param {ReturnType<typeof nodeSpawn>} child @param {number | null} processGroupPid @param {NodeJS.Signals} signal @param {(pid: number, signal: NodeJS.Signals) => boolean} killProcess */
269
+ function signalChildTree(child, processGroupPid, signal, killProcess) {
270
+ if (processGroupPid !== null) return killProcess(-processGroupPid, signal)
271
+ return child.kill(signal)
272
+ }
273
+
156
274
  /** @param {() => void} onInterrupt @param {() => void} onTerminate */
157
275
  function removeSignalHandlers(onInterrupt, onTerminate) {
158
276
  process.removeListener("SIGINT", onInterrupt)
@@ -3,6 +3,7 @@
3
3
  import {PROVIDERS} from "../providers/index.js"
4
4
 
5
5
  const COMMAND_PATTERN = /^\/code(?:@[A-Za-z0-9_]+)?\s+(codex|claude|opencode)\s+(\S[\s\S]*)$/u
6
+ const EVIDENCE_PATTERN = /^\/evidence(?:@[A-Za-z0-9_]+)?\s+(evidence_[A-Za-z0-9_-]{43})\s+(bytes|lines)\s+(\d+):(\d+)$/u
6
7
 
7
8
  /**
8
9
  * @typedef {{provider: "codex" | "claude" | "opencode", prompt: string}} CodeCommand
@@ -27,3 +28,18 @@ export function parseCodeCommand(text) {
27
28
  prompt
28
29
  }
29
30
  }
31
+
32
+ /** @param {string} text */
33
+ export function parseEvidenceCommand(text) {
34
+ if (typeof text !== "string") return null
35
+ const match = EVIDENCE_PATTERN.exec(text)
36
+ if (!match) return null
37
+ const handle = match[1]
38
+ const selector = match[2]
39
+ const start = Number(match[3])
40
+ const limit = Number(match[4])
41
+ if (!handle || !Number.isSafeInteger(start) || !Number.isSafeInteger(limit) || limit <= 0) return null
42
+ if (selector === "bytes" && start >= 0) return {handle, bytes: {offset: start, limit}}
43
+ if (selector === "lines" && start > 0) return {handle, lines: {start, limit}}
44
+ return null
45
+ }