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
@@ -0,0 +1,313 @@
1
+ // @ts-nocheck
2
+ /* eslint-disable jsdoc/require-jsdoc */
3
+
4
+ import {constants} from "node:fs"
5
+ import {open} from "node:fs/promises"
6
+ import {createServer} from "node:http"
7
+ import {bearerToken, createGrantStore, validateBrokerRequest, validateResponsesBody} from "./model-broker-policy.js"
8
+ import {abortable, readResponseCapped} from "./absolute-deadline.js"
9
+
10
+ const MAX_BODY_BYTES = 2_097_152
11
+ const MAX_SECRET_BYTES = 65_536
12
+
13
+ /** @param {{environment?: NodeJS.ProcessEnv, fetchImplementation?: typeof fetch}} [options] */
14
+ export async function startModelBroker(options = {}) {
15
+ const environment = options.environment ?? process.env
16
+ const host = environment.THREADWIRE_MODEL_BROKER_HOST ?? "0.0.0.0"
17
+ const port = portValue(environment.THREADWIRE_MODEL_BROKER_PORT, 8789)
18
+ const adminToken = safeSetting(environment.THREADWIRE_MODEL_BROKER_ADMIN_TOKEN, "THREADWIRE_MODEL_BROKER_ADMIN_TOKEN")
19
+ const upstream = upstreamUrl(environment.THREADWIRE_CODEX_UPSTREAM_URL)
20
+ let credential = await readSecretFile(environment.THREADWIRE_CODEX_CREDENTIAL_FILE)
21
+ let generation = 1
22
+ let reloading = false
23
+ let stopping = false
24
+ const shutdown = new AbortController()
25
+ const controlSockets = new Set()
26
+ const grants = createGrantStore()
27
+ /** @type {Map<string, import("node:http").Server>} */
28
+ const listeners = new Map()
29
+ /** @type {Map<string, {controller: AbortController, timer: NodeJS.Timeout, sockets: Set<import("node:net").Socket>}>} */
30
+ const grantRuntimes = new Map()
31
+ const fetchImplementation = options.fetchImplementation ?? fetch
32
+ const revokeGrant = async (token) => {
33
+ grants.revoke(token)
34
+ const runtime = grantRuntimes.get(token)
35
+ grantRuntimes.delete(token)
36
+ if (runtime !== undefined) {
37
+ clearTimeout(runtime.timer)
38
+ runtime.controller.abort(new Error("Broker grant expired"))
39
+ for (const socket of runtime.sockets) socket.destroy()
40
+ runtime.sockets.clear()
41
+ }
42
+ await closeListener(listeners, token, 1000)
43
+ }
44
+
45
+ const server = createServer(async (request, response) => {
46
+ try {
47
+ if (stopping) throw new Error("Model broker is shutting down")
48
+ if (request.method === "GET" && request.url === "/healthz") {
49
+ response.writeHead(200, {"content-type": "application/json"})
50
+ response.end(JSON.stringify({ok: true, generation}))
51
+ return
52
+ }
53
+ if (request.url === "/admin/grants" && request.method === "POST") {
54
+ requireAdmin(request.headers.authorization, adminToken)
55
+ if (reloading) throw new Error("Model broker reload is active")
56
+ const body = await readJson(request, shutdown.signal)
57
+ const grant = parseGrant(body)
58
+ const expiresAt = Date.now() + grant.ttlMs
59
+ const token = grants.issue(grant)
60
+ const controller = new AbortController()
61
+ const sockets = new Set()
62
+ const listener = createServer((workerRequest, workerResponse) => {
63
+ proxyWorkerRequest(workerRequest, workerResponse, {
64
+ grants, token, grant, credential: () => credential, upstream, fetchImplementation,
65
+ signal: controller.signal
66
+ }).catch((error) => {
67
+ if (workerResponse.destroyed) return
68
+ process.stderr.write(`threadwire-model-broker: ${error instanceof Error ? error.message : "request failed"}\n`)
69
+ const status = isClientError(error) ? 403 : 502
70
+ sendJson(workerResponse, status, {error: status === 403 ? "request denied" : "model broker unavailable"})
71
+ })
72
+ })
73
+ listener.on("connection", (socket) => {
74
+ if (controller.signal.aborted) {
75
+ socket.destroy()
76
+ return
77
+ }
78
+ sockets.add(socket)
79
+ socket.once("close", () => sockets.delete(socket))
80
+ })
81
+ try {
82
+ await listen(listener, 0, grant.brokerAddress)
83
+ } catch (error) {
84
+ grants.revoke(token)
85
+ throw error
86
+ }
87
+ listeners.set(token, listener)
88
+ const expiryTimer = setTimeout(() => { void revokeGrant(token) }, Math.max(1, expiresAt - Date.now()))
89
+ expiryTimer.unref()
90
+ grantRuntimes.set(token, {controller, timer: expiryTimer, sockets})
91
+ const address = listener.address()
92
+ if (address === null || typeof address === "string") throw new Error("Invalid broker listener")
93
+ sendJson(response, 201, {token, generation, port: address.port})
94
+ return
95
+ }
96
+ if (request.url?.startsWith("/admin/grants/") && request.method === "DELETE") {
97
+ requireAdmin(request.headers.authorization, adminToken)
98
+ const token = decodeURIComponent(request.url.slice("/admin/grants/".length))
99
+ await revokeGrant(token)
100
+ response.writeHead(204)
101
+ response.end()
102
+ return
103
+ }
104
+ if (request.url?.startsWith("/admin/grants/") && request.url.endsWith("/activate") && request.method === "POST") {
105
+ requireAdmin(request.headers.authorization, adminToken)
106
+ const token = decodeURIComponent(request.url.slice("/admin/grants/".length, -"/activate".length))
107
+ grants.activate(token)
108
+ sendJson(response, 200, {ok: true})
109
+ return
110
+ }
111
+ if (request.url === "/admin/reload" && request.method === "POST") {
112
+ requireAdmin(request.headers.authorization, adminToken)
113
+ if (reloading) throw new Error("Model broker reload is active")
114
+ reloading = true
115
+ try {
116
+ const replacement = await readSecretFile(environment.THREADWIRE_CODEX_CREDENTIAL_FILE)
117
+ grants.clear()
118
+ await Promise.all([...listeners.keys()].map(revokeGrant))
119
+ credential = replacement
120
+ generation += 1
121
+ } finally {
122
+ reloading = false
123
+ }
124
+ sendJson(response, 200, {ok: true, generation})
125
+ return
126
+ }
127
+ sendJson(response, 403, {error: "request denied"})
128
+ } catch (error) {
129
+ const status = isClientError(error) ? 403 : 502
130
+ sendJson(response, status, {error: status === 403 ? "request denied" : "model broker unavailable"})
131
+ }
132
+ })
133
+ server.on("connection", (socket) => {
134
+ if (shutdown.signal.aborted) return socket.destroy()
135
+ controlSockets.add(socket)
136
+ socket.once("close", () => controlSockets.delete(socket))
137
+ })
138
+ await listen(server, port, host)
139
+ return {
140
+ server,
141
+ close: async () => {
142
+ stopping = true
143
+ shutdown.abort(new Error("Model broker is shutting down"))
144
+ for (const socket of controlSockets) socket.destroy()
145
+ await Promise.all([...listeners.keys()].map(revokeGrant))
146
+ await Promise.race([
147
+ new Promise((resolve) => server.close(() => resolve(undefined))),
148
+ new Promise((resolve) => {
149
+ const timer = setTimeout(resolve, 1000)
150
+ timer.unref()
151
+ })
152
+ ])
153
+ },
154
+ activeListeners: () => listeners.size,
155
+ config: {host, port, upstream: upstream.toString()}
156
+ }
157
+ }
158
+
159
+ /** @param {unknown} value */
160
+ function parseGrant(value) {
161
+ if (!isRecord(value) || value.provider !== "codex" || typeof value.runId !== "string" || typeof value.networkId !== "string" || typeof value.model !== "string" || typeof value.brokerAddress !== "string") {
162
+ throw new Error("Invalid broker grant")
163
+ }
164
+ if (!/^(?:\d{1,3}\.){3}\d{1,3}$/u.test(value.brokerAddress)) throw new Error("Invalid broker grant")
165
+ const ttlMs = value.ttlMs
166
+ if (typeof ttlMs !== "number") throw new Error("Invalid broker grant")
167
+ return {provider: "codex", runId: value.runId, networkId: value.networkId, model: value.model, brokerAddress: value.brokerAddress, ttlMs}
168
+ }
169
+
170
+ async function proxyWorkerRequest(request, response, options) {
171
+ throwIfAborted(options.signal)
172
+ const policyHeaders = {...request.headers}
173
+ delete policyHeaders.host
174
+ delete policyHeaders.connection
175
+ delete policyHeaders["transfer-encoding"]
176
+ validateBrokerRequest({method: request.method, path: request.url, headers: policyHeaders})
177
+ const suppliedToken = bearerToken(request.headers.authorization)
178
+ if (suppliedToken !== options.token) throw new Error("Broker grant token mismatch")
179
+ const grant = options.grants.authorize(suppliedToken, {
180
+ provider: "codex", networkId: options.grant.networkId, runId: options.grant.runId
181
+ })
182
+ const body = await readBody(request, options.signal)
183
+ throwIfAborted(options.signal)
184
+ options.grants.authorize(suppliedToken, {
185
+ provider: "codex", networkId: options.grant.networkId, runId: options.grant.runId
186
+ })
187
+ validateResponsesBody(body, grant.model)
188
+ throwIfAborted(options.signal)
189
+ const upstreamResponse = await abortable(options.fetchImplementation(new URL("/v1/responses", options.upstream), {
190
+ method: "POST",
191
+ redirect: "error",
192
+ headers: {
193
+ authorization: `Bearer ${options.credential()}`,
194
+ "content-type": "application/json",
195
+ "user-agent": "threadwire-model-broker/1"
196
+ },
197
+ body,
198
+ signal: options.signal
199
+ }), options.signal)
200
+ throwIfAborted(options.signal)
201
+ const bytes = await readResponseCapped(upstreamResponse, MAX_BODY_BYTES, options.signal)
202
+ throwIfAborted(options.signal)
203
+ response.writeHead(upstreamResponse.status, {"content-type": safeContentType(upstreamResponse.headers.get("content-type"))})
204
+ response.end(bytes)
205
+ }
206
+
207
+ async function closeListener(listeners, token, timeoutMs) {
208
+ const listener = listeners.get(token)
209
+ listeners.delete(token)
210
+ if (listener === undefined) return
211
+ await Promise.race([
212
+ new Promise((resolve) => listener.close(() => resolve(undefined))),
213
+ new Promise((resolve) => {
214
+ const timer = setTimeout(resolve, timeoutMs)
215
+ timer.unref()
216
+ })
217
+ ])
218
+ }
219
+
220
+ async function readSecretFile(path) {
221
+ const safePath = safeSetting(path, "THREADWIRE_CODEX_CREDENTIAL_FILE")
222
+ let handle
223
+ try {
224
+ handle = await open(safePath, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
225
+ const metadata = await handle.stat()
226
+ if (!metadata.isFile() || metadata.size < 1 || metadata.size > MAX_SECRET_BYTES || (metadata.mode & 0o022) !== 0) throw new Error()
227
+ const value = (await handle.readFile("utf8")).replace(/[\r\n]+$/u, "")
228
+ if (value.length === 0 || /[\r\n]/u.test(value)) throw new Error()
229
+ return value
230
+ } catch {
231
+ throw new Error("Codex credential file is unavailable")
232
+ } finally {
233
+ await handle?.close().catch(() => {})
234
+ }
235
+ }
236
+
237
+ async function readJson(request, signal) {
238
+ const body = await readBody(request, signal)
239
+ try {
240
+ return JSON.parse(body.toString("utf8"))
241
+ } catch {
242
+ throw new Error("Invalid request")
243
+ }
244
+ }
245
+
246
+ async function readBody(request, signal) {
247
+ const chunks = []
248
+ let size = 0
249
+ for await (const chunk of request) {
250
+ throwIfAborted(signal)
251
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
252
+ size += buffer.length
253
+ if (size > MAX_BODY_BYTES) throw new Error("Request exceeded capacity")
254
+ chunks.push(buffer)
255
+ }
256
+ throwIfAborted(signal)
257
+ return Buffer.concat(chunks)
258
+ }
259
+
260
+ function throwIfAborted(signal) {
261
+ if (signal?.aborted) throw signal.reason instanceof Error ? signal.reason : new Error("Broker grant expired")
262
+ }
263
+
264
+ function requireAdmin(value, expected) {
265
+ if (value !== `Bearer ${expected}`) throw new Error("Request denied")
266
+ }
267
+
268
+ function upstreamUrl(value) {
269
+ const url = new URL(safeSetting(value, "THREADWIRE_CODEX_UPSTREAM_URL"))
270
+ if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
271
+ throw new Error("THREADWIRE_CODEX_UPSTREAM_URL is invalid")
272
+ }
273
+ return url
274
+ }
275
+
276
+ function safeSetting(value, name) {
277
+ if (typeof value !== "string" || value.length === 0 || /[\r\n]/u.test(value)) throw new Error(`${name} is required`)
278
+ return value
279
+ }
280
+
281
+ function portValue(value, fallback) {
282
+ if (value === undefined) return fallback
283
+ const number = Number(value)
284
+ if (!/^\d+$/u.test(value) || !Number.isSafeInteger(number) || number < 1 || number > 65535) throw new Error("Invalid model broker port")
285
+ return number
286
+ }
287
+
288
+ function safeContentType(value) {
289
+ return value && /^[\w.+-]+\/[\w.+-]+(?:;\s*charset=[\w-]+)?$/u.test(value) ? value : "application/json"
290
+ }
291
+
292
+ function sendJson(response, status, value) {
293
+ response.writeHead(status, {"content-type": "application/json"})
294
+ response.end(JSON.stringify(value))
295
+ }
296
+
297
+ function isClientError(error) {
298
+ return error instanceof Error && /denied|grant|Invalid request|capacity/u.test(error.message)
299
+ }
300
+
301
+ function isRecord(value) {
302
+ return typeof value === "object" && value !== null && !Array.isArray(value)
303
+ }
304
+
305
+ function listen(server, port, host) {
306
+ return new Promise((resolve, reject) => {
307
+ server.once("error", reject)
308
+ server.listen(port, host, () => {
309
+ server.removeListener("error", reject)
310
+ resolve(undefined)
311
+ })
312
+ })
313
+ }
@@ -0,0 +1,28 @@
1
+ // @ts-check
2
+
3
+ import {readFile} from "node:fs/promises"
4
+ import {isAbsolute, relative} from "node:path"
5
+
6
+ /** @param {string} root @param {(path: string, encoding: "utf8") => Promise<string>} [reader] */
7
+ export async function assertNoNestedMounts(root, reader = readFile) {
8
+ const mountinfo = await reader("/proc/self/mountinfo", "utf8")
9
+ for (const line of mountinfo.split("\n")) {
10
+ if (line.length === 0) continue
11
+ const fields = line.split(" ")
12
+ const encoded = fields[4]
13
+ if (encoded === undefined) throw new Error("Mount topology unavailable")
14
+ const mountpoint = decodeMountinfo(encoded)
15
+ if (mountpoint !== root && within(root, mountpoint)) throw new Error("Nested worktree mounts are forbidden")
16
+ }
17
+ }
18
+
19
+ /** @param {string} root @param {string} child */
20
+ function within(root, child) {
21
+ const path = relative(root, child)
22
+ return path !== "" && !path.startsWith("..") && !isAbsolute(path)
23
+ }
24
+
25
+ /** @param {string} value */
26
+ function decodeMountinfo(value) {
27
+ return value.replace(/\\(040|011|012|134)/gu, (_, octal) => String.fromCharCode(Number.parseInt(octal, 8)))
28
+ }
@@ -0,0 +1,68 @@
1
+ // @ts-check
2
+
3
+ import {closeSync, fsyncSync, openSync, writeSync} from "node:fs"
4
+ import {dirname} from "node:path"
5
+
6
+ const filesystemOperations = {closeSync, fsyncSync, openSync, writeSync}
7
+
8
+ /**
9
+ * @typedef {object} FilesystemOperations
10
+ * @property {(fileDescriptor: number) => void} closeSync Close a transcript.
11
+ * @property {(fileDescriptor: number) => void} fsyncSync Sync a transcript.
12
+ * @property {(path: string, flags: string, mode?: number) => number} openSync Open a transcript or directory.
13
+ * @property {(fileDescriptor: number, buffer: Buffer, offset: number, length: number) => number} writeSync
14
+ * Write transcript bytes.
15
+ */
16
+
17
+ export class NormalizedOutput {
18
+ /**
19
+ * @param {Pick<NodeJS.WritableStream, "write">} output
20
+ * @param {string | undefined} transcriptPath
21
+ * @param {FilesystemOperations} [filesystem]
22
+ */
23
+ constructor(output, transcriptPath, filesystem = filesystemOperations) {
24
+ this.output = output
25
+ this.filesystem = filesystem
26
+ this.transcriptDirectory = transcriptPath === undefined ? undefined : dirname(transcriptPath)
27
+ this.fileDescriptor = transcriptPath === undefined
28
+ ? undefined
29
+ : this.filesystem.openSync(transcriptPath, "wx", 0o600)
30
+ this.closed = false
31
+ }
32
+
33
+ /** @param {string} record */
34
+ write(record) {
35
+ if (this.closed) throw new Error("Normalized output is closed")
36
+ if (this.fileDescriptor !== undefined) {
37
+ const bytes = Buffer.from(record)
38
+ let offset = 0
39
+ while (offset < bytes.length) {
40
+ const written = this.filesystem.writeSync(
41
+ this.fileDescriptor,
42
+ bytes,
43
+ offset,
44
+ bytes.length - offset
45
+ )
46
+ if (written === 0) throw new Error("Transcript write made zero bytes of progress")
47
+ offset += written
48
+ }
49
+ this.filesystem.fsyncSync(this.fileDescriptor)
50
+ const directoryDescriptor = this.filesystem.openSync(
51
+ /** @type {string} */ (this.transcriptDirectory),
52
+ "r"
53
+ )
54
+ try {
55
+ this.filesystem.fsyncSync(directoryDescriptor)
56
+ } finally {
57
+ this.filesystem.closeSync(directoryDescriptor)
58
+ }
59
+ }
60
+ this.output.write(record)
61
+ }
62
+
63
+ close() {
64
+ if (this.closed) return
65
+ this.closed = true
66
+ if (this.fileDescriptor !== undefined) this.filesystem.closeSync(this.fileDescriptor)
67
+ }
68
+ }
@@ -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)
@@ -0,0 +1,44 @@
1
+ // @ts-check
2
+
3
+ const MODEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u
4
+ const COLORS = new Set(["always", "never", "auto"])
5
+
6
+ /** @param {string[]} arguments_ */
7
+ export function validateRelayWriteProviderArguments(arguments_) {
8
+ /** @type {string[]} */
9
+ const normalized = []
10
+ let model = "gpt-5-codex"
11
+ for (let index = 0; index < arguments_.length; index += 1) {
12
+ const argument = /** @type {string} */ (arguments_[index])
13
+ if (argument === "--model" || argument === "-m") {
14
+ const value = arguments_[index + 1]
15
+ if (value === undefined || !MODEL_PATTERN.test(value)) throw new Error("Provider argument is unavailable in relay write mode")
16
+ model = value
17
+ normalized.push("--model", value)
18
+ index += 1
19
+ continue
20
+ }
21
+ if (argument.startsWith("--model=")) {
22
+ const value = argument.slice("--model=".length)
23
+ if (!MODEL_PATTERN.test(value)) throw new Error("Provider argument is unavailable in relay write mode")
24
+ model = value
25
+ normalized.push("--model", value)
26
+ continue
27
+ }
28
+ if (argument === "--color") {
29
+ const value = arguments_[index + 1]
30
+ if (value === undefined || !COLORS.has(value)) throw new Error("Provider argument is unavailable in relay write mode")
31
+ normalized.push("--color", value)
32
+ index += 1
33
+ continue
34
+ }
35
+ if (argument.startsWith("--color=")) {
36
+ const value = argument.slice("--color=".length)
37
+ if (!COLORS.has(value)) throw new Error("Provider argument is unavailable in relay write mode")
38
+ normalized.push("--color", value)
39
+ continue
40
+ }
41
+ throw new Error("Provider argument is unavailable in relay write mode")
42
+ }
43
+ return {arguments: normalized, model}
44
+ }