@namzu/sdk 52.2.0 → 52.4.0

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 (143) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/bridge/a2a/mapper.d.ts.map +1 -1
  3. package/dist/bridge/a2a/mapper.js +3 -0
  4. package/dist/bridge/a2a/mapper.js.map +1 -1
  5. package/dist/bridge/sse/mapper.d.ts.map +1 -1
  6. package/dist/bridge/sse/mapper.js +18 -0
  7. package/dist/bridge/sse/mapper.js.map +1 -1
  8. package/dist/connector/web/guarded-fetch.d.ts.map +1 -1
  9. package/dist/connector/web/guarded-fetch.js +44 -0
  10. package/dist/connector/web/guarded-fetch.js.map +1 -1
  11. package/dist/contracts/session/api.d.ts +1 -1
  12. package/dist/contracts/session/api.d.ts.map +1 -1
  13. package/dist/index.d.ts +4 -2
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +2 -1
  16. package/dist/index.js.map +1 -1
  17. package/dist/pals/channels/routing.d.ts +1 -1
  18. package/dist/pals/channels/schema.d.ts +7 -7
  19. package/dist/pals/communication/ingress-schema.d.ts +32 -32
  20. package/dist/process/kill-tree.d.ts +3 -2
  21. package/dist/process/kill-tree.d.ts.map +1 -1
  22. package/dist/process/kill-tree.js.map +1 -1
  23. package/dist/public-runtime.d.ts +2 -2
  24. package/dist/public-runtime.d.ts.map +1 -1
  25. package/dist/public-runtime.js +1 -1
  26. package/dist/public-runtime.js.map +1 -1
  27. package/dist/public-tools.d.ts +3 -1
  28. package/dist/public-tools.d.ts.map +1 -1
  29. package/dist/public-tools.js +6 -1
  30. package/dist/public-tools.js.map +1 -1
  31. package/dist/runtime/jobs/awaited-jobs.d.ts +59 -2
  32. package/dist/runtime/jobs/awaited-jobs.d.ts.map +1 -1
  33. package/dist/runtime/jobs/awaited-jobs.js +108 -5
  34. package/dist/runtime/jobs/awaited-jobs.js.map +1 -1
  35. package/dist/runtime/jobs/monitor-stream.d.ts +96 -0
  36. package/dist/runtime/jobs/monitor-stream.d.ts.map +1 -0
  37. package/dist/runtime/jobs/monitor-stream.js +221 -0
  38. package/dist/runtime/jobs/monitor-stream.js.map +1 -0
  39. package/dist/runtime/jobs/registry.d.ts +175 -1
  40. package/dist/runtime/jobs/registry.d.ts.map +1 -1
  41. package/dist/runtime/jobs/registry.js +267 -19
  42. package/dist/runtime/jobs/registry.js.map +1 -1
  43. package/dist/runtime/jobs/websocket-source.d.ts +120 -0
  44. package/dist/runtime/jobs/websocket-source.d.ts.map +1 -0
  45. package/dist/runtime/jobs/websocket-source.js +379 -0
  46. package/dist/runtime/jobs/websocket-source.js.map +1 -0
  47. package/dist/runtime/query/executor.d.ts +9 -0
  48. package/dist/runtime/query/executor.d.ts.map +1 -1
  49. package/dist/runtime/query/executor.js +6 -0
  50. package/dist/runtime/query/executor.js.map +1 -1
  51. package/dist/runtime/query/index.d.ts +16 -0
  52. package/dist/runtime/query/index.d.ts.map +1 -1
  53. package/dist/runtime/query/index.js +93 -3
  54. package/dist/runtime/query/index.js.map +1 -1
  55. package/dist/runtime/query/iteration/outstanding-work.d.ts +14 -0
  56. package/dist/runtime/query/iteration/outstanding-work.d.ts.map +1 -1
  57. package/dist/runtime/query/iteration/outstanding-work.js +64 -3
  58. package/dist/runtime/query/iteration/outstanding-work.js.map +1 -1
  59. package/dist/runtime/query/iteration/phases/context.d.ts +9 -0
  60. package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
  61. package/dist/runtime/query/iteration/phases/context.js.map +1 -1
  62. package/dist/runtime/query/iteration/phases/tool-review.d.ts.map +1 -1
  63. package/dist/runtime/query/iteration/phases/tool-review.js +11 -2
  64. package/dist/runtime/query/iteration/phases/tool-review.js.map +1 -1
  65. package/dist/runtime/query/release-turn.d.ts.map +1 -1
  66. package/dist/runtime/query/release-turn.js +35 -0
  67. package/dist/runtime/query/release-turn.js.map +1 -1
  68. package/dist/runtime/query/steering.d.ts +35 -0
  69. package/dist/runtime/query/steering.d.ts.map +1 -1
  70. package/dist/runtime/query/steering.js +134 -0
  71. package/dist/runtime/query/steering.js.map +1 -1
  72. package/dist/runtime/query/tooling.d.ts +4 -0
  73. package/dist/runtime/query/tooling.d.ts.map +1 -1
  74. package/dist/runtime/query/tooling.js +4 -0
  75. package/dist/runtime/query/tooling.js.map +1 -1
  76. package/dist/runtime/system-events.d.ts +9 -0
  77. package/dist/runtime/system-events.d.ts.map +1 -1
  78. package/dist/runtime/system-events.js +12 -0
  79. package/dist/runtime/system-events.js.map +1 -1
  80. package/dist/tools/builtins/bash.d.ts +3 -0
  81. package/dist/tools/builtins/bash.d.ts.map +1 -1
  82. package/dist/tools/builtins/bash.js +3 -2
  83. package/dist/tools/builtins/bash.js.map +1 -1
  84. package/dist/tools/builtins/index.d.ts +1 -0
  85. package/dist/tools/builtins/index.d.ts.map +1 -1
  86. package/dist/tools/builtins/index.js +1 -0
  87. package/dist/tools/builtins/index.js.map +1 -1
  88. package/dist/tools/builtins/job.d.ts.map +1 -1
  89. package/dist/tools/builtins/job.js +6 -2
  90. package/dist/tools/builtins/job.js.map +1 -1
  91. package/dist/tools/builtins/monitor.d.ts +58 -0
  92. package/dist/tools/builtins/monitor.d.ts.map +1 -0
  93. package/dist/tools/builtins/monitor.js +306 -0
  94. package/dist/tools/builtins/monitor.js.map +1 -0
  95. package/dist/tools/builtins/wait-for-job.d.ts +1 -1
  96. package/dist/turn/reporter.d.ts.map +1 -1
  97. package/dist/turn/reporter.js +8 -0
  98. package/dist/turn/reporter.js.map +1 -1
  99. package/dist/types/sandbox/index.d.ts +10 -0
  100. package/dist/types/sandbox/index.d.ts.map +1 -1
  101. package/dist/types/sandbox/index.js.map +1 -1
  102. package/dist/types/session/events.d.ts +30 -4
  103. package/dist/types/session/events.d.ts.map +1 -1
  104. package/dist/types/session/events.js +6 -1
  105. package/dist/types/session/events.js.map +1 -1
  106. package/dist/types/session/records.d.ts +2 -2
  107. package/dist/types/session/records.d.ts.map +1 -1
  108. package/dist/types/session/records.js +3 -2
  109. package/dist/types/session/records.js.map +1 -1
  110. package/dist/types/tool/index.d.ts +60 -0
  111. package/dist/types/tool/index.d.ts.map +1 -1
  112. package/dist/types/tool/index.js.map +1 -1
  113. package/package.json +4 -2
  114. package/src/bridge/a2a/mapper.ts +3 -0
  115. package/src/bridge/sse/mapper.ts +18 -0
  116. package/src/connector/web/guarded-fetch.ts +41 -0
  117. package/src/contracts/session/api.ts +1 -0
  118. package/src/index.ts +23 -0
  119. package/src/process/kill-tree.ts +4 -2
  120. package/src/public-runtime.ts +4 -1
  121. package/src/public-tools.ts +14 -0
  122. package/src/runtime/jobs/awaited-jobs.ts +127 -5
  123. package/src/runtime/jobs/monitor-stream.ts +269 -0
  124. package/src/runtime/jobs/registry.ts +437 -23
  125. package/src/runtime/jobs/websocket-source.ts +438 -0
  126. package/src/runtime/query/executor.ts +15 -0
  127. package/src/runtime/query/index.ts +116 -3
  128. package/src/runtime/query/iteration/outstanding-work.ts +64 -3
  129. package/src/runtime/query/iteration/phases/context.ts +9 -0
  130. package/src/runtime/query/iteration/phases/tool-review.ts +21 -9
  131. package/src/runtime/query/release-turn.ts +33 -0
  132. package/src/runtime/query/steering.ts +170 -0
  133. package/src/runtime/query/tooling.ts +8 -0
  134. package/src/runtime/system-events.ts +17 -0
  135. package/src/tools/builtins/bash.ts +3 -2
  136. package/src/tools/builtins/index.ts +6 -0
  137. package/src/tools/builtins/job.ts +11 -2
  138. package/src/tools/builtins/monitor.ts +376 -0
  139. package/src/turn/reporter.ts +8 -0
  140. package/src/types/sandbox/index.ts +10 -0
  141. package/src/types/session/events.ts +31 -4
  142. package/src/types/session/records.ts +3 -2
  143. package/src/types/tool/index.ts +51 -1
@@ -0,0 +1,438 @@
1
+ import { lookup as dnsLookup } from 'node:dns/promises'
2
+ import { EventEmitter } from 'node:events'
3
+ import { isIP } from 'node:net'
4
+
5
+ import type { WebSocket as WebSocketClient } from 'ws'
6
+
7
+ import { isPrivateAddress } from '../../connector/web/guarded-fetch.js'
8
+ import { isolationOf } from '../../sandbox/isolation.js'
9
+ import type { Sandbox } from '../../types/sandbox/index.js'
10
+ import type { MonitorWebSocketTarget } from '../../types/tool/index.js'
11
+ import type { JobHost, JobSource } from './registry.js'
12
+
13
+ /**
14
+ * A WebSocket as a monitor's source.
15
+ *
16
+ * A command's stdout is not the only thing worth watching: some services push
17
+ * their events (a CI run's log stream, a server's change feed) and offer no
18
+ * command that prints them. This module turns such a connection into the same
19
+ * thing a command is to the registry: a process-shaped object that writes
20
+ * lines on stdout, can be asked to stop, and ends. Everything after that (the
21
+ * 200 ms batches, the rationing, the expiry, the stop, the untrusted frame the
22
+ * model reads it in) is the registry's and is shared with commands unchanged.
23
+ *
24
+ * What is different is where it runs and what it can reach. A command runs
25
+ * where the registry's spawn puts it, inside a sandbox if there is one; this
26
+ * connection is opened by the HOST process, outside any sandbox. So the
27
+ * address is checked before anything is sent, a sandbox that isolates the
28
+ * network refuses it unless it has said the host is allowed, and the tool
29
+ * asks a person first (see `tools/builtins/monitor.ts`).
30
+ */
31
+
32
+ /** The largest frame accepted; a bigger one ends the watch. Same as the reference. */
33
+ export const WEBSOCKET_MAX_PAYLOAD_BYTES = 1_048_576
34
+ export const WEBSOCKET_HANDSHAKE_TIMEOUT_MS = 15_000
35
+ /** After a polite close, how long a peer gets to answer before the socket is destroyed. */
36
+ export const WEBSOCKET_CLOSE_GRACE_MS = 1000
37
+ export const WEBSOCKET_URL_MAX_CHARS = 2048
38
+ export const WEBSOCKET_PROTOCOLS_MAX = 16
39
+ /** Longest close reason relayed; the protocol allows 123 bytes. */
40
+ const CLOSE_REASON_MAX_CHARS = 123
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Input
44
+ // ---------------------------------------------------------------------------
45
+
46
+ /** RFC 6455 subprotocol names are RFC 2616 tokens: no separators, no controls. */
47
+ const TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/
48
+
49
+ /** Why a URL cannot be a monitor's source, or undefined when it can. */
50
+ export function webSocketUrlProblem(raw: string): string | undefined {
51
+ if (raw.length > WEBSOCKET_URL_MAX_CHARS) {
52
+ return `The URL is longer than ${WEBSOCKET_URL_MAX_CHARS} characters.`
53
+ }
54
+ // ASCII without whitespace or controls: a model that wants a non-ASCII host
55
+ // writes it in punycode, and nothing here is left to the parser's leniency
56
+ // (it strips tabs and newlines silently).
57
+ if (!/^[\x21-\x7e]+$/.test(raw)) {
58
+ return 'The URL must be ASCII, with no spaces or control characters.'
59
+ }
60
+ let url: URL
61
+ try {
62
+ url = new URL(raw)
63
+ } catch {
64
+ return 'That is not a URL.'
65
+ }
66
+ if (url.protocol !== 'ws:' && url.protocol !== 'wss:') {
67
+ return 'The URL must start with ws:// or wss://.'
68
+ }
69
+ if (url.username !== '' || url.password !== '') {
70
+ return 'A URL with a user name or password is not opened.'
71
+ }
72
+ if (url.hash !== '') return 'A WebSocket URL cannot have a fragment (#...).'
73
+ if (url.hostname === '') return 'The URL has no host.'
74
+ return undefined
75
+ }
76
+
77
+ /** Why a subprotocol list is not valid, or undefined when it is. */
78
+ export function webSocketProtocolsProblem(protocols: readonly string[]): string | undefined {
79
+ if (protocols.length > WEBSOCKET_PROTOCOLS_MAX) {
80
+ return `At most ${WEBSOCKET_PROTOCOLS_MAX} subprotocols.`
81
+ }
82
+ for (const protocol of protocols) {
83
+ if (!TOKEN.test(protocol)) return `"${protocol}" is not a valid subprotocol name.`
84
+ }
85
+ if (new Set(protocols).size !== protocols.length) return 'Subprotocol names must be unique.'
86
+ return undefined
87
+ }
88
+
89
+ /** What a record and an approval call this source: the URL as given, which can carry no credentials. */
90
+ export function webSocketJobCommand(url: string): string {
91
+ return `websocket ${url}`
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Where it may connect
96
+ // ---------------------------------------------------------------------------
97
+
98
+ /** The address check refused, in words for the model. */
99
+ export class WebSocketRefusedError extends Error {
100
+ constructor(message: string) {
101
+ super(message)
102
+ this.name = 'WebSocketRefusedError'
103
+ }
104
+ }
105
+
106
+ export interface WebSocketVetOptions {
107
+ /**
108
+ * How a name becomes addresses. Defaults to `node:dns`. For tests only, and
109
+ * never reachable from a tool argument: the hook belongs to whoever builds
110
+ * the tool (`createMonitorTool`).
111
+ */
112
+ readonly resolve?: (hostname: string) => Promise<readonly string[]>
113
+ /**
114
+ * Allow addresses inside the host's own network. For tests only: a local
115
+ * server on 127.0.0.1 is the one honest reason to, and it is a decision the
116
+ * code that builds the tool makes, never the model.
117
+ */
118
+ readonly allowPrivateAddresses?: boolean
119
+ }
120
+
121
+ /** The host name of a WebSocket URL as a resolver or an address check wants it. */
122
+ export function webSocketHostname(raw: string): string {
123
+ return new URL(raw).hostname.toLowerCase().replace(/^\[|\]$/g, '')
124
+ }
125
+
126
+ /**
127
+ * Resolve the host and refuse unless EVERY address is outside the host's own
128
+ * network; return the addresses that were checked.
129
+ *
130
+ * Refusal covers a name that does not resolve, one that resolves to nothing,
131
+ * and one with any private address among its answers: a name that answers both
132
+ * a public and a private address is a name an attacker controls, and a
133
+ * connection may pick either. Failing to resolve is a refusal and never a
134
+ * pass: an empty list satisfies "no private address found" and the connection
135
+ * that followed would resolve the name again against whatever answered.
136
+ */
137
+ export async function vetWebSocketTarget(
138
+ raw: string,
139
+ options: WebSocketVetOptions = {},
140
+ ): Promise<readonly { readonly address: string; readonly family: 4 | 6 }[]> {
141
+ const hostname = webSocketHostname(raw)
142
+ let addresses: readonly string[]
143
+ const literal = isIP(hostname)
144
+ if (literal !== 0) {
145
+ addresses = [hostname]
146
+ } else {
147
+ const resolve =
148
+ options.resolve ??
149
+ (async (name: string) => (await dnsLookup(name, { all: true })).map((r) => r.address))
150
+ try {
151
+ addresses = await resolve(hostname)
152
+ } catch (err) {
153
+ throw new WebSocketRefusedError(
154
+ `Refused "${hostname}": it could not be resolved (${err instanceof Error ? err.message : String(err)}), so nothing here can say where it points.`,
155
+ )
156
+ }
157
+ if (addresses.length === 0) {
158
+ throw new WebSocketRefusedError(`Refused "${hostname}": it resolves to no addresses.`)
159
+ }
160
+ }
161
+ if (options.allowPrivateAddresses !== true) {
162
+ for (const address of addresses) {
163
+ if (isPrivateAddress(address)) {
164
+ throw new WebSocketRefusedError(
165
+ `Refused "${hostname}": it resolves to ${address}, inside this host's own network. A WebSocket monitor only connects to public addresses.`,
166
+ )
167
+ }
168
+ }
169
+ }
170
+ return addresses.map((address) => ({ address, family: isIP(address) === 6 ? 6 : 4 }))
171
+ }
172
+
173
+ /**
174
+ * A `lookup` for the socket that answers with the addresses that were checked
175
+ * and nothing else.
176
+ *
177
+ * This is what closes the gap `web_fetch` documents: there the address is
178
+ * checked, then the platform `fetch` resolves the name AGAIN, so a name that
179
+ * answers publicly the first time and privately the second (DNS rebinding)
180
+ * gets through. Here the connection never asks DNS: it is handed the checked
181
+ * addresses, so there is no second answer to differ. The URL still names the
182
+ * host, so TLS keeps its SNI and its certificate check on the hostname and not
183
+ * on an address.
184
+ */
185
+ export function pinnedLookup(
186
+ addresses: readonly { readonly address: string; readonly family: 4 | 6 }[],
187
+ ) {
188
+ return (
189
+ _hostname: string,
190
+ options: { all?: boolean; family?: number | string } | undefined,
191
+ callback: (
192
+ error: NodeJS.ErrnoException | null,
193
+ address: string | { address: string; family: number }[],
194
+ family?: number,
195
+ ) => void,
196
+ ): void => {
197
+ // A caller that wants one family gets that family's addresses; asking for
198
+ // one we hold none of is an error rather than a fall back to DNS.
199
+ const wanted = options?.family === 4 || options?.family === 6 ? options.family : undefined
200
+ const usable = wanted === undefined ? addresses : addresses.filter((a) => a.family === wanted)
201
+ const first = usable[0]
202
+ if (first === undefined) {
203
+ const error: NodeJS.ErrnoException = new Error('no vetted address of that family')
204
+ error.code = 'ENOTFOUND'
205
+ callback(error, '', 0)
206
+ return
207
+ }
208
+ if (options?.all === true) {
209
+ callback(
210
+ null,
211
+ usable.map((a) => ({ address: a.address, family: a.family })),
212
+ )
213
+ return
214
+ }
215
+ callback(null, first.address, first.family)
216
+ }
217
+ }
218
+
219
+ /** `api.example.com` is that host; `.example.com` is the domain and its subdomains. */
220
+ export function hostMatchesPolicy(hostname: string, allowedHosts: readonly string[]): boolean {
221
+ const host = hostname.toLowerCase().replace(/\.$/, '')
222
+ return allowedHosts.some((entry) => {
223
+ const allowed = entry.toLowerCase().replace(/\.$/, '')
224
+ if (allowed.startsWith('.')) return host === allowed.slice(1) || host.endsWith(allowed)
225
+ return host === allowed
226
+ })
227
+ }
228
+
229
+ /**
230
+ * Why a sandbox forbids this connection, or undefined when it does not.
231
+ *
232
+ * The connection is opened by the host process, outside the boundary, so a
233
+ * sandbox's network rules do not bind it. If a turn runs under a sandbox that
234
+ * isolates the network, letting the host connect would be a way out of that
235
+ * isolation, so it is refused unless the sandbox's live policy names the host.
236
+ * A sandbox that does not isolate the network says nothing about it, and the
237
+ * connection is allowed, subject to the address check and to review.
238
+ */
239
+ export function webSocketSandboxRefusal(
240
+ sandbox: Sandbox | undefined,
241
+ hostname: string,
242
+ ): string | undefined {
243
+ if (sandbox === undefined) return undefined
244
+ if (!isolationOf(sandbox.environment).network) return undefined
245
+ const allowed = sandbox.networkPolicy?.allowedHosts
246
+ if (allowed !== undefined && hostMatchesPolicy(hostname, allowed)) return undefined
247
+ return `A WebSocket monitor connects from the host, outside this turn's sandbox, which blocks the network. "${hostname}" is not on the sandbox's list of allowed hosts, so the connection was not opened. Allow the host in the sandbox's network policy, or watch the service from inside the sandbox with a command.`
248
+ }
249
+
250
+ // ---------------------------------------------------------------------------
251
+ // Notices
252
+ // ---------------------------------------------------------------------------
253
+
254
+ /** Server-written text, made one short line before it goes into a notice. */
255
+ function oneLine(text: string): string {
256
+ const line = Array.from(text, (character) => {
257
+ const code = character.charCodeAt(0)
258
+ return code < 0x20 || (code >= 0x7f && code <= 0x9f) ? ' ' : character
259
+ })
260
+ .join('')
261
+ .replace(/\s+/g, ' ')
262
+ .trim()
263
+ return line.length <= CLOSE_REASON_MAX_CHARS
264
+ ? line
265
+ : `${line.slice(0, CLOSE_REASON_MAX_CHARS)}...`
266
+ }
267
+
268
+ export const webSocketRejectedNotice = (status: number): string =>
269
+ `[WebSocket upgrade rejected: HTTP ${status}]`
270
+ export const webSocketErrorNotice = (message: string): string =>
271
+ `[WebSocket error: ${oneLine(message)}]`
272
+ export const webSocketClosedNotice = (code: number, reason: string): string => {
273
+ const why = oneLine(reason)
274
+ return why === '' ? `[WebSocket closed: ${code}]` : `[WebSocket closed: ${code} ${why}]`
275
+ }
276
+ export const webSocketDroppedNotice = (): string =>
277
+ `[Dropped a frame larger than ${WEBSOCKET_MAX_PAYLOAD_BYTES} bytes; closing]`
278
+
279
+ // ---------------------------------------------------------------------------
280
+ // The connection, as something the registry can hold
281
+ // ---------------------------------------------------------------------------
282
+
283
+ export interface WebSocketSourceOptions {
284
+ readonly target: MonitorWebSocketTarget
285
+ readonly host: JobHost
286
+ }
287
+
288
+ function frameBytes(data: Buffer | ArrayBuffer | Buffer[]): number {
289
+ if (Array.isArray(data)) return data.reduce((sum, part) => sum + part.length, 0)
290
+ return data instanceof ArrayBuffer ? data.byteLength : data.length
291
+ }
292
+
293
+ /**
294
+ * Open the connection and return it as a job's process.
295
+ *
296
+ * The socket library is loaded when the first monitor of this kind starts, so
297
+ * a session that never watches a WebSocket never pays for it.
298
+ */
299
+ export function connectWebSocketSource(options: WebSocketSourceOptions): JobSource {
300
+ const { target, host } = options
301
+ const stand = Object.assign(new EventEmitter(), {
302
+ pid: undefined,
303
+ stdout: new EventEmitter(),
304
+ stderr: new EventEmitter(),
305
+ })
306
+ let socket: WebSocketClient | undefined
307
+ let graceTimer: ReturnType<typeof setTimeout> | undefined
308
+ // Set once somebody asked the connection to stop (a kill, the expiry, the
309
+ // output rule). From then on the connection says nothing: the registry has
310
+ // already told the model why the watch ended, and nothing follows that.
311
+ let stopping = false
312
+ let opened = false
313
+ let rejected = false
314
+ let oversize = false
315
+ // The connection failed before it opened: the error notice was the last word.
316
+ let failedToOpen = false
317
+ let ended = false
318
+
319
+ const write = (text: string): void => {
320
+ if (stopping || ended) return
321
+ stand.stdout.emit('data', text)
322
+ }
323
+ const end = (exitCode: number): void => {
324
+ if (ended) return
325
+ ended = true
326
+ clearTimeout(graceTimer)
327
+ stand.emit('close', exitCode, null)
328
+ }
329
+
330
+ void import('ws')
331
+ .then(({ WebSocket }) => {
332
+ if (stopping) {
333
+ end(0)
334
+ return
335
+ }
336
+ const client = new WebSocket(
337
+ target.url,
338
+ target.protocols && target.protocols.length > 0 ? [...target.protocols] : undefined,
339
+ {
340
+ maxPayload: WEBSOCKET_MAX_PAYLOAD_BYTES,
341
+ // A redirect could lead to an address nobody checked.
342
+ followRedirects: false,
343
+ // A compressed frame inflates past the size cap before it is
344
+ // measured; refusing the extension leaves every frame as sent.
345
+ perMessageDeflate: false,
346
+ handshakeTimeout: WEBSOCKET_HANDSHAKE_TIMEOUT_MS,
347
+ // Only the vetted addresses; see `pinnedLookup`.
348
+ lookup: pinnedLookup(target.addresses),
349
+ },
350
+ )
351
+ socket = client
352
+ client.on('open', () => {
353
+ opened = true
354
+ })
355
+ client.on('unexpected-response', (_request, response) => {
356
+ if (stopping) return
357
+ rejected = true
358
+ response.resume()
359
+ host.notice(webSocketRejectedNotice(response.statusCode ?? 0), { final: true })
360
+ // With a listener for this event the library leaves the half-open
361
+ // request to us.
362
+ client.terminate()
363
+ })
364
+ client.on('error', (error: Error & { code?: string }) => {
365
+ if (stopping || rejected || oversize) return
366
+ if (error.code === 'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH') {
367
+ // The library knows a frame was too big and not how big: it stops
368
+ // reading at the length field.
369
+ oversize = true
370
+ host.notice(webSocketDroppedNotice(), { final: true })
371
+ client.terminate()
372
+ return
373
+ }
374
+ if (!opened) {
375
+ // It never opened, so there is no close to report after this:
376
+ // one notice says it failed, and it is the last.
377
+ failedToOpen = true
378
+ host.notice(webSocketErrorNotice(error.message), { final: true })
379
+ return
380
+ }
381
+ host.notice(webSocketErrorNotice(error.message))
382
+ })
383
+ client.on('message', (data, isBinary) => {
384
+ if (isBinary) {
385
+ write(`[binary frame, ${frameBytes(data)} bytes]\n`)
386
+ return
387
+ }
388
+ const text = Array.isArray(data)
389
+ ? Buffer.concat(data).toString('utf8')
390
+ : Buffer.from(data as Buffer).toString('utf8')
391
+ // Frames are not lines. A newline closes this one, so the next frame
392
+ // starts a fresh line and a frame without one is not glued to it.
393
+ write(text.endsWith('\n') ? text : `${text}\n`)
394
+ })
395
+ client.on('close', (code, reason) => {
396
+ const why = reason.toString('utf8')
397
+ if (!stopping && !rejected && !oversize && !failedToOpen) {
398
+ host.notice(webSocketClosedNotice(code, why), { final: true })
399
+ if (opened) host.connectionClosed(code, oneLine(why))
400
+ }
401
+ // A normal or going-away close is the stream ending; anything else,
402
+ // and a connection that never opened, is a failure.
403
+ end(opened && (code === 1000 || code === 1001 || code === 1005) ? 0 : 1)
404
+ })
405
+ })
406
+ .catch((error: unknown) => {
407
+ host.notice(webSocketErrorNotice(error instanceof Error ? error.message : String(error)), {
408
+ final: true,
409
+ })
410
+ end(1)
411
+ })
412
+
413
+ return {
414
+ child: stand,
415
+ // Not a process, so the registry's group kill has nothing to signal; this
416
+ // is how it is stopped. SIGTERM asks politely, SIGKILL (and the grace
417
+ // running out) destroys the socket.
418
+ kill(signal) {
419
+ if (ended) return
420
+ if (signal === 'SIGKILL') {
421
+ stopping = true
422
+ if (socket) socket.terminate()
423
+ else end(0)
424
+ return
425
+ }
426
+ if (stopping) return
427
+ stopping = true
428
+ if (!socket) return // the import resolves, sees `stopping`, and ends
429
+ if (socket.readyState === socket.OPEN) {
430
+ socket.close(1000)
431
+ graceTimer = setTimeout(() => socket?.terminate(), WEBSOCKET_CLOSE_GRACE_MS)
432
+ graceTimer.unref?.()
433
+ } else {
434
+ socket.terminate()
435
+ }
436
+ },
437
+ }
438
+ }
@@ -395,6 +395,15 @@ export interface ToolExecutorConfig {
395
395
  * recorder gets no hold rather than a marking call that goes nowhere.
396
396
  */
397
397
  onJobAwaited?: (id: string) => void
398
+ /** The longest a monitor may live in this turn; see `QueryParams.monitorTimeoutCapMs`. */
399
+ monitorTimeoutCapMs?: number
400
+ /**
401
+ * Where a monitor the turn owns itself is recorded, so the turn stays open
402
+ * for it. Passed only when the monitor's owner is the turn: a session-owned
403
+ * monitor is heard by the host, and holding the turn for it would delay the
404
+ * end of every turn for as long as the watch lives.
405
+ */
406
+ onMonitorStarted?: (id: string) => void
398
407
 
399
408
  /**
400
409
  * Where the `skill` tool reads from.
@@ -1948,6 +1957,12 @@ export class ToolExecutor {
1948
1957
  }
1949
1958
  : {}),
1950
1959
  ...(this.config.onJobAwaited ? { onAwaited: this.config.onJobAwaited } : {}),
1960
+ ...(this.config.monitorTimeoutCapMs !== undefined
1961
+ ? { monitorTimeoutCapMs: this.config.monitorTimeoutCapMs }
1962
+ : {}),
1963
+ ...(this.config.onMonitorStarted
1964
+ ? { onMonitorStarted: this.config.onMonitorStarted }
1965
+ : {}),
1951
1966
  },
1952
1967
  ),
1953
1968
  }
@@ -145,7 +145,13 @@ import {
145
145
  supersededByRecovery,
146
146
  } from './resume-pending.js'
147
147
  import { acquireSandbox } from './sandbox-lifecycle.js'
148
- import { SteeringBinding, type SteeringChannel, isOperatorUserMessage } from './steering.js'
148
+ import {
149
+ SteeringBinding,
150
+ type SteeringChannel,
151
+ formatMonitorEnd,
152
+ formatMonitorEvent,
153
+ isOperatorUserMessage,
154
+ } from './steering.js'
149
155
  import { ToolGrantSet } from './tool-grants.js'
150
156
  import { createToolPause } from './tool-pause.js'
151
157
  import { ToolingBootstrap } from './tooling.js'
@@ -280,6 +286,22 @@ export interface QueryParams {
280
286
  * mean the model saw it. A host can carry unacknowledged exits to its next turn.
281
287
  */
282
288
  onJobNoticeDelivered?: (jobIds: readonly string[]) => void
289
+ /**
290
+ * Called with monitor event ids only when their text enters model context:
291
+ * a tool result or a recorded runtime message. A `monitor_event` alone does
292
+ * not mean the model saw it, so a host that wakes an idle session for
293
+ * events must not treat one as delivered until this says so. The end of a
294
+ * monitor is acknowledged through {@link onJobNoticeDelivered}, with its
295
+ * job id, as any job's exit is.
296
+ */
297
+ onMonitorEventsDelivered?: (eventIds: readonly string[]) => void
298
+ /**
299
+ * The longest a monitor started in this turn may live, in milliseconds.
300
+ * Absent, the SDK ceiling (30 minutes). A host lowers it where nobody will
301
+ * be around for the rest, such as a single-shot run. Larger requests are
302
+ * clamped to it without an error.
303
+ */
304
+ monitorTimeoutCapMs?: number
283
305
 
284
306
  /**
285
307
  * What else goes in this turn's prompt.
@@ -1373,6 +1395,27 @@ export async function* query(params: QueryParams): AsyncGenerator<SessionEvent,
1373
1395
  // subscription that fills it below, because the wait-intent recorder needs
1374
1396
  // to ask whether its text has been read yet.
1375
1397
  const jobNotices = params.backgroundJobs ? new SteeringBinding() : undefined
1398
+ // What this turn's monitors said, in the system-event envelope. Its own
1399
+ // channel because the exit notices above are plain text with a plain ack,
1400
+ // and a monitor's text is untrusted process output with an ack of its own.
1401
+ const monitorNotices = params.backgroundJobs ? new SteeringBinding() : undefined
1402
+ const pendingMonitorEventIds = new Set<string>()
1403
+ const pendingMonitorEndIds = new Set<string>()
1404
+ const acknowledgeMonitorNotices = () => {
1405
+ const eventIds = [...pendingMonitorEventIds]
1406
+ const endIds = [...pendingMonitorEndIds]
1407
+ pendingMonitorEventIds.clear()
1408
+ pendingMonitorEndIds.clear()
1409
+ try {
1410
+ if (eventIds.length > 0) params.onMonitorEventsDelivered?.(eventIds)
1411
+ if (endIds.length > 0) params.onJobNoticeDelivered?.(endIds)
1412
+ } catch (error) {
1413
+ // A host observer cannot retract model-visible context or fail the turn.
1414
+ ctx.log.warn('A monitor notice observer failed', {
1415
+ 'exception.message': error instanceof Error ? error.message : String(error),
1416
+ })
1417
+ }
1418
+ }
1376
1419
  const pendingJobNoticeIds = new Set<string>()
1377
1420
  const acknowledgeJobNotices = () => {
1378
1421
  if (pendingJobNoticeIds.size === 0) return
@@ -1391,8 +1434,17 @@ export async function* query(params: QueryParams): AsyncGenerator<SessionEvent,
1391
1434
  // thing that can hold this turn open for a job. Built only where there is a
1392
1435
  // registry, so a host with no background mode carries no recorder and the
1393
1436
  // bound ref has no `markAwaited` to offer.
1437
+ // A turn that owns its jobs itself has no host listening for a monitor, so
1438
+ // it holds open for the monitors it started; a session-owned monitor is the
1439
+ // host's to hear (`AwaitedJobs`' `MonitorHold`).
1440
+ const turnOwnsJobs = params.backgroundJobOwner === undefined
1394
1441
  const awaitedJobs = params.backgroundJobs
1395
- ? new AwaitedJobs(params.backgroundJobs, jobOwner, () => jobNotices?.pending ?? false)
1442
+ ? new AwaitedJobs(
1443
+ params.backgroundJobs,
1444
+ jobOwner,
1445
+ () => jobNotices?.pending ?? false,
1446
+ turnOwnsJobs ? { unreadNotice: () => monitorNotices?.pending ?? false } : undefined,
1447
+ )
1396
1448
  : undefined
1397
1449
  awaitedJobs?.attach()
1398
1450
 
@@ -1424,6 +1476,10 @@ export async function* query(params: QueryParams): AsyncGenerator<SessionEvent,
1424
1476
  ...(params.backgroundJobs ? { backgroundJobs: params.backgroundJobs } : {}),
1425
1477
  ...(params.backgroundJobOwner ? { backgroundJobOwner: params.backgroundJobOwner } : {}),
1426
1478
  ...(awaitedJobs ? { onJobAwaited: (id: string) => awaitedJobs.expect(id) } : {}),
1479
+ ...(awaitedJobs ? { onMonitorStarted: (id: string) => awaitedJobs.expectMonitor(id) } : {}),
1480
+ ...(params.monitorTimeoutCapMs !== undefined
1481
+ ? { monitorTimeoutCapMs: params.monitorTimeoutCapMs }
1482
+ : {}),
1427
1483
  // The `skill` tool's registry. Threaded from the turn rather than
1428
1484
  // held by the tool, because a tool that reached for a module-level
1429
1485
  // registry would answer about whatever the last turn configured.
@@ -1493,8 +1549,31 @@ export async function* query(params: QueryParams): AsyncGenerator<SessionEvent,
1493
1549
  // result, and the host as an event — without either polling. Subscribed
1494
1550
  // for the owner the turn's jobs are bound to, so a session-owned job that
1495
1551
  // ends during this turn is reported here too.
1496
- const unsubscribeJobExits = params.backgroundJobs?.onExit((job) => {
1552
+ const unsubscribeExits = params.backgroundJobs?.onExit((job) => {
1497
1553
  if (job.owner !== jobOwner) return
1554
+ if (job.kind === 'monitor' && params.backgroundJobs) {
1555
+ // A monitor's end is told in the monitor's own words (the same
1556
+ // `describeMonitorEnd` the transcript and a single-shot run use) and
1557
+ // in the same envelope as its events, rather than as a generic exit.
1558
+ monitorNotices?.steer(formatMonitorEnd(job, params.backgroundJobs.monitorStats(job.id)))
1559
+ pendingMonitorEndIds.add(job.id)
1560
+ void eventTranslator
1561
+ .emitEvent({
1562
+ type: 'background_job_exited',
1563
+ turnId: ctx.turnId,
1564
+ jobId: job.id,
1565
+ command: job.command,
1566
+ status: job.status === 'killed' ? 'killed' : 'exited',
1567
+ ...(job.exitCode !== undefined ? { exitCode: job.exitCode } : {}),
1568
+ ...(job.signal ? { signal: job.signal } : {}),
1569
+ monitor: {
1570
+ description: job.description ?? '',
1571
+ ...(job.stopReason ? { stopReason: job.stopReason } : {}),
1572
+ },
1573
+ })
1574
+ .catch(() => {})
1575
+ return
1576
+ }
1498
1577
  const outcome =
1499
1578
  job.status === 'killed'
1500
1579
  ? 'was stopped'
@@ -1515,6 +1594,38 @@ export async function* query(params: QueryParams): AsyncGenerator<SessionEvent,
1515
1594
  })
1516
1595
  .catch(() => {})
1517
1596
  })
1597
+ // What a monitor prints reaches the model as an event on its next tool
1598
+ // result, and the host as `monitor_event`. Subscribed for the owner the
1599
+ // turn's jobs are bound to, like the exits above.
1600
+ const unsubscribeMonitorEvents = params.backgroundJobs?.onMonitorEvent((event) => {
1601
+ if (event.owner !== jobOwner) return
1602
+ let command = ''
1603
+ try {
1604
+ command = params.backgroundJobs?.get(event.jobId).command ?? ''
1605
+ } catch {
1606
+ // The record is gone; the event still stands without its command.
1607
+ }
1608
+ monitorNotices?.steer(formatMonitorEvent(event, command))
1609
+ pendingMonitorEventIds.add(event.id)
1610
+ void eventTranslator
1611
+ .emitEvent({
1612
+ type: 'monitor_event',
1613
+ turnId: ctx.turnId,
1614
+ jobId: event.jobId,
1615
+ eventId: event.id,
1616
+ description: event.description,
1617
+ kind: event.kind,
1618
+ text: event.text,
1619
+ })
1620
+ .catch(() => {})
1621
+ })
1622
+ const unsubscribeJobExits =
1623
+ unsubscribeExits || unsubscribeMonitorEvents
1624
+ ? () => {
1625
+ unsubscribeExits?.()
1626
+ unsubscribeMonitorEvents?.()
1627
+ }
1628
+ : undefined
1518
1629
  let workingStateManager: WorkingStateManager | undefined
1519
1630
  // Normalised once, defaults applied. A host that passes a partial
1520
1631
  // object — a strategy and a window, nothing else — used to reach the
@@ -1664,6 +1775,8 @@ export async function* query(params: QueryParams): AsyncGenerator<SessionEvent,
1664
1775
  ...(params.steering ? { steering: params.steering } : {}),
1665
1776
  ...(jobNotices ? { jobNotices } : {}),
1666
1777
  ...(jobNotices ? { onJobNoticeDelivered: acknowledgeJobNotices } : {}),
1778
+ ...(monitorNotices ? { monitorNotices } : {}),
1779
+ ...(monitorNotices ? { onMonitorNoticeDelivered: acknowledgeMonitorNotices } : {}),
1667
1780
  ...(awaitedJobs ? { awaitedJobs } : {}),
1668
1781
  checkpointMgr,
1669
1782
  planManager: ctx.planManager,