@namzu/sdk 52.1.0 → 52.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.
Files changed (149) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +1 -1
  3. package/dist/bridge/a2a/mapper.d.ts.map +1 -1
  4. package/dist/bridge/a2a/mapper.js +3 -0
  5. package/dist/bridge/a2a/mapper.js.map +1 -1
  6. package/dist/bridge/sse/mapper.d.ts.map +1 -1
  7. package/dist/bridge/sse/mapper.js +18 -0
  8. package/dist/bridge/sse/mapper.js.map +1 -1
  9. package/dist/connector/tools/adapter.d.ts +2 -2
  10. package/dist/contracts/a2a.d.ts +92 -92
  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/manager/resident/learning.d.ts +6 -6
  18. package/dist/manager/resident/store.d.ts +4 -4
  19. package/dist/pals/activity/subscription-schema.d.ts +48 -48
  20. package/dist/pals/channels/routing.d.ts +1 -1
  21. package/dist/pals/channels/schema.d.ts +7 -7
  22. package/dist/pals/communication/ingress-schema.d.ts +224 -224
  23. package/dist/pals/communication/schema.d.ts +18 -18
  24. package/dist/peers/protocol.d.ts +36 -36
  25. package/dist/public-runtime.d.ts +2 -1
  26. package/dist/public-runtime.d.ts.map +1 -1
  27. package/dist/public-runtime.js +1 -1
  28. package/dist/public-runtime.js.map +1 -1
  29. package/dist/public-tools.d.ts +3 -1
  30. package/dist/public-tools.d.ts.map +1 -1
  31. package/dist/public-tools.js +7 -1
  32. package/dist/public-tools.js.map +1 -1
  33. package/dist/runtime/jobs/awaited-jobs.d.ts +59 -2
  34. package/dist/runtime/jobs/awaited-jobs.d.ts.map +1 -1
  35. package/dist/runtime/jobs/awaited-jobs.js +108 -5
  36. package/dist/runtime/jobs/awaited-jobs.js.map +1 -1
  37. package/dist/runtime/jobs/monitor-stream.d.ts +96 -0
  38. package/dist/runtime/jobs/monitor-stream.d.ts.map +1 -0
  39. package/dist/runtime/jobs/monitor-stream.js +221 -0
  40. package/dist/runtime/jobs/monitor-stream.js.map +1 -0
  41. package/dist/runtime/jobs/registry.d.ts +96 -0
  42. package/dist/runtime/jobs/registry.d.ts.map +1 -1
  43. package/dist/runtime/jobs/registry.js +203 -20
  44. package/dist/runtime/jobs/registry.js.map +1 -1
  45. package/dist/runtime/query/executor.d.ts +10 -0
  46. package/dist/runtime/query/executor.d.ts.map +1 -1
  47. package/dist/runtime/query/executor.js +7 -0
  48. package/dist/runtime/query/executor.js.map +1 -1
  49. package/dist/runtime/query/index.d.ts +18 -0
  50. package/dist/runtime/query/index.d.ts.map +1 -1
  51. package/dist/runtime/query/index.js +94 -3
  52. package/dist/runtime/query/index.js.map +1 -1
  53. package/dist/runtime/query/iteration/outstanding-work.d.ts +14 -0
  54. package/dist/runtime/query/iteration/outstanding-work.d.ts.map +1 -1
  55. package/dist/runtime/query/iteration/outstanding-work.js +64 -3
  56. package/dist/runtime/query/iteration/outstanding-work.js.map +1 -1
  57. package/dist/runtime/query/iteration/phases/context.d.ts +9 -0
  58. package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
  59. package/dist/runtime/query/iteration/phases/context.js.map +1 -1
  60. package/dist/runtime/query/iteration/phases/tool-review.d.ts.map +1 -1
  61. package/dist/runtime/query/iteration/phases/tool-review.js +11 -2
  62. package/dist/runtime/query/iteration/phases/tool-review.js.map +1 -1
  63. package/dist/runtime/query/release-turn.d.ts.map +1 -1
  64. package/dist/runtime/query/release-turn.js +35 -0
  65. package/dist/runtime/query/release-turn.js.map +1 -1
  66. package/dist/runtime/query/steering.d.ts +35 -0
  67. package/dist/runtime/query/steering.d.ts.map +1 -1
  68. package/dist/runtime/query/steering.js +121 -0
  69. package/dist/runtime/query/steering.js.map +1 -1
  70. package/dist/runtime/query/tooling.d.ts +5 -0
  71. package/dist/runtime/query/tooling.d.ts.map +1 -1
  72. package/dist/runtime/query/tooling.js +5 -0
  73. package/dist/runtime/query/tooling.js.map +1 -1
  74. package/dist/runtime/system-events.d.ts +9 -0
  75. package/dist/runtime/system-events.d.ts.map +1 -1
  76. package/dist/runtime/system-events.js +12 -0
  77. package/dist/runtime/system-events.js.map +1 -1
  78. package/dist/tools/builtins/bash.d.ts +3 -0
  79. package/dist/tools/builtins/bash.d.ts.map +1 -1
  80. package/dist/tools/builtins/bash.js +3 -2
  81. package/dist/tools/builtins/bash.js.map +1 -1
  82. package/dist/tools/builtins/browser.d.ts +4 -4
  83. package/dist/tools/builtins/code-analysis.d.ts +49 -0
  84. package/dist/tools/builtins/code-analysis.d.ts.map +1 -0
  85. package/dist/tools/builtins/code-analysis.js +409 -0
  86. package/dist/tools/builtins/code-analysis.js.map +1 -0
  87. package/dist/tools/builtins/computer-use.d.ts +24 -24
  88. package/dist/tools/builtins/index.d.ts +2 -0
  89. package/dist/tools/builtins/index.d.ts.map +1 -1
  90. package/dist/tools/builtins/index.js +2 -0
  91. package/dist/tools/builtins/index.js.map +1 -1
  92. package/dist/tools/builtins/job.d.ts.map +1 -1
  93. package/dist/tools/builtins/job.js +6 -2
  94. package/dist/tools/builtins/job.js.map +1 -1
  95. package/dist/tools/builtins/lsp.d.ts +1 -1
  96. package/dist/tools/builtins/monitor.d.ts +24 -0
  97. package/dist/tools/builtins/monitor.d.ts.map +1 -0
  98. package/dist/tools/builtins/monitor.js +167 -0
  99. package/dist/tools/builtins/monitor.js.map +1 -0
  100. package/dist/tools/builtins/skill.d.ts +2 -2
  101. package/dist/tools/builtins/wait-for-job.d.ts +1 -1
  102. package/dist/turn/reporter.d.ts.map +1 -1
  103. package/dist/turn/reporter.js +8 -0
  104. package/dist/turn/reporter.js.map +1 -1
  105. package/dist/types/code-analysis/index.d.ts +299 -0
  106. package/dist/types/code-analysis/index.d.ts.map +1 -0
  107. package/dist/types/code-analysis/index.js +30 -0
  108. package/dist/types/code-analysis/index.js.map +1 -0
  109. package/dist/types/plugin/index.d.ts +6 -6
  110. package/dist/types/session/events.d.ts +30 -4
  111. package/dist/types/session/events.d.ts.map +1 -1
  112. package/dist/types/session/events.js +6 -1
  113. package/dist/types/session/events.js.map +1 -1
  114. package/dist/types/session/records.d.ts +129 -129
  115. package/dist/types/session/records.d.ts.map +1 -1
  116. package/dist/types/session/records.js +3 -2
  117. package/dist/types/session/records.js.map +1 -1
  118. package/dist/types/tool/index.d.ts +34 -0
  119. package/dist/types/tool/index.d.ts.map +1 -1
  120. package/dist/types/tool/index.js.map +1 -1
  121. package/package.json +2 -2
  122. package/src/bridge/a2a/mapper.ts +3 -0
  123. package/src/bridge/sse/mapper.ts +18 -0
  124. package/src/contracts/session/api.ts +1 -0
  125. package/src/index.ts +23 -0
  126. package/src/public-runtime.ts +28 -0
  127. package/src/public-tools.ts +16 -0
  128. package/src/runtime/jobs/awaited-jobs.ts +127 -5
  129. package/src/runtime/jobs/monitor-stream.ts +269 -0
  130. package/src/runtime/jobs/registry.ts +286 -21
  131. package/src/runtime/query/executor.ts +17 -0
  132. package/src/runtime/query/index.ts +119 -3
  133. package/src/runtime/query/iteration/outstanding-work.ts +64 -3
  134. package/src/runtime/query/iteration/phases/context.ts +9 -0
  135. package/src/runtime/query/iteration/phases/tool-review.ts +21 -9
  136. package/src/runtime/query/release-turn.ts +33 -0
  137. package/src/runtime/query/steering.ts +144 -0
  138. package/src/runtime/query/tooling.ts +10 -0
  139. package/src/runtime/system-events.ts +17 -0
  140. package/src/tools/builtins/bash.ts +3 -2
  141. package/src/tools/builtins/code-analysis.ts +486 -0
  142. package/src/tools/builtins/index.ts +11 -0
  143. package/src/tools/builtins/job.ts +11 -2
  144. package/src/tools/builtins/monitor.ts +206 -0
  145. package/src/turn/reporter.ts +8 -0
  146. package/src/types/code-analysis/index.ts +325 -0
  147. package/src/types/session/events.ts +31 -4
  148. package/src/types/session/records.ts +3 -2
  149. package/src/types/tool/index.ts +33 -1
@@ -10,7 +10,15 @@ import type {
10
10
  BackgroundJobOutputWaitResult,
11
11
  } from '../../types/job/index.js'
12
12
  import { subscribeToAbort } from '../../utils/abort.js'
13
+ import { oneMetadataLine } from '../system-events.js'
13
14
  import { createBackgroundJobId } from './job-id.js'
15
+ import {
16
+ MONITOR_TIMEOUT_CAP_MS,
17
+ type MonitorStreamStats,
18
+ createMonitorStream,
19
+ expiryNotice,
20
+ tooMuchOutputNotice,
21
+ } from './monitor-stream.js'
14
22
 
15
23
  /**
16
24
  * Work that outlives a tool call, owned by whoever started it.
@@ -57,6 +65,38 @@ export interface BackgroundJob {
57
65
  readonly recoveryRequired?: boolean
58
66
  /** Safe registry diagnostic for an unconfirmed stop; excludes transport secrets. */
59
67
  readonly stopError?: string
68
+ /**
69
+ * `monitor` for a job started by the `monitor` tool: its stdout is
70
+ * delivered line by line as events, and it expires. Absent for every
71
+ * other job.
72
+ */
73
+ readonly kind?: 'monitor'
74
+ /** What the monitor watches, in the model's words; shown with every event. */
75
+ readonly description?: string
76
+ /** When the monitor will be stopped if the command has not ended first (epoch ms). */
77
+ readonly expiresAt?: number
78
+ /**
79
+ * Why a monitor was stopped, when the registry stopped it: `expired` at
80
+ * its deadline, `too_much_output` when the source outran the event
81
+ * ration, `stopped` for a `kill()` asked for from outside. Absent while it
82
+ * runs and when the command ended on its own.
83
+ */
84
+ readonly stopReason?: 'expired' | 'too_much_output' | 'stopped'
85
+ }
86
+
87
+ /**
88
+ * One thing a monitor has to say: a batch of its stdout lines, or a notice
89
+ * from the monitor itself (events suppressed, stopped, expired).
90
+ */
91
+ export interface MonitorEvent {
92
+ /** `${jobId}:${seq}`, unique per registry and stable, so a host can acknowledge it. */
93
+ readonly id: string
94
+ readonly jobId: string
95
+ readonly owner: string
96
+ readonly description: string
97
+ readonly kind: 'output' | 'notice'
98
+ readonly text: string
99
+ readonly at: number
60
100
  }
61
101
 
62
102
  export interface BackgroundJobOutput {
@@ -114,6 +154,13 @@ export interface StartJobParams {
114
154
  * decoded the way a foreground command's are, not as a Linux guest's.
115
155
  */
116
156
  readonly hostOutput?: boolean
157
+ /**
158
+ * Run the command as a monitor: stdout is split into lines and delivered
159
+ * to `onMonitorEvent` listeners in batches, and the job is stopped at
160
+ * `timeoutMs`. Both streams stay in the retained buffer, so `read` still
161
+ * shows everything, stderr included.
162
+ */
163
+ readonly monitor?: { readonly description: string; readonly timeoutMs: number }
117
164
  }
118
165
 
119
166
  export interface BackgroundJobRegistryConfig {
@@ -212,6 +259,16 @@ interface JobEntry {
212
259
  exit: Promise<void>
213
260
  /** Settles once the process exists or failed to; `error` says which. */
214
261
  started: Promise<{ readonly error?: string }>
262
+ /** Present for a monitor. */
263
+ monitor?: MonitorState
264
+ }
265
+
266
+ interface MonitorState {
267
+ readonly stream: ReturnType<typeof createMonitorStream>
268
+ readonly description: string
269
+ readonly timeoutMs: number
270
+ seq: number
271
+ expiryTimer?: ReturnType<typeof setTimeout>
215
272
  }
216
273
 
217
274
  /** Whether any process of the group led by `pid` is still alive. */
@@ -230,6 +287,7 @@ const GROUP_POLL_MS = 250
230
287
  export class BackgroundJobRegistry {
231
288
  private readonly jobs = new Map<string, JobEntry>()
232
289
  private readonly exitListeners = new Set<(job: BackgroundJob) => void>()
290
+ private readonly monitorListeners = new Set<(event: MonitorEvent) => void>()
233
291
  private outputWaiters = 0
234
292
  private readonly outputWaitersByOwner = new Map<string, number>()
235
293
 
@@ -256,6 +314,52 @@ export class BackgroundJobRegistry {
256
314
  }
257
315
  }
258
316
 
317
+ /**
318
+ * Be told what a monitor has to say, whoever owns it: a batch of its
319
+ * stdout lines, or a notice from the monitor itself. Returns the
320
+ * unsubscribe.
321
+ *
322
+ * Every listener hears every monitor's events, whatever their `owner`: a
323
+ * consumer serving one turn or session MUST filter on `event.owner`, as the
324
+ * turn does. A listener that throws is its owner's problem, as in
325
+ * {@link onExit}.
326
+ */
327
+ onMonitorEvent(listener: (event: MonitorEvent) => void): () => void {
328
+ this.monitorListeners.add(listener)
329
+ return () => {
330
+ this.monitorListeners.delete(listener)
331
+ }
332
+ }
333
+
334
+ private announceMonitorEvent(entry: JobEntry, kind: MonitorEvent['kind'], text: string): void {
335
+ const monitor = entry.monitor
336
+ if (!monitor) return
337
+ monitor.seq += 1
338
+ const event: MonitorEvent = {
339
+ id: `${entry.record.id}:${monitor.seq}`,
340
+ jobId: entry.record.id,
341
+ owner: entry.record.owner,
342
+ description: monitor.description,
343
+ kind,
344
+ text,
345
+ at: Date.now(),
346
+ }
347
+ for (const listener of this.monitorListeners) {
348
+ try {
349
+ listener(event)
350
+ } catch {
351
+ // A listener that throws is its owner's problem, not the job's.
352
+ }
353
+ }
354
+ }
355
+
356
+ /** What a monitor's stream has done so far, for the summary of how it ended. */
357
+ monitorStats(id: string): MonitorStreamStats {
358
+ const entry = this.jobs.get(id)
359
+ if (!entry) throw new UnknownBackgroundJobError({ id })
360
+ return entry.monitor?.stream.stats() ?? { eventsDelivered: 0, suppressed: 0, stdoutChars: 0 }
361
+ }
362
+
259
363
  /** A failed remote stop never releases ownership or announces a terminal job. */
260
364
  private confirmTermination(entry: JobEntry): Promise<void> {
261
365
  if (entry.terminationAttempt) return entry.terminationAttempt
@@ -364,6 +468,32 @@ export class BackgroundJobRegistry {
364
468
  // it may be remote, and need not emit `spawn` at all.
365
469
  if (params.spawn || child.pid !== undefined) settleStarted()
366
470
 
471
+ // A monitor's stdout goes through a stream that batches and rations it;
472
+ // everything it says reaches listeners as `MonitorEvent`s. The callbacks
473
+ // close over `entry`, which exists by the time any of them can run.
474
+ const monitor: MonitorState | undefined = params.monitor
475
+ ? {
476
+ description: params.monitor.description,
477
+ timeoutMs: params.monitor.timeoutMs,
478
+ seq: 0,
479
+ stream: createMonitorStream({
480
+ onBatch: (text) => this.announceMonitorEvent(entry, 'output', text),
481
+ onNotice: (text) => this.announceMonitorEvent(entry, 'notice', text),
482
+ onOverflow: ({ suppressed, overMs }) => {
483
+ // The reason is recorded before the kill, so the exit that
484
+ // follows is described as what it was and not as a stop
485
+ // somebody asked for.
486
+ entry.record = { ...entry.record, stopReason: 'too_much_output' }
487
+ this.announceMonitorEvent(entry, 'notice', tooMuchOutputNotice(suppressed, overMs))
488
+ void this.kill(id).catch(() => {
489
+ // The retained record carries the failure (recoveryRequired).
490
+ })
491
+ },
492
+ }),
493
+ }
494
+ : undefined
495
+ const startedAt = Date.now()
496
+
367
497
  let finalizeEntry!: (code: number | null, signal: NodeJS.Signals | null) => void
368
498
  const entry: JobEntry = {
369
499
  record: {
@@ -371,8 +501,16 @@ export class BackgroundJobRegistry {
371
501
  owner: params.owner,
372
502
  command: params.command,
373
503
  status: 'running',
374
- startedAt: Date.now(),
504
+ startedAt,
505
+ ...(monitor
506
+ ? {
507
+ kind: 'monitor' as const,
508
+ description: monitor.description,
509
+ expiresAt: startedAt + monitor.timeoutMs,
510
+ }
511
+ : {}),
375
512
  },
513
+ ...(monitor ? { monitor } : {}),
376
514
  child,
377
515
  ...(started.kill ? { killProcess: started.kill.bind(started) } : {}),
378
516
  ...(started.terminate ? { terminateProcess: started.terminate.bind(started) } : {}),
@@ -400,6 +538,15 @@ export class BackgroundJobRegistry {
400
538
  }
401
539
  resolve()
402
540
  for (const observer of entry.outputObservers) observer()
541
+ if (entry.monitor) {
542
+ // The last batch is a monitor's last words and must precede
543
+ // the exit: a listener that learns the job ended and THEN
544
+ // gets output would be telling the model about a finished
545
+ // watch after it had been told the watch was over.
546
+ clearTimeout(entry.monitor.expiryTimer)
547
+ entry.monitor.stream.drain()
548
+ entry.monitor.stream.close()
549
+ }
403
550
  this.announceExit(entry.record)
404
551
  }
405
552
  finalizeEntry = finalize
@@ -467,6 +614,10 @@ export class BackgroundJobRegistry {
467
614
  entry.buffer += text
468
615
  entry.buffer = outputTail(entry.buffer, this.maxBytes).text
469
616
  entry.outputChunks.push(chunk)
617
+ // Only stdout is an event. stderr stays readable with `read`, which is
618
+ // where a watcher's diagnostics belong: a model told about every
619
+ // warning line would stop reading the events that matter.
620
+ if (stream === 'stdout') entry.monitor?.stream.push(text)
470
621
  const retainedStart = entry.produced - Buffer.byteLength(entry.buffer)
471
622
  while (
472
623
  entry.outputChunks.length > MAX_CHANNEL_CHUNKS ||
@@ -497,9 +648,37 @@ export class BackgroundJobRegistry {
497
648
  child.stderr?.on('data', onData('stderr'))
498
649
 
499
650
  this.jobs.set(id, entry)
651
+ if (monitor) {
652
+ // A monitor has a life. Without a deadline a watch the model forgot
653
+ // would run, and bill its events, until the session ended.
654
+ monitor.expiryTimer = setTimeout(() => this.expireMonitor(entry), monitor.timeoutMs)
655
+ monitor.expiryTimer.unref?.()
656
+ }
500
657
  return entry.record
501
658
  }
502
659
 
660
+ /** The deadline: the last batch, then the notice, then the same kill every stop uses. */
661
+ private expireMonitor(entry: JobEntry): void {
662
+ const monitor = entry.monitor
663
+ if (!monitor || entry.record.status !== 'running') return
664
+ monitor.stream.flush()
665
+ // The flush can itself have stopped the monitor for too much output.
666
+ if (entry.record.stopReason !== undefined || entry.record.status !== 'running') return
667
+ entry.record = { ...entry.record, stopReason: 'expired' }
668
+ this.announceMonitorEvent(
669
+ entry,
670
+ 'notice',
671
+ expiryNotice(monitor.timeoutMs, monitor.stream.stats().eventsDelivered),
672
+ )
673
+ // Nothing follows a terminal notice. A command that ignores SIGTERM keeps
674
+ // printing until the SIGKILL, and those lines would reach the model after
675
+ // it was told the watch was over.
676
+ monitor.stream.close()
677
+ void this.kill(entry.record.id).catch(() => {
678
+ // The retained record carries the failure (recoveryRequired).
679
+ })
680
+ }
681
+
503
682
  /**
504
683
  * Resolves once the job's process exists, or once it could not be
505
684
  * created (`error` then says why). Lets a caller report a start that
@@ -762,9 +941,33 @@ export class BackgroundJobRegistry {
762
941
  const entry = this.jobs.get(id)
763
942
  if (!entry) throw new UnknownBackgroundJobError({ id })
764
943
  if (entry.record.status !== 'running') return entry.record
944
+ // A stop nobody else explained is the model's (or the operator's). The
945
+ // expiry and output rules record their own reason first.
946
+ const stoppedBy = entry.monitor !== undefined && entry.record.stopReason === undefined
947
+ if (stoppedBy) entry.record = { ...entry.record, stopReason: 'stopped' }
948
+ // An accepted stop ends the event stream: what was already complete goes
949
+ // out, and nothing the dying process prints afterwards does. (The expiry
950
+ // and output rules close it themselves, after their own notice.)
951
+ const closeStream = (): void => {
952
+ if (!stoppedBy) return
953
+ entry.monitor?.stream.flush()
954
+ entry.monitor?.stream.close()
955
+ }
765
956
  if (entry.terminateProcess) {
766
957
  entry.killRequested = true
767
- await this.confirmTermination(entry)
958
+ try {
959
+ await this.confirmTermination(entry)
960
+ } catch (error) {
961
+ // Still running: do not leave it saying a stop happened.
962
+ if (stoppedBy) {
963
+ const { stopReason: _reason, ...rest } = entry.record
964
+ entry.record = rest
965
+ }
966
+ throw error
967
+ }
968
+ // Only once the stop is confirmed: a remote stop that failed leaves a
969
+ // running monitor, and a closed stream could not be reopened.
970
+ closeStream()
768
971
  await entry.exit
769
972
  return entry.record
770
973
  }
@@ -773,6 +976,7 @@ export class BackgroundJobRegistry {
773
976
  // tell a kill from an ordinary exit. Set it after and the race decides
774
977
  // which of two different answers a reader gets.
775
978
  entry.record = { ...entry.record, status: 'killed' }
979
+ closeStream()
776
980
  for (const observer of entry.outputObservers) observer()
777
981
  const signal = (sig: NodeJS.Signals) =>
778
982
  entry.killProcess ? entry.killProcess(sig) : killTree(entry.child, sig)
@@ -847,6 +1051,19 @@ export function bindOwner(
847
1051
  * than present and silently doing nothing.
848
1052
  */
849
1053
  readonly onAwaited?: (id: string) => void
1054
+ /**
1055
+ * The longest a monitor started through this binding may live. A host
1056
+ * may lower it (a single-shot run has no tomorrow to watch for); the
1057
+ * default is {@link MONITOR_TIMEOUT_CAP_MS}.
1058
+ */
1059
+ readonly monitorTimeoutCapMs?: number
1060
+ /**
1061
+ * Be told that this binding started a monitor. The turn passes its own
1062
+ * recorder here when the monitor belongs to the turn itself, which is
1063
+ * what makes the turn stay open for it; a session-owned monitor is not
1064
+ * reported, because the host wakes on its events.
1065
+ */
1066
+ readonly onMonitorStarted?: (id: string) => void
850
1067
  } = {},
851
1068
  ) {
852
1069
  const mine = (id: string): BackgroundJob => {
@@ -854,26 +1071,48 @@ export function bindOwner(
854
1071
  if (job.owner !== owner) throw new UnknownBackgroundJobError({ id })
855
1072
  return job
856
1073
  }
1074
+ const launch = (
1075
+ params: { command: string; workingDirectory?: string },
1076
+ monitor?: StartJobParams['monitor'],
1077
+ ): BackgroundJob => {
1078
+ const workingDirectory = params.workingDirectory ?? defaults.workingDirectory ?? process.cwd()
1079
+ const spawnHost = defaults.spawn
1080
+ return registry.start({
1081
+ owner,
1082
+ command: params.command,
1083
+ workingDirectory,
1084
+ ...(defaults.env ? { env: defaults.env } : {}),
1085
+ ...(spawnHost
1086
+ ? {
1087
+ spawn: () =>
1088
+ spawnHost({
1089
+ command: params.command,
1090
+ workingDirectory,
1091
+ ...(defaults.env ? { env: defaults.env } : {}),
1092
+ }),
1093
+ }
1094
+ : {}),
1095
+ ...(monitor ? { monitor } : {}),
1096
+ })
1097
+ }
857
1098
  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({
862
- 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
- : {}),
876
- })
1099
+ start: (params: { command: string; workingDirectory?: string }) => launch(params),
1100
+ // The same spawn, owner and cap as `start`, plus a deadline. The timeout
1101
+ // is clamped to the host's ceiling and the clamped value is returned, so
1102
+ // the number the model is told is the number that will fire.
1103
+ startMonitor: (params: {
1104
+ command: string
1105
+ description: string
1106
+ timeoutMs: number
1107
+ workingDirectory?: string
1108
+ }) => {
1109
+ const timeoutMs = Math.min(
1110
+ params.timeoutMs,
1111
+ defaults.monitorTimeoutCapMs ?? MONITOR_TIMEOUT_CAP_MS,
1112
+ )
1113
+ const job = launch(params, { description: params.description, timeoutMs })
1114
+ defaults.onMonitorStarted?.(job.id)
1115
+ return { job, timeoutMs }
877
1116
  },
878
1117
  // A foreground command that ran out of time, kept instead of killed.
879
1118
  // The same owner and cap as `start`; the process is the one already
@@ -924,3 +1163,29 @@ export function bindOwner(
924
1163
  list: () => registry.list(owner),
925
1164
  }
926
1165
  }
1166
+
1167
+ /**
1168
+ * How a monitor ended, in the words the model, the transcript and a
1169
+ * single-shot run all use, so the three cannot disagree about it.
1170
+ *
1171
+ * `completed` is the command ending by itself: with output it is the stream
1172
+ * ending, and without any it is a watch that never saw anything, which a
1173
+ * reader must not mistake for a quiet success. A non-zero exit is `failed`.
1174
+ * `killed` covers every stop the registry or the model asked for; the expiry
1175
+ * and the too-much-output stop already told the model why in a notice of their
1176
+ * own, so the summary does not repeat it.
1177
+ */
1178
+ export function describeMonitorEnd(
1179
+ job: Pick<BackgroundJob, 'description' | 'status' | 'exitCode' | 'signal' | 'error'>,
1180
+ stats: Pick<MonitorStreamStats, 'stdoutChars'>,
1181
+ ): { status: 'completed' | 'failed' | 'killed'; summary: string } {
1182
+ const label = `Monitor "${oneMetadataLine(job.description ?? 'unnamed')}"`
1183
+ if (job.status === 'killed') return { status: 'killed', summary: `${label} stopped` }
1184
+ if (job.exitCode === 0 && job.error === undefined) {
1185
+ return stats.stdoutChars > 0
1186
+ ? { status: 'completed', summary: `${label} stream ended` }
1187
+ : { status: 'completed', summary: `${label} ended without producing output (exit 0)` }
1188
+ }
1189
+ const how = job.exitCode !== undefined ? `exit ${job.exitCode}` : (job.signal ?? 'exit unknown')
1190
+ return { status: 'failed', summary: `${label} script failed (${how})` }
1191
+ }
@@ -336,6 +336,7 @@ export const DEFAULT_TOOL_RETRY_BACKOFF: BackoffPolicy = {
336
336
 
337
337
  export interface ToolExecutorConfig {
338
338
  fileReadTracker?: FileReadTracker
339
+ codeAnalysis?: import('../../types/code-analysis/index.js').CodeAnalysisProvider
339
340
  tools: ToolManager
340
341
  /** Exact runtime output-tool owner allowed to retain post-screen, post-hook JSON before preview capping. */
341
342
  durableStructuredOutputTool?: ToolDefinition
@@ -394,6 +395,15 @@ export interface ToolExecutorConfig {
394
395
  * recorder gets no hold rather than a marking call that goes nowhere.
395
396
  */
396
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
397
407
 
398
408
  /**
399
409
  * Where the `skill` tool reads from.
@@ -1905,6 +1915,7 @@ export class ToolExecutor {
1905
1915
  this.dispatchNested(name, input, context, recordObservation, undefined, options),
1906
1916
  sandbox: this.config.sandbox,
1907
1917
  fileReadTracker: this.fileReadTracker,
1918
+ ...(this.config.codeAnalysis ? { codeAnalysis: this.config.codeAnalysis } : {}),
1908
1919
  // Bound to this turn, once. Binding here rather than passing the
1909
1920
  // owner from the tool is what makes the scoping structural: there
1910
1921
  // is no argument a tool could pass to reach another turn's jobs.
@@ -1946,6 +1957,12 @@ export class ToolExecutor {
1946
1957
  }
1947
1958
  : {}),
1948
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
+ : {}),
1949
1966
  },
1950
1967
  ),
1951
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'
@@ -153,6 +159,8 @@ import { ToolingBootstrap } from './tooling.js'
153
159
  export interface QueryParams {
154
160
  /** Share observations across turns of one conversation and filesystem; otherwise run-local. */
155
161
  fileReadTracker?: import('../../types/tool/index.js').FileReadTracker
162
+ /** The `code_analysis` tool's index; see `ToolContext.codeAnalysis`. Absent: the tool is not offered. */
163
+ codeAnalysis?: import('../../types/code-analysis/index.js').CodeAnalysisProvider
156
164
  /** One ledger shared with the task scheduler and descendant child sessions, keyed by (rootSessionId, rootTurnId). */
157
165
  budget?: SessionTokenBudget
158
166
  /**
@@ -278,6 +286,22 @@ export interface QueryParams {
278
286
  * mean the model saw it. A host can carry unacknowledged exits to its next turn.
279
287
  */
280
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
281
305
 
282
306
  /**
283
307
  * What else goes in this turn's prompt.
@@ -1371,6 +1395,27 @@ export async function* query(params: QueryParams): AsyncGenerator<SessionEvent,
1371
1395
  // subscription that fills it below, because the wait-intent recorder needs
1372
1396
  // to ask whether its text has been read yet.
1373
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
+ }
1374
1419
  const pendingJobNoticeIds = new Set<string>()
1375
1420
  const acknowledgeJobNotices = () => {
1376
1421
  if (pendingJobNoticeIds.size === 0) return
@@ -1389,8 +1434,17 @@ export async function* query(params: QueryParams): AsyncGenerator<SessionEvent,
1389
1434
  // thing that can hold this turn open for a job. Built only where there is a
1390
1435
  // registry, so a host with no background mode carries no recorder and the
1391
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
1392
1441
  const awaitedJobs = params.backgroundJobs
1393
- ? 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
+ )
1394
1448
  : undefined
1395
1449
  awaitedJobs?.attach()
1396
1450
 
@@ -1422,12 +1476,17 @@ export async function* query(params: QueryParams): AsyncGenerator<SessionEvent,
1422
1476
  ...(params.backgroundJobs ? { backgroundJobs: params.backgroundJobs } : {}),
1423
1477
  ...(params.backgroundJobOwner ? { backgroundJobOwner: params.backgroundJobOwner } : {}),
1424
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
+ : {}),
1425
1483
  // The `skill` tool's registry. Threaded from the turn rather than
1426
1484
  // held by the tool, because a tool that reached for a module-level
1427
1485
  // registry would answer about whatever the last turn configured.
1428
1486
  ...(params.skillRegistry ? { skills: params.skillRegistry } : {}),
1429
1487
  ...(params.web ? { web: params.web } : {}),
1430
1488
  ...(params.fileReadTracker ? { fileReadTracker: params.fileReadTracker } : {}),
1489
+ ...(params.codeAnalysis ? { codeAnalysis: params.codeAnalysis } : {}),
1431
1490
  ...(params.toolTimeoutMs !== undefined ? { toolTimeoutMs: params.toolTimeoutMs } : {}),
1432
1491
  ...(params.toolRetryBackoff !== undefined
1433
1492
  ? { toolRetryBackoff: params.toolRetryBackoff }
@@ -1490,8 +1549,31 @@ export async function* query(params: QueryParams): AsyncGenerator<SessionEvent,
1490
1549
  // result, and the host as an event — without either polling. Subscribed
1491
1550
  // for the owner the turn's jobs are bound to, so a session-owned job that
1492
1551
  // ends during this turn is reported here too.
1493
- const unsubscribeJobExits = params.backgroundJobs?.onExit((job) => {
1552
+ const unsubscribeExits = params.backgroundJobs?.onExit((job) => {
1494
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
+ }
1495
1577
  const outcome =
1496
1578
  job.status === 'killed'
1497
1579
  ? 'was stopped'
@@ -1512,6 +1594,38 @@ export async function* query(params: QueryParams): AsyncGenerator<SessionEvent,
1512
1594
  })
1513
1595
  .catch(() => {})
1514
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
1515
1629
  let workingStateManager: WorkingStateManager | undefined
1516
1630
  // Normalised once, defaults applied. A host that passes a partial
1517
1631
  // object — a strategy and a window, nothing else — used to reach the
@@ -1661,6 +1775,8 @@ export async function* query(params: QueryParams): AsyncGenerator<SessionEvent,
1661
1775
  ...(params.steering ? { steering: params.steering } : {}),
1662
1776
  ...(jobNotices ? { jobNotices } : {}),
1663
1777
  ...(jobNotices ? { onJobNoticeDelivered: acknowledgeJobNotices } : {}),
1778
+ ...(monitorNotices ? { monitorNotices } : {}),
1779
+ ...(monitorNotices ? { onMonitorNoticeDelivered: acknowledgeMonitorNotices } : {}),
1664
1780
  ...(awaitedJobs ? { awaitedJobs } : {}),
1665
1781
  checkpointMgr,
1666
1782
  planManager: ctx.planManager,