@namzu/sdk 52.3.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 (46) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/connector/web/guarded-fetch.d.ts.map +1 -1
  3. package/dist/connector/web/guarded-fetch.js +44 -0
  4. package/dist/connector/web/guarded-fetch.js.map +1 -1
  5. package/dist/process/kill-tree.d.ts +3 -2
  6. package/dist/process/kill-tree.d.ts.map +1 -1
  7. package/dist/process/kill-tree.js.map +1 -1
  8. package/dist/public-runtime.d.ts +1 -1
  9. package/dist/public-runtime.d.ts.map +1 -1
  10. package/dist/public-tools.d.ts +2 -1
  11. package/dist/public-tools.d.ts.map +1 -1
  12. package/dist/public-tools.js +1 -1
  13. package/dist/public-tools.js.map +1 -1
  14. package/dist/runtime/jobs/registry.d.ts +80 -2
  15. package/dist/runtime/jobs/registry.d.ts.map +1 -1
  16. package/dist/runtime/jobs/registry.js +70 -5
  17. package/dist/runtime/jobs/registry.js.map +1 -1
  18. package/dist/runtime/jobs/websocket-source.d.ts +120 -0
  19. package/dist/runtime/jobs/websocket-source.d.ts.map +1 -0
  20. package/dist/runtime/jobs/websocket-source.js +379 -0
  21. package/dist/runtime/jobs/websocket-source.js.map +1 -0
  22. package/dist/runtime/query/steering.d.ts +2 -2
  23. package/dist/runtime/query/steering.d.ts.map +1 -1
  24. package/dist/runtime/query/steering.js +16 -3
  25. package/dist/runtime/query/steering.js.map +1 -1
  26. package/dist/tools/builtins/monitor.d.ts +38 -4
  27. package/dist/tools/builtins/monitor.d.ts.map +1 -1
  28. package/dist/tools/builtins/monitor.js +236 -97
  29. package/dist/tools/builtins/monitor.js.map +1 -1
  30. package/dist/types/sandbox/index.d.ts +10 -0
  31. package/dist/types/sandbox/index.d.ts.map +1 -1
  32. package/dist/types/sandbox/index.js.map +1 -1
  33. package/dist/types/tool/index.d.ts +35 -0
  34. package/dist/types/tool/index.d.ts.map +1 -1
  35. package/dist/types/tool/index.js.map +1 -1
  36. package/package.json +4 -2
  37. package/src/connector/web/guarded-fetch.ts +41 -0
  38. package/src/process/kill-tree.ts +4 -2
  39. package/src/public-runtime.ts +1 -1
  40. package/src/public-tools.ts +3 -0
  41. package/src/runtime/jobs/registry.ts +162 -13
  42. package/src/runtime/jobs/websocket-source.ts +438 -0
  43. package/src/runtime/query/steering.ts +32 -6
  44. package/src/tools/builtins/monitor.ts +293 -123
  45. package/src/types/sandbox/index.ts +10 -0
  46. package/src/types/tool/index.ts +27 -0
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/types/tool/index.ts"],"names":[],"mappings":"AAqwCA,cAAc,aAAa,CAAA"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/types/tool/index.ts"],"names":[],"mappings":"AAgyCA,cAAc,aAAa,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@namzu/sdk",
3
- "version": "52.3.0",
3
+ "version": "52.4.0",
4
4
  "description": "Open-source AI kernel for TypeScript: runs an agent the way an operating system runs a process, with an identity and a budget, scheduled tool calls and checkpointed turns.",
5
5
  "license": "FSL-1.1-MIT",
6
6
  "engines": {
@@ -64,6 +64,7 @@
64
64
  "@types/brace-expansion": "^1.1.2",
65
65
  "@types/node": "^22.19.17",
66
66
  "@types/turndown": "^5.0.6",
67
+ "@types/ws": "8.18.1",
67
68
  "@vitest/coverage-v8": "^4.1.11",
68
69
  "fast-check": "^4.7.0",
69
70
  "jsonschema": "^1.5.0",
@@ -80,7 +81,8 @@
80
81
  "minimatch": "^9.0.9",
81
82
  "pica": "^10.0.3",
82
83
  "quickjs-emscripten-core": "0.32.0",
83
- "turndown": "^7.2.4"
84
+ "turndown": "^7.2.4",
85
+ "ws": "8.21.3"
84
86
  },
85
87
  "scripts": {
86
88
  "build": "tsc --build",
@@ -101,6 +101,9 @@ export function isPrivateAddress(address: string): boolean {
101
101
  if (a === 192 && b === 168) return true // private
102
102
  if (a === 169 && b === 254) return true // link-local, incl. cloud metadata
103
103
  if (a === 0) return true // "this network"
104
+ if (a === 100 && b >= 64 && b <= 127) return true // shared address space (CGNAT), 100.64.0.0/10
105
+ if (a === 198 && (b === 18 || b === 19)) return true // benchmarking, 198.18.0.0/15
106
+ if (a === 192 && b === 0 && parts[2] === 0) return true // IETF protocol assignments, 192.0.0.0/24
104
107
  if (a >= 224) return true // multicast and reserved
105
108
  return false
106
109
  }
@@ -123,8 +126,35 @@ export function isPrivateAddress(address: string): boolean {
123
126
  const low = Number.parseInt(mapped[2], 16)
124
127
  return isPrivateAddress(`${high >>> 8}.${high & 0xff}.${low >>> 8}.${low & 0xff}`)
125
128
  }
129
+ const hextets = expandHextets(canonical)
130
+ const embedded = (index: number): string => {
131
+ const high = hextets[index] ?? 0
132
+ const low = hextets[index + 1] ?? 0
133
+ return `${high >>> 8}.${high & 0xff}.${low >>> 8}.${low & 0xff}`
134
+ }
135
+ const zeros = (from: number, to: number): boolean =>
136
+ hextets.slice(from, to).every((hextet) => hextet === 0)
137
+ // IPv4-compatible, ::/96 (deprecated): `::7f00:1` is 127.0.0.1 for a
138
+ // stack that still honours it, and nothing public is addressed this way.
139
+ if (zeros(0, 6)) return true
140
+ // IPv4-translated, ::ffff:0:0/96 (SIIT): the IPv4 address is the last 32 bits.
141
+ if (zeros(0, 4) && hextets[4] === 0xffff && hextets[5] === 0)
142
+ return isPrivateAddress(embedded(6))
143
+ // NAT64 well-known prefix, 64:ff9b::/96: a translator turns it into the
144
+ // IPv4 address in the last 32 bits, so `64:ff9b::7f00:1` reaches
145
+ // 127.0.0.1 through it. Refused only when that address is private: a
146
+ // public one is what the prefix is for on an IPv6-only network.
147
+ if (hextets[0] === 0x64 && hextets[1] === 0xff9b && zeros(2, 6)) {
148
+ return isPrivateAddress(embedded(6))
149
+ }
150
+ // NAT64 local-use prefix, 64:ff9b:1::/48 (RFC 8215): a site's own
151
+ // translator, so the embedded address cannot be read from a fixed place.
152
+ if (hextets[0] === 0x64 && hextets[1] === 0xff9b && hextets[2] === 1) return true
153
+ // 6to4, 2002::/16: the IPv4 address is the next 32 bits.
154
+ if (hextets[0] === 0x2002) return isPrivateAddress(embedded(1))
126
155
  const firstHextet = Number.parseInt(canonical.split(':', 1)[0] ?? '0', 16)
127
156
  if ((firstHextet & 0xffc0) === 0xfe80) return true // link-local fe80::/10
157
+ if ((firstHextet & 0xffc0) === 0xfec0) return true // site-local fec0::/10 (deprecated)
128
158
  if ((firstHextet & 0xfe00) === 0xfc00) return true // unique-local fc00::/7
129
159
  if ((firstHextet & 0xff00) === 0xff00) return true // multicast ff00::/8
130
160
  return false
@@ -132,6 +162,17 @@ export function isPrivateAddress(address: string): boolean {
132
162
  return false
133
163
  }
134
164
 
165
+ /** A canonical IPv6 address (as `new URL` writes it, no dots) as its eight 16-bit groups. */
166
+ function expandHextets(canonical: string): number[] {
167
+ const [head = '', tail] = canonical.split('::')
168
+ const front = head === '' ? [] : head.split(':')
169
+ const back = tail === undefined || tail === '' ? [] : tail.split(':')
170
+ const fill = tail === undefined ? 0 : 8 - front.length - back.length
171
+ return [...front, ...new Array<string>(Math.max(0, fill)).fill('0'), ...back].map((group) =>
172
+ Number.parseInt(group, 16),
173
+ )
174
+ }
175
+
135
176
  export class GuardedFetchProvider implements WebFetchProvider {
136
177
  private readonly timeoutMs: number
137
178
  private readonly maxBytes: number
@@ -1,5 +1,4 @@
1
1
  import { execFileSync } from 'node:child_process'
2
- import type { spawn } from 'node:child_process'
3
2
 
4
3
  /**
5
4
  * Git Bash children that the Windows process tree cannot reach.
@@ -83,7 +82,10 @@ function taskkillPath(): string {
83
82
  * already-dead tree that second call is a deliberate no-op, the same as the
84
83
  * POSIX SIGKILL that follows a SIGTERM which already worked.
85
84
  */
86
- export function killTree(child: ReturnType<typeof spawn>, signal: NodeJS.Signals): void {
85
+ export function killTree(
86
+ child: { readonly pid?: number | undefined },
87
+ signal: NodeJS.Signals,
88
+ ): void {
87
89
  if (!child.pid) return
88
90
  try {
89
91
  if (process.platform === 'win32') {
@@ -1587,7 +1587,7 @@ export type {
1587
1587
  LoggerOptions,
1588
1588
  MutableLogSinkCounters,
1589
1589
  } from './utils/log/types.js'
1590
- export type { JobProcess } from './runtime/jobs/registry.js'
1590
+ export type { JobChild, JobHost, JobPipe, JobProcess, JobSource } from './runtime/jobs/registry.js'
1591
1591
  export { WORKING_STATE_MIME } from './connector/mcp/adapter.js'
1592
1592
 
1593
1593
  export {
@@ -92,9 +92,12 @@ export { LSP_TOOL_NAME, LspTool, getCodeNavigationTools } from './tools/builtins
92
92
  export {
93
93
  MONITOR_CANNOT_DETACH,
94
94
  MONITOR_TOOL_NAME,
95
+ MONITOR_WEBSOCKET_WEB_OFF,
95
96
  MonitorTool,
97
+ createMonitorTool,
96
98
  getMonitorTools,
97
99
  } from './tools/builtins/monitor.js'
100
+ export type { MonitorToolOptions } from './tools/builtins/monitor.js'
98
101
  export { GlobTool } from './tools/builtins/glob.js'
99
102
  export { GrepTool } from './tools/builtins/grep.js'
100
103
  // Reads, lists and stops what `bash run_in_background` starts. Ships in
@@ -9,6 +9,7 @@ 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'
13
14
  import { oneMetadataLine } from '../system-events.js'
14
15
  import { createBackgroundJobId } from './job-id.js'
@@ -19,6 +20,7 @@ import {
19
20
  expiryNotice,
20
21
  tooMuchOutputNotice,
21
22
  } from './monitor-stream.js'
23
+ import { connectWebSocketSource, webSocketJobCommand } from './websocket-source.js'
22
24
 
23
25
  /**
24
26
  * Work that outlives a tool call, owned by whoever started it.
@@ -82,6 +84,15 @@ export interface BackgroundJob {
82
84
  * runs and when the command ended on its own.
83
85
  */
84
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
85
96
  }
86
97
 
87
98
  /**
@@ -97,6 +108,8 @@ export interface MonitorEvent {
97
108
  readonly kind: 'output' | 'notice'
98
109
  readonly text: string
99
110
  readonly at: number
111
+ /** Set when the source is a WebSocket connection, whose text the server wrote. */
112
+ readonly transport?: 'websocket'
100
113
  }
101
114
 
102
115
  export interface BackgroundJobOutput {
@@ -118,6 +131,60 @@ export interface BackgroundJobOutput {
118
131
  readonly error?: string
119
132
  }
120
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
+
121
188
  /** A job's process, when something other than the registry starts it. */
122
189
  export interface JobProcess {
123
190
  readonly child: ReturnType<typeof spawn>
@@ -138,7 +205,7 @@ export interface StartJobParams {
138
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
139
206
  * expect stdin.
140
207
  */
141
- readonly spawn?: () => JobProcess
208
+ readonly spawn?: (host: JobHost) => JobProcess | JobSource
142
209
  /**
143
210
  * Output the process already wrote before the registry took it over, in
144
211
  * the order it was written. Used when a foreground command is moved to the
@@ -160,7 +227,12 @@ export interface StartJobParams {
160
227
  * `timeoutMs`. Both streams stay in the retained buffer, so `read` still
161
228
  * shows everything, stderr included.
162
229
  */
163
- readonly monitor?: { readonly description: string; readonly timeoutMs: number }
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
+ }
164
236
  }
165
237
 
166
238
  export interface BackgroundJobRegistryConfig {
@@ -241,7 +313,7 @@ export class UnknownBackgroundJobError extends Error {
241
313
 
242
314
  interface JobEntry {
243
315
  record: BackgroundJob
244
- child: ReturnType<typeof spawn>
316
+ child: JobChild
245
317
  /** The spawner's own kill, when it gave one. */
246
318
  killProcess?: (signal: NodeJS.Signals) => void
247
319
  terminateProcess?: (signal?: NodeJS.Signals) => Promise<void>
@@ -269,6 +341,8 @@ interface MonitorState {
269
341
  readonly timeoutMs: number
270
342
  seq: number
271
343
  expiryTimer?: ReturnType<typeof setTimeout>
344
+ /** A terminal notice went out: nothing more is delivered, from the source or from anywhere. */
345
+ sealed?: boolean
272
346
  }
273
347
 
274
348
  /** Whether any process of the group led by `pid` is still alive. */
@@ -343,6 +417,7 @@ export class BackgroundJobRegistry {
343
417
  kind,
344
418
  text,
345
419
  at: Date.now(),
420
+ ...(entry.record.transport ? { transport: entry.record.transport } : {}),
346
421
  }
347
422
  for (const listener of this.monitorListeners) {
348
423
  try {
@@ -432,8 +507,14 @@ export class BackgroundJobRegistry {
432
507
  // The same spawn as a foreground `bash` call, so the permission
433
508
  // rules' reading of the line holds for the job too, and so the two
434
509
  // cannot disagree about how a shell is started on a given platform.
435
- const started = params.spawn
436
- ? 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)
437
518
  : {
438
519
  child: spawnHostShell(params.command, {
439
520
  cwd: params.workingDirectory,
@@ -484,6 +565,7 @@ export class BackgroundJobRegistry {
484
565
  // follows is described as what it was and not as a stop
485
566
  // somebody asked for.
486
567
  entry.record = { ...entry.record, stopReason: 'too_much_output' }
568
+ if (entry.monitor) entry.monitor.sealed = true
487
569
  this.announceMonitorEvent(entry, 'notice', tooMuchOutputNotice(suppressed, overMs))
488
570
  void this.kill(id).catch(() => {
489
571
  // The retained record carries the failure (recoveryRequired).
@@ -507,6 +589,7 @@ export class BackgroundJobRegistry {
507
589
  kind: 'monitor' as const,
508
590
  description: monitor.description,
509
591
  expiresAt: startedAt + monitor.timeoutMs,
592
+ ...(params.monitor?.transport ? { transport: params.monitor.transport } : {}),
510
593
  }
511
594
  : {}),
512
595
  },
@@ -673,12 +756,31 @@ export class BackgroundJobRegistry {
673
756
  // Nothing follows a terminal notice. A command that ignores SIGTERM keeps
674
757
  // printing until the SIGKILL, and those lines would reach the model after
675
758
  // it was told the watch was over.
759
+ monitor.sealed = true
676
760
  monitor.stream.close()
677
761
  void this.kill(entry.record.id).catch(() => {
678
762
  // The retained record carries the failure (recoveryRequired).
679
763
  })
680
764
  }
681
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
+
682
784
  /**
683
785
  * Resolves once the job's process exists, or once it could not be
684
786
  * created (`error` then says why). Lets a caller report a start that
@@ -949,9 +1051,10 @@ export class BackgroundJobRegistry {
949
1051
  // out, and nothing the dying process prints afterwards does. (The expiry
950
1052
  // and output rules close it themselves, after their own notice.)
951
1053
  const closeStream = (): void => {
952
- if (!stoppedBy) return
953
- entry.monitor?.stream.flush()
954
- entry.monitor?.stream.close()
1054
+ if (!stoppedBy || !entry.monitor) return
1055
+ entry.monitor.stream.flush()
1056
+ entry.monitor.sealed = true
1057
+ entry.monitor.stream.close()
955
1058
  }
956
1059
  if (entry.terminateProcess) {
957
1060
  entry.killRequested = true
@@ -1095,6 +1198,8 @@ export function bindOwner(
1095
1198
  ...(monitor ? { monitor } : {}),
1096
1199
  })
1097
1200
  }
1201
+ const clampMonitorTimeout = (requested: number): number =>
1202
+ Math.min(requested, defaults.monitorTimeoutCapMs ?? MONITOR_TIMEOUT_CAP_MS)
1098
1203
  return {
1099
1204
  start: (params: { command: string; workingDirectory?: string }) => launch(params),
1100
1205
  // The same spawn, owner and cap as `start`, plus a deadline. The timeout
@@ -1106,14 +1211,35 @@ export function bindOwner(
1106
1211
  timeoutMs: number
1107
1212
  workingDirectory?: string
1108
1213
  }) => {
1109
- const timeoutMs = Math.min(
1110
- params.timeoutMs,
1111
- defaults.monitorTimeoutCapMs ?? MONITOR_TIMEOUT_CAP_MS,
1112
- )
1214
+ const timeoutMs = clampMonitorTimeout(params.timeoutMs)
1113
1215
  const job = launch(params, { description: params.description, timeoutMs })
1114
1216
  defaults.onMonitorStarted?.(job.id)
1115
1217
  return { job, timeoutMs }
1116
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({
1234
+ owner,
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' },
1239
+ })
1240
+ defaults.onMonitorStarted?.(job.id)
1241
+ return { job, timeoutMs }
1242
+ },
1117
1243
  // A foreground command that ran out of time, kept instead of killed.
1118
1244
  // The same owner and cap as `start`; the process is the one already
1119
1245
  // running, with what it has written so far.
@@ -1176,11 +1302,34 @@ export function bindOwner(
1176
1302
  * own, so the summary does not repeat it.
1177
1303
  */
1178
1304
  export function describeMonitorEnd(
1179
- job: Pick<BackgroundJob, 'description' | 'status' | 'exitCode' | 'signal' | 'error'>,
1305
+ job: Pick<
1306
+ BackgroundJob,
1307
+ | 'description'
1308
+ | 'status'
1309
+ | 'exitCode'
1310
+ | 'signal'
1311
+ | 'error'
1312
+ | 'transport'
1313
+ | 'closeCode'
1314
+ | 'closeReason'
1315
+ >,
1180
1316
  stats: Pick<MonitorStreamStats, 'stdoutChars'>,
1181
1317
  ): { status: 'completed' | 'failed' | 'killed'; summary: string } {
1182
1318
  const label = `Monitor "${oneMetadataLine(job.description ?? 'unnamed')}"`
1183
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
+ }
1184
1333
  if (job.exitCode === 0 && job.error === undefined) {
1185
1334
  return stats.stdoutChars > 0
1186
1335
  ? { status: 'completed', summary: `${label} stream ended` }