dsh-computers 1.1.0 → 1.3.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.
package/lib/hub.js CHANGED
@@ -1,17 +1,22 @@
1
1
  /**
2
2
  * The live side of dsh-computers: one SSE stream per computer and the exec
3
- * dispatcher that rides it (PROTOCOL §3.2, §3.3, §5.2).
3
+ * dispatcher that rides it (PROTOCOL §3.2, §3.3, §11.2, §11.6).
4
4
  *
5
5
  * Invariants this file keeps:
6
6
  * - at most one stream per computer; opening a new one ends the old one and
7
7
  * fails the old agent's in-flight executions;
8
8
  * - `online` is the live stream and nothing else; `lastSeenAt` is display data
9
9
  * refreshed by stream open / heartbeat / result POST;
10
- * - every exec has a unique `req_…` id, every promise settles exactly once —
11
- * a result POST, the timeout, the abort signal, the stream closing or the
12
- * plugin disposing;
13
- * - a late result for an already settled id is not accepted (the route answers
14
- * 404).
10
+ * - every exec has a unique `req_…` id and settles exactly once — a result
11
+ * POST, the silence timeout, the abort signal, a job cancel, the stream
12
+ * closing or the plugin disposing;
13
+ * - streaming frames are deduped/reassembled by `seq` and a `heartbeat` frame
14
+ * resets the execution's silence timer (it carries no output);
15
+ * - a foreground timeout can be *adopted* by a job instead of killing the
16
+ * command (`promoteOnTimeout`), after which chunks keep flowing into the job
17
+ * ring and the model controls it with job_output/job_kill;
18
+ * - a late result for an already settled id is not accepted (the route
19
+ * answers 404).
15
20
  *
16
21
  * Node builtins only.
17
22
  */
@@ -21,7 +26,7 @@ import { newRequestId } from './keys.js'
21
26
  /** Failure carrying a machine-readable code the tool layer maps to a message. */
22
27
  export class HubError extends Error {
23
28
  /**
24
- * @param {string} code - `offline` | `timeout` | `aborted` | `disposed`.
29
+ * @param {string} code - `offline` | `timeout` | `aborted` | `invalid` | `disposed`.
25
30
  * @param {string} message - model-facing detail.
26
31
  */
27
32
  constructor(code, message) {
@@ -39,6 +44,12 @@ const SSE_HEADERS = {
39
44
  'x-accel-buffering': 'no',
40
45
  }
41
46
 
47
+ /** Host features advertised in `hello` (PROTOCOL §11.1/§11.5). */
48
+ export const HOST_FEATURES = Object.freeze(['stream', 'fs'])
49
+
50
+ /** How long a killed execution may take to report its final result before the host settles it locally. */
51
+ const KILL_SETTLE_MS = 5_000
52
+
42
53
  /**
43
54
  * Trim UTF-8 text to a byte budget without cutting a character in half.
44
55
  * @param {string} text - input text.
@@ -50,17 +61,25 @@ function capText(text, maxBytes) {
50
61
  const bytes = Buffer.byteLength(text, 'utf8')
51
62
  if (bytes <= maxBytes) return { text, truncated: false }
52
63
  let out = Buffer.from(text, 'utf8').subarray(0, maxBytes).toString('utf8')
53
- // A multi-byte character split at the boundary decodes to U+FFFD; drop only
54
- // the trailing replacement characters this slice may have produced.
55
64
  while (out.endsWith('\uFFFD')) out = out.slice(0, -1)
56
65
  return { text: `${out}\n[output truncated]`, truncated: true }
57
66
  }
58
67
 
68
+ /** Decode one base64 frame payload; empty/absent decodes to ''. */
69
+ function decodeFrame(data) {
70
+ if (typeof data !== 'string' || data === '') return ''
71
+ try {
72
+ return Buffer.from(data, 'base64').toString('utf8')
73
+ } catch {
74
+ return ''
75
+ }
76
+ }
77
+
59
78
  /**
60
79
  * Create the hub for one plugin mount.
61
80
  * @param {object} options - hub options.
62
81
  * @param {object} options.store - registry store (presence timestamps).
63
- * @param {number} options.heartbeatMs - SSE ping cadence reported to the agent.
82
+ * @param {number} options.heartbeatMs - ping cadence and presence window base.
64
83
  * @param {number} options.maxOutputBytes - per-stream result cap.
65
84
  * @param {(message: string) => void} [options.onWarn] - warning sink.
66
85
  * @returns {object} the hub.
@@ -85,11 +104,15 @@ export function createHub({ store, heartbeatMs, maxOutputBytes, onWarn = () => {
85
104
  }
86
105
  }
87
106
 
88
- /** Detach one pending execution's timer and abort listener (idempotent). */
107
+ /** Detach one pending execution's timers and abort listener (idempotent). */
89
108
  const detach = (entry) => {
90
- if (entry.timer !== null) {
91
- clearTimeout(entry.timer)
92
- entry.timer = null
109
+ if (entry.silenceTimer !== null) {
110
+ clearTimeout(entry.silenceTimer)
111
+ entry.silenceTimer = null
112
+ }
113
+ if (entry.graceTimer !== null) {
114
+ clearTimeout(entry.graceTimer)
115
+ entry.graceTimer = null
93
116
  }
94
117
  if (entry.signal !== undefined && entry.onAbort !== null) {
95
118
  entry.signal.removeEventListener('abort', entry.onAbort)
@@ -97,13 +120,91 @@ export function createHub({ store, heartbeatMs, maxOutputBytes, onWarn = () => {
97
120
  }
98
121
  }
99
122
 
100
- /** Reject one entry exactly once. */
123
+ /** Normalize one final result payload into the tool-facing shape. */
124
+ const normalizeResult = (entry, payload) => {
125
+ const stdout = capText(typeof payload.stdout === 'string' ? payload.stdout : entry.buffered.stdout, maxOutputBytes)
126
+ const stderr = capText(typeof payload.stderr === 'string' ? payload.stderr : entry.buffered.stderr, maxOutputBytes)
127
+ const exitCode = typeof payload.exitCode === 'number' && Number.isFinite(payload.exitCode)
128
+ ? Math.trunc(payload.exitCode)
129
+ : null
130
+ const duration = typeof payload.durationMs === 'number' && Number.isFinite(payload.durationMs) && payload.durationMs >= 0
131
+ ? Math.trunc(payload.durationMs)
132
+ : Date.now() - entry.startedAt
133
+ return {
134
+ requestId: entry.requestId,
135
+ ok: payload.ok === true,
136
+ exitCode,
137
+ signal: typeof payload.signal === 'string' ? payload.signal : null,
138
+ stdout: stdout.text,
139
+ stderr: stderr.text,
140
+ durationMs: duration,
141
+ truncated: payload.truncated === true || stdout.truncated || stderr.truncated,
142
+ timedOut: payload.timedOut === true,
143
+ error: typeof payload.error === 'string' ? payload.error : null,
144
+ killed: entry.killed === true,
145
+ }
146
+ }
147
+
148
+ /** Locally settle a killed execution that never reported a final result. */
149
+ const settleKilled = (entry) => {
150
+ if (entry.settled) return
151
+ const result = {
152
+ requestId: entry.requestId,
153
+ ok: false,
154
+ exitCode: null,
155
+ signal: 'SIGKILL',
156
+ stdout: entry.buffered.stdout,
157
+ stderr: entry.buffered.stderr,
158
+ durationMs: Date.now() - entry.startedAt,
159
+ truncated: false,
160
+ timedOut: false,
161
+ error: 'cancelled',
162
+ killed: true,
163
+ }
164
+ finish(entry, result)
165
+ }
166
+
167
+ /** Resolve/reject the entry exactly once, feeding the job ring when present. */
168
+ const finish = (entry, result) => {
169
+ if (entry.settled) return
170
+ entry.settled = true
171
+ detach(entry)
172
+ pending.delete(entry.requestId)
173
+ if (entry.job !== null) {
174
+ try {
175
+ entry.job.settle(result)
176
+ } catch (error) {
177
+ onWarn(`dsh-computers: job settle failed: ${error && error.message ? error.message : error}`)
178
+ }
179
+ }
180
+ if (entry.background === true) {
181
+ entry.resultResolve(result)
182
+ } else if (entry.promoted === true) {
183
+ // The foreground tool call already returned {jobId, promoted:true}.
184
+ } else {
185
+ entry.resolve(result)
186
+ }
187
+ }
188
+
189
+ /** Reject one entry exactly once (v1 failure paths). */
101
190
  const failEntry = (entry, error) => {
102
191
  if (entry.settled) return
103
192
  entry.settled = true
104
193
  detach(entry)
105
194
  pending.delete(entry.requestId)
106
- entry.reject(error)
195
+ if (entry.job !== null) {
196
+ try {
197
+ entry.job.failed(error)
198
+ } catch {
199
+ // Already settled by the job registry.
200
+ }
201
+ }
202
+ if (entry.background === true) entry.resultResolve({
203
+ requestId: entry.requestId, ok: false, exitCode: null, signal: null, stdout: entry.buffered.stdout,
204
+ stderr: entry.buffered.stderr, durationMs: Date.now() - entry.startedAt, truncated: false,
205
+ timedOut: false, error: error.message, killed: error.code === 'aborted',
206
+ })
207
+ else if (entry.promoted !== true) entry.reject(error)
107
208
  }
108
209
 
109
210
  /** Reject every execution belonging to one computer (stream gone/offline). */
@@ -129,22 +230,139 @@ export function createHub({ store, heartbeatMs, maxOutputBytes, onWarn = () => {
129
230
  }
130
231
  }
131
232
 
233
+ /** (Re)arm the silence timeout. Streaming heartbeats/chunks push it forward. */
234
+ const armSilenceTimer = (entry) => {
235
+ if (entry.timeoutMs <= 0) return
236
+ if (entry.silenceTimer !== null) clearTimeout(entry.silenceTimer)
237
+ entry.silenceTimer = setTimeout(() => {
238
+ entry.silenceTimer = null
239
+ if (entry.settled) return
240
+ // §11.6.3: adopt the running execution into a background job instead of
241
+ // killing it, when the tool layer offered a promotion adapter.
242
+ const adopt = typeof entry.onTimeout === 'function' ? entry.onTimeout(entry) : null
243
+ if (adopt !== null && adopt !== undefined) {
244
+ entry.job = adopt
245
+ entry.promoted = true
246
+ entry.resolve({ promoted: true, jobId: adopt.jobId, requestId: entry.requestId, computer: entry.computer, command: entry.command })
247
+ return
248
+ }
249
+ hub.send(entry.computerId, 'cancel', { requestId: entry.requestId })
250
+ failEntry(entry, new HubError('timeout',
251
+ `computer '${entry.label}' did not answer within ${entry.timeoutMs}ms (the command was cancelled)`))
252
+ }, entry.timeoutMs)
253
+ if (typeof entry.silenceTimer.unref === 'function') entry.silenceTimer.unref()
254
+ }
255
+
256
+ /** Append decoded incremental output to the execution tail and the job ring. */
257
+ const appendChunk = (entry, channel, text) => {
258
+ if (text === '') return
259
+ const budget = maxOutputBytes
260
+ const key = channel === 'stderr' ? 'stderr' : 'stdout'
261
+ if (entry.bufferedBytes[key] < budget) {
262
+ const room = budget - entry.bufferedBytes[key]
263
+ const piece = capText(text, room)
264
+ entry.buffered[key] += piece.text
265
+ entry.bufferedBytes[key] += Buffer.byteLength(piece.text, 'utf8')
266
+ }
267
+ if (entry.job !== null) {
268
+ try {
269
+ entry.job.append(key, text)
270
+ } catch {
271
+ // The ring rejects writes after settlement; nothing else to do.
272
+ }
273
+ }
274
+ }
275
+
276
+ /** Create one pending execution and send its `exec` event. */
277
+ const beginExecution = ({
278
+ computerId, name, command, cwd = null, timeoutMs, agentTimeoutMs = null, shell = 'sh', signal,
279
+ background = false, stream = true, job = null, onTimeout = null,
280
+ resolve = () => {}, reject = () => {},
281
+ }) => {
282
+ const label = typeof name === 'string' && name !== '' ? name : computerId
283
+ if (disposed) throw new HubError('disposed', 'the dsh-computers host plugin is shutting down')
284
+ if (!streams.has(computerId)) {
285
+ throw new HubError('offline', `computer '${label}' is offline; run computer_list to see what is connected`)
286
+ }
287
+ if (typeof command !== 'string' || command.trim() === '') {
288
+ throw new HubError('invalid', 'command must be a non-empty string')
289
+ }
290
+ const streamRef = streams.get(computerId)
291
+ if (streamRef === undefined) {
292
+ throw new HubError('offline', `computer '${label}' is offline; run computer_list to see what is connected`)
293
+ }
294
+ const requestId = newRequestId()
295
+ let resultResolve = () => {}
296
+ const entry = {
297
+ requestId,
298
+ computerId,
299
+ label,
300
+ command,
301
+ computer: null,
302
+ startedAt: Date.now(),
303
+ signal,
304
+ onAbort: null,
305
+ silenceTimer: null,
306
+ graceTimer: null,
307
+ settled: false,
308
+ killed: false,
309
+ promoted: false,
310
+ background,
311
+ stream,
312
+ job,
313
+ onTimeout,
314
+ timeoutMs,
315
+ expectedSeq: 0,
316
+ chunkMap: new Map(),
317
+ buffered: { stdout: '', stderr: '' },
318
+ bufferedBytes: { stdout: 0, stderr: 0 },
319
+ resultPromise: null,
320
+ resultResolve: () => {},
321
+ resolve,
322
+ reject,
323
+ }
324
+ entry.resultPromise = new Promise((resolve) => {
325
+ resultResolve = resolve
326
+ entry.resultResolve = resolve
327
+ })
328
+ entry.resultResolve = resultResolve
329
+ pending.set(requestId, entry)
330
+ armSilenceTimer(entry)
331
+
332
+ if (signal !== undefined) {
333
+ const abort = () => {
334
+ hub.send(computerId, 'cancel', { requestId })
335
+ failEntry(entry, new HubError('aborted', `computer_exec was aborted while running on '${label}'`))
336
+ }
337
+ if (signal.aborted) {
338
+ abort()
339
+ return { requestId, result: entry.resultPromise, entry }
340
+ }
341
+ entry.onAbort = abort
342
+ signal.addEventListener('abort', abort, { once: true })
343
+ }
344
+
345
+ const written = writeFrame(streamRef, 'exec', {
346
+ requestId,
347
+ command,
348
+ cwd: cwd ?? null,
349
+ // The HOST owns the foreground timeout (§11.6.3 promotes instead of
350
+ // killing), so the agent receives its own generous ceiling.
351
+ timeoutMs: agentTimeoutMs ?? timeoutMs,
352
+ shell,
353
+ stream,
354
+ })
355
+ if (!written) {
356
+ failEntry(entry, new HubError('offline', `computer '${label}' is offline; run computer_list to see what is connected`))
357
+ }
358
+ return { requestId, result: entry.resultPromise, entry }
359
+ }
360
+
132
361
  const hub = {
133
362
  /** Whether a computer currently holds an open SSE stream. */
134
363
  isLive: (computerId) => streams.has(computerId),
135
364
 
136
- /**
137
- * Presence is the live SSE stream and nothing else.
138
- *
139
- * `lastSeenAt` is still refreshed by stream open / heartbeat / result POST
140
- * and is shown in the UI, but a heartbeat without a stream must NOT read
141
- * as online: the settings page polls every 5 s, so a machine that died
142
- * while its heartbeat timestamp is still warm would show "在线" for up to
143
- * 2x heartbeatMs. (PROTOCOL.md §2 still describes that older fallback; the
144
- * live behaviour is stream-only and the protocol is being updated.)
145
- * @param {object} computer - registry record.
146
- * @returns {boolean} whether the computer holds an open stream.
147
- */
365
+ /** Presence used everywhere the browser/tools need "online". */
148
366
  isOnline(computer) {
149
367
  if (computer === null || typeof computer !== 'object') return false
150
368
  return streams.has(computer.id)
@@ -155,14 +373,14 @@ export function createHub({ store, heartbeatMs, maxOutputBytes, onWarn = () => {
155
373
 
156
374
  /**
157
375
  * Accept a new SSE stream for a computer, replacing any previous one.
158
- * The caller owns authentication and `lastSeenAt`; the hub owns framing.
159
376
  * @param {string} computerId - authenticated computer.
160
377
  * @param {string} name - display name for the `hello` event.
161
378
  * @param {import('node:http').IncomingMessage} req - request (for close hooks).
162
379
  * @param {import('node:http').ServerResponse} res - response to hold open.
380
+ * @param {{ capabilities?: string[] }} [handshake] - metadata to echo in `hello`.
163
381
  * @returns {{ replaced: boolean, stream: object }} the opened stream.
164
382
  */
165
- openStream(computerId, name, req, res) {
383
+ openStream(computerId, name, req, res, handshake = {}) {
166
384
  if (disposed) throw new HubError('disposed', 'the dsh-computers host plugin is shutting down')
167
385
  const previous = streams.get(computerId)
168
386
  let replaced = false
@@ -174,8 +392,6 @@ export function createHub({ store, heartbeatMs, maxOutputBytes, onWarn = () => {
174
392
  }
175
393
 
176
394
  res.writeHead(200, SSE_HEADERS)
177
- // Flush headers immediately: an agent must see the stream accepted
178
- // before it decides the connection failed.
179
395
  if (typeof res.flushHeaders === 'function') res.flushHeaders()
180
396
  res.write('retry: 2000\n\n')
181
397
  if (typeof res.socket?.setNoDelay === 'function') {
@@ -188,10 +404,17 @@ export function createHub({ store, heartbeatMs, maxOutputBytes, onWarn = () => {
188
404
 
189
405
  const stream = { computerId, res, timer: null, openedAt: Date.now() }
190
406
  streams.set(computerId, stream)
191
- writeFrame(stream, 'hello', { computerId, name, heartbeatMs })
407
+ const capabilities = Array.isArray(handshake.capabilities) ? handshake.capabilities : ['exec']
408
+ writeFrame(stream, 'hello', {
409
+ computerId,
410
+ name,
411
+ heartbeatMs,
412
+ capabilities,
413
+ // §11.1/§11.5: the agent only uses v2 frames when the host advertises them.
414
+ features: [...HOST_FEATURES],
415
+ })
192
416
 
193
417
  const onClose = () => {
194
- // Only the CURRENT stream owns the computer's liveness.
195
418
  if (streams.get(computerId) !== stream) return
196
419
  retire(computerId, stream, { end: false })
197
420
  failComputerExecs(computerId, new HubError('offline', 'the connection to the computer was lost'))
@@ -199,15 +422,12 @@ export function createHub({ store, heartbeatMs, maxOutputBytes, onWarn = () => {
199
422
  res.on('close', onClose)
200
423
  res.on('error', onClose)
201
424
  req.on('close', () => {
202
- // The agent may close the request side without the response side
203
- // reporting it promptly on every platform.
204
425
  if (res.writableEnded || res.destroyed) onClose()
205
426
  })
206
427
 
207
428
  stream.timer = setInterval(() => {
208
429
  if (!writeFrame(stream, 'ping', { t: Date.now() })) onClose()
209
430
  }, heartbeatMs)
210
- // Pings must not keep a shutting-down process alive.
211
431
  if (typeof stream.timer.unref === 'function') stream.timer.unref()
212
432
 
213
433
  return { replaced, stream }
@@ -215,9 +435,6 @@ export function createHub({ store, heartbeatMs, maxOutputBytes, onWarn = () => {
215
435
 
216
436
  /**
217
437
  * Push one event to a computer's live stream.
218
- * @param {string} computerId - target computer.
219
- * @param {string} event - SSE event name.
220
- * @param {unknown} data - JSON payload.
221
438
  * @returns {boolean} false when the computer has no live stream.
222
439
  */
223
440
  send(computerId, event, data) {
@@ -226,15 +443,10 @@ export function createHub({ store, heartbeatMs, maxOutputBytes, onWarn = () => {
226
443
  return writeFrame(stream, event, data)
227
444
  },
228
445
 
229
- /**
230
- * End a computer's live stream and fail its in-flight executions.
231
- * @param {string} computerId - target computer.
232
- * @returns {boolean} whether a stream was open.
233
- */
446
+ /** End a computer's live stream and fail its in-flight executions. */
234
447
  closeStream(computerId) {
235
448
  const stream = streams.get(computerId)
236
449
  if (stream === undefined) {
237
- // Still fail executions: the HTTP side may already be gone.
238
450
  failComputerExecs(computerId, new HubError('offline', 'the computer was disconnected'))
239
451
  return false
240
452
  }
@@ -244,86 +456,103 @@ export function createHub({ store, heartbeatMs, maxOutputBytes, onWarn = () => {
244
456
  },
245
457
 
246
458
  /**
247
- * Dispatch one command through the computer's live stream.
459
+ * Dispatch one foreground command (v1 shape) through the live stream.
248
460
  * @param {object} request - exec request.
249
- * @param {string} request.computerId - target computer.
250
- * @param {string} request.name - display name used in error messages.
251
- * @param {string} request.command - shell command.
252
- * @param {string | null} [request.cwd] - working directory or null.
253
- * @param {number} request.timeoutMs - settlement deadline.
254
- * @param {string} [request.shell] - shell hint for the agent.
255
- * @param {AbortSignal | undefined} [request.signal] - caller cancellation.
256
- * @returns {Promise<object>} normalized result when the agent answers.
461
+ * @returns {Promise<object>} normalized result, or `{ promoted:true, jobId }`.
257
462
  */
258
- async dispatch({ computerId, name, command, cwd = null, timeoutMs, shell = 'sh', signal }) {
259
- const label = typeof name === 'string' && name !== '' ? name : computerId
260
- if (disposed) throw new HubError('disposed', 'the dsh-computers host plugin is shutting down')
261
- if (!streams.has(computerId)) {
262
- throw new HubError('offline', `computer '${label}' is offline; run computer_list to see what is connected`)
263
- }
264
- if (typeof command !== 'string' || command.trim() === '') {
265
- throw new HubError('invalid', 'command must be a non-empty string')
266
- }
267
- const stream = streams.get(computerId)
268
- if (stream === undefined) {
269
- throw new HubError('offline', `computer '${label}' is offline; run computer_list to see what is connected`)
270
- }
271
- const requestId = newRequestId()
463
+ async dispatch({ computerId, name, command, cwd = null, timeoutMs, agentTimeoutMs = null, shell = 'sh', signal, stream = true, onTimeout = null }) {
272
464
  return await new Promise((resolve, reject) => {
273
- const entry = {
274
- requestId,
275
- computerId,
276
- command,
277
- startedAt: Date.now(),
278
- signal,
279
- onAbort: null,
280
- timer: null,
281
- settled: false,
282
- resolve,
283
- reject,
284
- }
285
- pending.set(requestId, entry)
286
-
287
- // NOT unref'd: an in-flight execution must settle even if it is the
288
- // only work left in the process.
289
- entry.timer = setTimeout(() => {
290
- // Tell the agent to kill the process group before settling, so a
291
- // timed-out command does not keep eating the user's computer.
292
- hub.send(computerId, 'cancel', { requestId })
293
- failEntry(entry, new HubError('timeout',
294
- `computer '${label}' did not answer within ${timeoutMs}ms (the command was cancelled)`))
295
- }, timeoutMs)
296
-
297
- if (signal !== undefined) {
298
- const abort = () => {
299
- hub.send(computerId, 'cancel', { requestId })
300
- failEntry(entry, new HubError('aborted', `computer_exec was aborted while running on '${label}'`))
301
- }
302
- if (signal.aborted) {
303
- abort()
304
- return
305
- }
306
- entry.onAbort = abort
307
- signal.addEventListener('abort', abort, { once: true })
465
+ // Promise executor runs synchronously; beginExecution throws are captured.
466
+ try {
467
+ beginExecution({
468
+ computerId, name, command, cwd, timeoutMs, agentTimeoutMs, shell, signal,
469
+ background: false, stream, onTimeout, resolve, reject,
470
+ })
471
+ } catch (error) {
472
+ reject(error)
308
473
  }
474
+ })
475
+ },
309
476
 
310
- const written = writeFrame(stream, 'exec', {
311
- requestId,
312
- command,
313
- cwd: cwd ?? null,
314
- timeoutMs,
315
- shell,
477
+ /**
478
+ * Start one background execution immediately (job path, §11.2).
479
+ * @returns {{ requestId: string, result: Promise<object> }} the execution.
480
+ */
481
+ startExecution({ computerId, name, command, cwd = null, timeoutMs = 0, agentTimeoutMs = null, shell = 'sh', job = null }) {
482
+ const handle = beginExecution({
483
+ computerId, name, command, cwd, timeoutMs, agentTimeoutMs, shell,
484
+ background: true, stream: true, job, onTimeout: null,
485
+ })
486
+ return { requestId: handle.requestId, result: handle.result }
487
+ },
488
+
489
+ /**
490
+ * Request cancellation of a running execution and settle it locally when
491
+ * the agent does not report a final result in time.
492
+ */
493
+ cancelExecution(computerId, requestId, { settleAfterMs = KILL_SETTLE_MS } = {}) {
494
+ const entry = pending.get(requestId)
495
+ if (entry === undefined || entry.computerId !== computerId) return false
496
+ entry.killed = true
497
+ hub.send(computerId, 'cancel', { requestId })
498
+ if (entry.graceTimer !== null) clearTimeout(entry.graceTimer)
499
+ entry.graceTimer = setTimeout(() => {
500
+ entry.graceTimer = null
501
+ if (!entry.settled) settleKilled(entry)
502
+ }, settleAfterMs)
503
+ if (typeof entry.graceTimer.unref === 'function') entry.graceTimer.unref()
504
+ return true
505
+ },
506
+
507
+ /**
508
+ * Apply one streaming frame (§11.2/§11.6.2):
509
+ * `{type:'stdout'|'stderr'|'heartbeat', data(base64), seq}` or the v1.3
510
+ * `{stdout, stderr}` pair. Out-of-order frames are reassembled by seq;
511
+ * duplicates are dropped; heartbeats only reset the silence timer.
512
+ * @returns {boolean} false when the id is unknown/settled or owned elsewhere.
513
+ */
514
+ settleChunk(computerId, payload) {
515
+ const requestId = payload !== null && typeof payload === 'object' ? payload.requestId : undefined
516
+ if (typeof requestId !== 'string') return false
517
+ const entry = pending.get(requestId)
518
+ if (entry === undefined || entry.computerId !== computerId) return false
519
+ const frameType = typeof payload.type === 'string' ? payload.type : ''
520
+ if (frameType === 'heartbeat') {
521
+ armSilenceTimer(entry)
522
+ return true
523
+ }
524
+ const seq = Number.isSafeInteger(payload.seq) && payload.seq >= 0 ? payload.seq : null
525
+ if (seq === null) return false
526
+ if (seq < entry.expectedSeq) return true // duplicate / already assembled
527
+ // A streaming frame revives the execution: heartbeats reset it too, and
528
+ // any real output proves the agent is alive.
529
+ armSilenceTimer(entry)
530
+ if (frameType === 'stdout' || frameType === 'stderr') {
531
+ entry.chunkMap.set(seq, { channel: frameType, text: decodeFrame(payload.data) })
532
+ } else if (payload.stdout !== undefined || payload.stderr !== undefined) {
533
+ // v1.3 chunk shape: one seq carrying both streams.
534
+ entry.chunkMap.set(seq, {
535
+ stdout: typeof payload.stdout === 'string' ? decodeFrame(payload.stdout) : '',
536
+ stderr: typeof payload.stderr === 'string' ? decodeFrame(payload.stderr) : '',
316
537
  })
317
- if (!written) {
318
- failEntry(entry, new HubError('offline', `computer '${label}' is offline; run computer_list to see what is connected`))
538
+ } else {
539
+ return true
540
+ }
541
+ while (entry.chunkMap.has(entry.expectedSeq)) {
542
+ const frame = entry.chunkMap.get(entry.expectedSeq)
543
+ entry.chunkMap.delete(entry.expectedSeq)
544
+ if (frame.channel !== undefined) appendChunk(entry, frame.channel, frame.text)
545
+ else {
546
+ appendChunk(entry, 'stdout', frame.stdout)
547
+ appendChunk(entry, 'stderr', frame.stderr)
319
548
  }
320
- })
549
+ entry.expectedSeq += 1
550
+ }
551
+ return true
321
552
  },
322
553
 
323
554
  /**
324
- * Settle one pending execution from the agent's result POST.
325
- * @param {string} computerId - authenticated computer.
326
- * @param {unknown} payload - result body from the agent.
555
+ * Settle one pending execution from the agent's final result POST.
327
556
  * @returns {boolean} false when the id is unknown/settled or owned elsewhere.
328
557
  */
329
558
  settle(computerId, payload) {
@@ -331,30 +560,7 @@ export function createHub({ store, heartbeatMs, maxOutputBytes, onWarn = () => {
331
560
  if (typeof requestId !== 'string') return false
332
561
  const entry = pending.get(requestId)
333
562
  if (entry === undefined || entry.computerId !== computerId) return false
334
- entry.settled = true
335
- detach(entry)
336
- pending.delete(requestId)
337
-
338
- const stdout = capText(typeof payload.stdout === 'string' ? payload.stdout : '', maxOutputBytes)
339
- const stderr = capText(typeof payload.stderr === 'string' ? payload.stderr : '', maxOutputBytes)
340
- const exitCode = typeof payload.exitCode === 'number' && Number.isFinite(payload.exitCode)
341
- ? Math.trunc(payload.exitCode)
342
- : null
343
- const duration = typeof payload.durationMs === 'number' && Number.isFinite(payload.durationMs) && payload.durationMs >= 0
344
- ? Math.trunc(payload.durationMs)
345
- : Date.now() - entry.startedAt
346
- entry.resolve({
347
- requestId,
348
- ok: payload.ok === true,
349
- exitCode,
350
- signal: typeof payload.signal === 'string' ? payload.signal : null,
351
- stdout: stdout.text,
352
- stderr: stderr.text,
353
- durationMs: duration,
354
- truncated: payload.truncated === true || stdout.truncated || stderr.truncated,
355
- timedOut: payload.timedOut === true,
356
- error: typeof payload.error === 'string' ? payload.error : null,
357
- })
563
+ finish(entry, normalizeResult(entry, payload))
358
564
  return true
359
565
  },
360
566