@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
@@ -9,8 +9,18 @@ import type {
9
9
  BackgroundJobOutputWaitOptions,
10
10
  BackgroundJobOutputWaitResult,
11
11
  } from '../../types/job/index.js'
12
+ import type { MonitorWebSocketTarget } from '../../types/tool/index.js'
12
13
  import { subscribeToAbort } from '../../utils/abort.js'
14
+ import { oneMetadataLine } from '../system-events.js'
13
15
  import { createBackgroundJobId } from './job-id.js'
16
+ import {
17
+ MONITOR_TIMEOUT_CAP_MS,
18
+ type MonitorStreamStats,
19
+ createMonitorStream,
20
+ expiryNotice,
21
+ tooMuchOutputNotice,
22
+ } from './monitor-stream.js'
23
+ import { connectWebSocketSource, webSocketJobCommand } from './websocket-source.js'
14
24
 
15
25
  /**
16
26
  * Work that outlives a tool call, owned by whoever started it.
@@ -57,6 +67,49 @@ export interface BackgroundJob {
57
67
  readonly recoveryRequired?: boolean
58
68
  /** Safe registry diagnostic for an unconfirmed stop; excludes transport secrets. */
59
69
  readonly stopError?: string
70
+ /**
71
+ * `monitor` for a job started by the `monitor` tool: its stdout is
72
+ * delivered line by line as events, and it expires. Absent for every
73
+ * other job.
74
+ */
75
+ readonly kind?: 'monitor'
76
+ /** What the monitor watches, in the model's words; shown with every event. */
77
+ readonly description?: string
78
+ /** When the monitor will be stopped if the command has not ended first (epoch ms). */
79
+ readonly expiresAt?: number
80
+ /**
81
+ * Why a monitor was stopped, when the registry stopped it: `expired` at
82
+ * its deadline, `too_much_output` when the source outran the event
83
+ * ration, `stopped` for a `kill()` asked for from outside. Absent while it
84
+ * runs and when the command ended on its own.
85
+ */
86
+ readonly stopReason?: 'expired' | 'too_much_output' | 'stopped'
87
+ /**
88
+ * What the monitor's source is, when it is not a command: `websocket` for a
89
+ * monitor of a WebSocket connection, whose `command` is then
90
+ * `websocket <url>` and which has no exit code of a process.
91
+ */
92
+ readonly transport?: 'websocket'
93
+ /** The close code and reason the server ended a WebSocket connection with, if it did. */
94
+ readonly closeCode?: number
95
+ readonly closeReason?: string
96
+ }
97
+
98
+ /**
99
+ * One thing a monitor has to say: a batch of its stdout lines, or a notice
100
+ * from the monitor itself (events suppressed, stopped, expired).
101
+ */
102
+ export interface MonitorEvent {
103
+ /** `${jobId}:${seq}`, unique per registry and stable, so a host can acknowledge it. */
104
+ readonly id: string
105
+ readonly jobId: string
106
+ readonly owner: string
107
+ readonly description: string
108
+ readonly kind: 'output' | 'notice'
109
+ readonly text: string
110
+ readonly at: number
111
+ /** Set when the source is a WebSocket connection, whose text the server wrote. */
112
+ readonly transport?: 'websocket'
60
113
  }
61
114
 
62
115
  export interface BackgroundJobOutput {
@@ -78,6 +131,60 @@ export interface BackgroundJobOutput {
78
131
  readonly error?: string
79
132
  }
80
133
 
134
+ /**
135
+ * What a `spawn` that is not a child process hands the registry: the few
136
+ * members of {@link JobChild} and how to stop it. A {@link JobProcess} is one
137
+ * too, so a `spawn` may return either.
138
+ */
139
+ export interface JobSource {
140
+ readonly child: JobChild
141
+ /** How to stop it, when the registry's process-group kill would not reach everything. */
142
+ kill?(signal: NodeJS.Signals): void
143
+ /** Confirm the owned tree stopped; reject instead of claiming an unconfirmed exit. */
144
+ terminate?(signal?: NodeJS.Signals): Promise<void>
145
+ }
146
+
147
+ /**
148
+ * What the registry reads of a job's process, and nothing more.
149
+ *
150
+ * A `ChildProcess` satisfies it, and so does anything else that writes bytes
151
+ * on two pipes and ends: a WebSocket connection held as a monitor's source is
152
+ * such a thing. Named so that the registry's contract is the few members it
153
+ * uses and not a type from `node:child_process` it would have to be cast to.
154
+ */
155
+ export interface JobChild {
156
+ readonly pid?: number | undefined
157
+ readonly stdout?: JobPipe | null
158
+ readonly stderr?: JobPipe | null
159
+ once(event: 'spawn', listener: () => void): unknown
160
+ on(event: 'error', listener: (error: Error) => void): unknown
161
+ once(
162
+ event: 'close',
163
+ listener: (code: number | null, signal: NodeJS.Signals | null) => void,
164
+ ): unknown
165
+ }
166
+
167
+ /** A readable stream, as far as the registry reads one. */
168
+ export interface JobPipe {
169
+ on(event: 'data', listener: (data: Buffer | string) => void): unknown
170
+ }
171
+
172
+ /**
173
+ * What a process can say to the registry besides its output, handed to the
174
+ * `spawn` that asked for it.
175
+ */
176
+ export interface JobHost {
177
+ /**
178
+ * Something the SOURCE has to say, delivered to a monitor's listeners as a
179
+ * `notice` event and never rationed as output is. `final` ends the stream:
180
+ * what is pending goes out first, and nothing the process writes after the
181
+ * notice is delivered. Ignored for a job that is not a monitor.
182
+ */
183
+ notice(text: string, options?: { readonly final?: boolean }): void
184
+ /** The server closed a WebSocket connection with this code and reason. */
185
+ connectionClosed(code: number, reason: string): void
186
+ }
187
+
81
188
  /** A job's process, when something other than the registry starts it. */
82
189
  export interface JobProcess {
83
190
  readonly child: ReturnType<typeof spawn>
@@ -98,7 +205,7 @@ export interface StartJobParams {
98
205
  * host in the `bash` tool's shell (`tools/command-shell.ts`). The process must be the leader of its own group and must not
99
206
  * expect stdin.
100
207
  */
101
- readonly spawn?: () => JobProcess
208
+ readonly spawn?: (host: JobHost) => JobProcess | JobSource
102
209
  /**
103
210
  * Output the process already wrote before the registry took it over, in
104
211
  * the order it was written. Used when a foreground command is moved to the
@@ -114,6 +221,18 @@ export interface StartJobParams {
114
221
  * decoded the way a foreground command's are, not as a Linux guest's.
115
222
  */
116
223
  readonly hostOutput?: boolean
224
+ /**
225
+ * Run the command as a monitor: stdout is split into lines and delivered
226
+ * to `onMonitorEvent` listeners in batches, and the job is stopped at
227
+ * `timeoutMs`. Both streams stay in the retained buffer, so `read` still
228
+ * shows everything, stderr included.
229
+ */
230
+ readonly monitor?: {
231
+ readonly description: string
232
+ readonly timeoutMs: number
233
+ /** The source is a WebSocket connection and not a command; see {@link BackgroundJob.transport}. */
234
+ readonly transport?: 'websocket'
235
+ }
117
236
  }
118
237
 
119
238
  export interface BackgroundJobRegistryConfig {
@@ -194,7 +313,7 @@ export class UnknownBackgroundJobError extends Error {
194
313
 
195
314
  interface JobEntry {
196
315
  record: BackgroundJob
197
- child: ReturnType<typeof spawn>
316
+ child: JobChild
198
317
  /** The spawner's own kill, when it gave one. */
199
318
  killProcess?: (signal: NodeJS.Signals) => void
200
319
  terminateProcess?: (signal?: NodeJS.Signals) => Promise<void>
@@ -212,6 +331,18 @@ interface JobEntry {
212
331
  exit: Promise<void>
213
332
  /** Settles once the process exists or failed to; `error` says which. */
214
333
  started: Promise<{ readonly error?: string }>
334
+ /** Present for a monitor. */
335
+ monitor?: MonitorState
336
+ }
337
+
338
+ interface MonitorState {
339
+ readonly stream: ReturnType<typeof createMonitorStream>
340
+ readonly description: string
341
+ readonly timeoutMs: number
342
+ seq: number
343
+ expiryTimer?: ReturnType<typeof setTimeout>
344
+ /** A terminal notice went out: nothing more is delivered, from the source or from anywhere. */
345
+ sealed?: boolean
215
346
  }
216
347
 
217
348
  /** Whether any process of the group led by `pid` is still alive. */
@@ -230,6 +361,7 @@ const GROUP_POLL_MS = 250
230
361
  export class BackgroundJobRegistry {
231
362
  private readonly jobs = new Map<string, JobEntry>()
232
363
  private readonly exitListeners = new Set<(job: BackgroundJob) => void>()
364
+ private readonly monitorListeners = new Set<(event: MonitorEvent) => void>()
233
365
  private outputWaiters = 0
234
366
  private readonly outputWaitersByOwner = new Map<string, number>()
235
367
 
@@ -256,6 +388,53 @@ export class BackgroundJobRegistry {
256
388
  }
257
389
  }
258
390
 
391
+ /**
392
+ * Be told what a monitor has to say, whoever owns it: a batch of its
393
+ * stdout lines, or a notice from the monitor itself. Returns the
394
+ * unsubscribe.
395
+ *
396
+ * Every listener hears every monitor's events, whatever their `owner`: a
397
+ * consumer serving one turn or session MUST filter on `event.owner`, as the
398
+ * turn does. A listener that throws is its owner's problem, as in
399
+ * {@link onExit}.
400
+ */
401
+ onMonitorEvent(listener: (event: MonitorEvent) => void): () => void {
402
+ this.monitorListeners.add(listener)
403
+ return () => {
404
+ this.monitorListeners.delete(listener)
405
+ }
406
+ }
407
+
408
+ private announceMonitorEvent(entry: JobEntry, kind: MonitorEvent['kind'], text: string): void {
409
+ const monitor = entry.monitor
410
+ if (!monitor) return
411
+ monitor.seq += 1
412
+ const event: MonitorEvent = {
413
+ id: `${entry.record.id}:${monitor.seq}`,
414
+ jobId: entry.record.id,
415
+ owner: entry.record.owner,
416
+ description: monitor.description,
417
+ kind,
418
+ text,
419
+ at: Date.now(),
420
+ ...(entry.record.transport ? { transport: entry.record.transport } : {}),
421
+ }
422
+ for (const listener of this.monitorListeners) {
423
+ try {
424
+ listener(event)
425
+ } catch {
426
+ // A listener that throws is its owner's problem, not the job's.
427
+ }
428
+ }
429
+ }
430
+
431
+ /** What a monitor's stream has done so far, for the summary of how it ended. */
432
+ monitorStats(id: string): MonitorStreamStats {
433
+ const entry = this.jobs.get(id)
434
+ if (!entry) throw new UnknownBackgroundJobError({ id })
435
+ return entry.monitor?.stream.stats() ?? { eventsDelivered: 0, suppressed: 0, stdoutChars: 0 }
436
+ }
437
+
259
438
  /** A failed remote stop never releases ownership or announces a terminal job. */
260
439
  private confirmTermination(entry: JobEntry): Promise<void> {
261
440
  if (entry.terminationAttempt) return entry.terminationAttempt
@@ -328,8 +507,14 @@ export class BackgroundJobRegistry {
328
507
  // The same spawn as a foreground `bash` call, so the permission
329
508
  // rules' reading of the line holds for the job too, and so the two
330
509
  // cannot disagree about how a shell is started on a given platform.
331
- const started = params.spawn
332
- ? params.spawn()
510
+ const host: JobHost = {
511
+ notice: (text, options) => this.noticeFromSource(entry, text, options?.final === true),
512
+ connectionClosed: (code, reason) => {
513
+ entry.record = { ...entry.record, closeCode: code, closeReason: reason }
514
+ },
515
+ }
516
+ const started: JobProcess | JobSource = params.spawn
517
+ ? params.spawn(host)
333
518
  : {
334
519
  child: spawnHostShell(params.command, {
335
520
  cwd: params.workingDirectory,
@@ -364,6 +549,33 @@ export class BackgroundJobRegistry {
364
549
  // it may be remote, and need not emit `spawn` at all.
365
550
  if (params.spawn || child.pid !== undefined) settleStarted()
366
551
 
552
+ // A monitor's stdout goes through a stream that batches and rations it;
553
+ // everything it says reaches listeners as `MonitorEvent`s. The callbacks
554
+ // close over `entry`, which exists by the time any of them can run.
555
+ const monitor: MonitorState | undefined = params.monitor
556
+ ? {
557
+ description: params.monitor.description,
558
+ timeoutMs: params.monitor.timeoutMs,
559
+ seq: 0,
560
+ stream: createMonitorStream({
561
+ onBatch: (text) => this.announceMonitorEvent(entry, 'output', text),
562
+ onNotice: (text) => this.announceMonitorEvent(entry, 'notice', text),
563
+ onOverflow: ({ suppressed, overMs }) => {
564
+ // The reason is recorded before the kill, so the exit that
565
+ // follows is described as what it was and not as a stop
566
+ // somebody asked for.
567
+ entry.record = { ...entry.record, stopReason: 'too_much_output' }
568
+ if (entry.monitor) entry.monitor.sealed = true
569
+ this.announceMonitorEvent(entry, 'notice', tooMuchOutputNotice(suppressed, overMs))
570
+ void this.kill(id).catch(() => {
571
+ // The retained record carries the failure (recoveryRequired).
572
+ })
573
+ },
574
+ }),
575
+ }
576
+ : undefined
577
+ const startedAt = Date.now()
578
+
367
579
  let finalizeEntry!: (code: number | null, signal: NodeJS.Signals | null) => void
368
580
  const entry: JobEntry = {
369
581
  record: {
@@ -371,8 +583,17 @@ export class BackgroundJobRegistry {
371
583
  owner: params.owner,
372
584
  command: params.command,
373
585
  status: 'running',
374
- startedAt: Date.now(),
586
+ startedAt,
587
+ ...(monitor
588
+ ? {
589
+ kind: 'monitor' as const,
590
+ description: monitor.description,
591
+ expiresAt: startedAt + monitor.timeoutMs,
592
+ ...(params.monitor?.transport ? { transport: params.monitor.transport } : {}),
593
+ }
594
+ : {}),
375
595
  },
596
+ ...(monitor ? { monitor } : {}),
376
597
  child,
377
598
  ...(started.kill ? { killProcess: started.kill.bind(started) } : {}),
378
599
  ...(started.terminate ? { terminateProcess: started.terminate.bind(started) } : {}),
@@ -400,6 +621,15 @@ export class BackgroundJobRegistry {
400
621
  }
401
622
  resolve()
402
623
  for (const observer of entry.outputObservers) observer()
624
+ if (entry.monitor) {
625
+ // The last batch is a monitor's last words and must precede
626
+ // the exit: a listener that learns the job ended and THEN
627
+ // gets output would be telling the model about a finished
628
+ // watch after it had been told the watch was over.
629
+ clearTimeout(entry.monitor.expiryTimer)
630
+ entry.monitor.stream.drain()
631
+ entry.monitor.stream.close()
632
+ }
403
633
  this.announceExit(entry.record)
404
634
  }
405
635
  finalizeEntry = finalize
@@ -467,6 +697,10 @@ export class BackgroundJobRegistry {
467
697
  entry.buffer += text
468
698
  entry.buffer = outputTail(entry.buffer, this.maxBytes).text
469
699
  entry.outputChunks.push(chunk)
700
+ // Only stdout is an event. stderr stays readable with `read`, which is
701
+ // where a watcher's diagnostics belong: a model told about every
702
+ // warning line would stop reading the events that matter.
703
+ if (stream === 'stdout') entry.monitor?.stream.push(text)
470
704
  const retainedStart = entry.produced - Buffer.byteLength(entry.buffer)
471
705
  while (
472
706
  entry.outputChunks.length > MAX_CHANNEL_CHUNKS ||
@@ -497,9 +731,56 @@ export class BackgroundJobRegistry {
497
731
  child.stderr?.on('data', onData('stderr'))
498
732
 
499
733
  this.jobs.set(id, entry)
734
+ if (monitor) {
735
+ // A monitor has a life. Without a deadline a watch the model forgot
736
+ // would run, and bill its events, until the session ended.
737
+ monitor.expiryTimer = setTimeout(() => this.expireMonitor(entry), monitor.timeoutMs)
738
+ monitor.expiryTimer.unref?.()
739
+ }
500
740
  return entry.record
501
741
  }
502
742
 
743
+ /** The deadline: the last batch, then the notice, then the same kill every stop uses. */
744
+ private expireMonitor(entry: JobEntry): void {
745
+ const monitor = entry.monitor
746
+ if (!monitor || entry.record.status !== 'running') return
747
+ monitor.stream.flush()
748
+ // The flush can itself have stopped the monitor for too much output.
749
+ if (entry.record.stopReason !== undefined || entry.record.status !== 'running') return
750
+ entry.record = { ...entry.record, stopReason: 'expired' }
751
+ this.announceMonitorEvent(
752
+ entry,
753
+ 'notice',
754
+ expiryNotice(monitor.timeoutMs, monitor.stream.stats().eventsDelivered),
755
+ )
756
+ // Nothing follows a terminal notice. A command that ignores SIGTERM keeps
757
+ // printing until the SIGKILL, and those lines would reach the model after
758
+ // it was told the watch was over.
759
+ monitor.sealed = true
760
+ monitor.stream.close()
761
+ void this.kill(entry.record.id).catch(() => {
762
+ // The retained record carries the failure (recoveryRequired).
763
+ })
764
+ }
765
+
766
+ /**
767
+ * A notice from the source of a monitor (a WebSocket's upgrade was refused,
768
+ * its connection closed). Delivered after whatever was already complete, so
769
+ * the last lines are not overtaken by the news that the stream ended.
770
+ */
771
+ private noticeFromSource(entry: JobEntry, text: string, final: boolean): void {
772
+ const monitor = entry.monitor
773
+ if (!monitor || monitor.sealed) return
774
+ monitor.stream.flush()
775
+ // The flush can itself have stopped the monitor for too much output.
776
+ if (monitor.sealed) return
777
+ this.announceMonitorEvent(entry, 'notice', text)
778
+ if (final) {
779
+ monitor.sealed = true
780
+ monitor.stream.close()
781
+ }
782
+ }
783
+
503
784
  /**
504
785
  * Resolves once the job's process exists, or once it could not be
505
786
  * created (`error` then says why). Lets a caller report a start that
@@ -762,9 +1043,34 @@ export class BackgroundJobRegistry {
762
1043
  const entry = this.jobs.get(id)
763
1044
  if (!entry) throw new UnknownBackgroundJobError({ id })
764
1045
  if (entry.record.status !== 'running') return entry.record
1046
+ // A stop nobody else explained is the model's (or the operator's). The
1047
+ // expiry and output rules record their own reason first.
1048
+ const stoppedBy = entry.monitor !== undefined && entry.record.stopReason === undefined
1049
+ if (stoppedBy) entry.record = { ...entry.record, stopReason: 'stopped' }
1050
+ // An accepted stop ends the event stream: what was already complete goes
1051
+ // out, and nothing the dying process prints afterwards does. (The expiry
1052
+ // and output rules close it themselves, after their own notice.)
1053
+ const closeStream = (): void => {
1054
+ if (!stoppedBy || !entry.monitor) return
1055
+ entry.monitor.stream.flush()
1056
+ entry.monitor.sealed = true
1057
+ entry.monitor.stream.close()
1058
+ }
765
1059
  if (entry.terminateProcess) {
766
1060
  entry.killRequested = true
767
- await this.confirmTermination(entry)
1061
+ try {
1062
+ await this.confirmTermination(entry)
1063
+ } catch (error) {
1064
+ // Still running: do not leave it saying a stop happened.
1065
+ if (stoppedBy) {
1066
+ const { stopReason: _reason, ...rest } = entry.record
1067
+ entry.record = rest
1068
+ }
1069
+ throw error
1070
+ }
1071
+ // Only once the stop is confirmed: a remote stop that failed leaves a
1072
+ // running monitor, and a closed stream could not be reopened.
1073
+ closeStream()
768
1074
  await entry.exit
769
1075
  return entry.record
770
1076
  }
@@ -773,6 +1079,7 @@ export class BackgroundJobRegistry {
773
1079
  // tell a kill from an ordinary exit. Set it after and the race decides
774
1080
  // which of two different answers a reader gets.
775
1081
  entry.record = { ...entry.record, status: 'killed' }
1082
+ closeStream()
776
1083
  for (const observer of entry.outputObservers) observer()
777
1084
  const signal = (sig: NodeJS.Signals) =>
778
1085
  entry.killProcess ? entry.killProcess(sig) : killTree(entry.child, sig)
@@ -847,6 +1154,19 @@ export function bindOwner(
847
1154
  * than present and silently doing nothing.
848
1155
  */
849
1156
  readonly onAwaited?: (id: string) => void
1157
+ /**
1158
+ * The longest a monitor started through this binding may live. A host
1159
+ * may lower it (a single-shot run has no tomorrow to watch for); the
1160
+ * default is {@link MONITOR_TIMEOUT_CAP_MS}.
1161
+ */
1162
+ readonly monitorTimeoutCapMs?: number
1163
+ /**
1164
+ * Be told that this binding started a monitor. The turn passes its own
1165
+ * recorder here when the monitor belongs to the turn itself, which is
1166
+ * what makes the turn stay open for it; a session-owned monitor is not
1167
+ * reported, because the host wakes on its events.
1168
+ */
1169
+ readonly onMonitorStarted?: (id: string) => void
850
1170
  } = {},
851
1171
  ) {
852
1172
  const mine = (id: string): BackgroundJob => {
@@ -854,26 +1174,71 @@ export function bindOwner(
854
1174
  if (job.owner !== owner) throw new UnknownBackgroundJobError({ id })
855
1175
  return job
856
1176
  }
1177
+ const launch = (
1178
+ params: { command: string; workingDirectory?: string },
1179
+ monitor?: StartJobParams['monitor'],
1180
+ ): BackgroundJob => {
1181
+ const workingDirectory = params.workingDirectory ?? defaults.workingDirectory ?? process.cwd()
1182
+ const spawnHost = defaults.spawn
1183
+ return registry.start({
1184
+ owner,
1185
+ command: params.command,
1186
+ workingDirectory,
1187
+ ...(defaults.env ? { env: defaults.env } : {}),
1188
+ ...(spawnHost
1189
+ ? {
1190
+ spawn: () =>
1191
+ spawnHost({
1192
+ command: params.command,
1193
+ workingDirectory,
1194
+ ...(defaults.env ? { env: defaults.env } : {}),
1195
+ }),
1196
+ }
1197
+ : {}),
1198
+ ...(monitor ? { monitor } : {}),
1199
+ })
1200
+ }
1201
+ const clampMonitorTimeout = (requested: number): number =>
1202
+ Math.min(requested, defaults.monitorTimeoutCapMs ?? MONITOR_TIMEOUT_CAP_MS)
857
1203
  return {
858
- start: (params: { command: string; workingDirectory?: string }) => {
859
- const workingDirectory = params.workingDirectory ?? defaults.workingDirectory ?? process.cwd()
860
- const spawnHost = defaults.spawn
861
- return registry.start({
1204
+ start: (params: { command: string; workingDirectory?: string }) => launch(params),
1205
+ // The same spawn, owner and cap as `start`, plus a deadline. The timeout
1206
+ // is clamped to the host's ceiling and the clamped value is returned, so
1207
+ // the number the model is told is the number that will fire.
1208
+ startMonitor: (params: {
1209
+ command: string
1210
+ description: string
1211
+ timeoutMs: number
1212
+ workingDirectory?: string
1213
+ }) => {
1214
+ const timeoutMs = clampMonitorTimeout(params.timeoutMs)
1215
+ const job = launch(params, { description: params.description, timeoutMs })
1216
+ defaults.onMonitorStarted?.(job.id)
1217
+ return { job, timeoutMs }
1218
+ },
1219
+ // A monitor whose source is a WebSocket connection the HOST process
1220
+ // opens, whatever the sandbox spawn would have done with a command: the
1221
+ // tool has already checked the address and the sandbox's network rule,
1222
+ // and the socket is pinned to the addresses it checked. A separate
1223
+ // method so a host that implements `startMonitor` for commands is not
1224
+ // handed an input it never wrote code for.
1225
+ startWebSocketMonitor: (params: {
1226
+ description: string
1227
+ timeoutMs: number
1228
+ workingDirectory?: string
1229
+ webSocket: MonitorWebSocketTarget
1230
+ }) => {
1231
+ const timeoutMs = clampMonitorTimeout(params.timeoutMs)
1232
+ const { webSocket } = params
1233
+ const job = registry.start({
862
1234
  owner,
863
- command: params.command,
864
- workingDirectory,
865
- ...(defaults.env ? { env: defaults.env } : {}),
866
- ...(spawnHost
867
- ? {
868
- spawn: () =>
869
- spawnHost({
870
- command: params.command,
871
- workingDirectory,
872
- ...(defaults.env ? { env: defaults.env } : {}),
873
- }),
874
- }
875
- : {}),
1235
+ command: webSocketJobCommand(webSocket.url),
1236
+ workingDirectory: params.workingDirectory ?? defaults.workingDirectory ?? process.cwd(),
1237
+ spawn: (host) => connectWebSocketSource({ target: webSocket, host }),
1238
+ monitor: { description: params.description, timeoutMs, transport: 'websocket' },
876
1239
  })
1240
+ defaults.onMonitorStarted?.(job.id)
1241
+ return { job, timeoutMs }
877
1242
  },
878
1243
  // A foreground command that ran out of time, kept instead of killed.
879
1244
  // The same owner and cap as `start`; the process is the one already
@@ -924,3 +1289,52 @@ export function bindOwner(
924
1289
  list: () => registry.list(owner),
925
1290
  }
926
1291
  }
1292
+
1293
+ /**
1294
+ * How a monitor ended, in the words the model, the transcript and a
1295
+ * single-shot run all use, so the three cannot disagree about it.
1296
+ *
1297
+ * `completed` is the command ending by itself: with output it is the stream
1298
+ * ending, and without any it is a watch that never saw anything, which a
1299
+ * reader must not mistake for a quiet success. A non-zero exit is `failed`.
1300
+ * `killed` covers every stop the registry or the model asked for; the expiry
1301
+ * and the too-much-output stop already told the model why in a notice of their
1302
+ * own, so the summary does not repeat it.
1303
+ */
1304
+ export function describeMonitorEnd(
1305
+ job: Pick<
1306
+ BackgroundJob,
1307
+ | 'description'
1308
+ | 'status'
1309
+ | 'exitCode'
1310
+ | 'signal'
1311
+ | 'error'
1312
+ | 'transport'
1313
+ | 'closeCode'
1314
+ | 'closeReason'
1315
+ >,
1316
+ stats: Pick<MonitorStreamStats, 'stdoutChars'>,
1317
+ ): { status: 'completed' | 'failed' | 'killed'; summary: string } {
1318
+ const label = `Monitor "${oneMetadataLine(job.description ?? 'unnamed')}"`
1319
+ if (job.status === 'killed') return { status: 'killed', summary: `${label} stopped` }
1320
+ if (job.transport === 'websocket') {
1321
+ // A connection has no script to fail and no exit status to quote: it was
1322
+ // closed, by the server and with a code, or it never opened.
1323
+ if (job.closeCode === undefined)
1324
+ return { status: 'failed', summary: `${label} connection failed` }
1325
+ // The code only. The server's reason is text it chose, and the summary
1326
+ // is a metadata line outside the untrusted frame; the reason reaches the
1327
+ // model inside the frame, in the `[WebSocket closed: ...]` notice.
1328
+ return {
1329
+ status: job.exitCode === 0 ? 'completed' : 'failed',
1330
+ summary: `${label} connection closed (${job.closeCode})`,
1331
+ }
1332
+ }
1333
+ if (job.exitCode === 0 && job.error === undefined) {
1334
+ return stats.stdoutChars > 0
1335
+ ? { status: 'completed', summary: `${label} stream ended` }
1336
+ : { status: 'completed', summary: `${label} ended without producing output (exit 0)` }
1337
+ }
1338
+ const how = job.exitCode !== undefined ? `exit ${job.exitCode}` : (job.signal ?? 'exit unknown')
1339
+ return { status: 'failed', summary: `${label} script failed (${how})` }
1340
+ }