@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
@@ -5,7 +5,7 @@ import type { TaskHandle } from '../../../types/agent/scheduler.js'
5
5
  import { createRuntimeContextMessage } from '../../../types/message/index.js'
6
6
  import type { SessionEvent } from '../../../types/session/index.js'
7
7
  import { readPositiveIntEnv } from '../../../utils/env.js'
8
- import { formatJobNote } from '../steering.js'
8
+ import { formatJobNote, formatMonitorNote } from '../steering.js'
9
9
  import type { IterationContext } from './phases/index.js'
10
10
 
11
11
  /**
@@ -179,7 +179,17 @@ export async function* holdForOutstandingWork(
179
179
  // to be running. A job therefore never shortens a wait, and it never
180
180
  // lengthens one either: where a task is outstanding too, that is how
181
181
  // long this turn was waiting anyway.
182
- const graceMs = inbox ? settleGraceMs(remainingMs) : awaitedJobGraceMs(remainingMs)
182
+ let graceMs = inbox ? settleGraceMs(remainingMs) : awaitedJobGraceMs(remainingMs)
183
+ // A monitor the turn owns is held for until the monitor itself ends, which
184
+ // its own deadline bounds; the two-minute job ceiling would walk away from
185
+ // a watch with most of its time left. As with a task, this only ever
186
+ // lengthens the wait: it is the longest justified leg that sets the
187
+ // deadline. A second of slack lets the expiry notice arrive before the
188
+ // grace does.
189
+ const monitorMs = jobs?.monitorHoldMs()
190
+ if (monitorMs !== undefined) {
191
+ graceMs = Math.max(graceMs, Math.min(settleGraceMs(remainingMs), monitorMs + 1000))
192
+ }
183
193
  ctx.log.info('Holding the turn open for outstanding work', {
184
194
  [NAMZU.TURN_ID]: ctx.recorder.turnId,
185
195
  [NAMZU.ITERATION]: iterationNum,
@@ -217,8 +227,9 @@ export async function* holdForOutstandingWork(
217
227
  )
218
228
  }
219
229
  const exited = deliverAwaitedJobExits(ctx)
230
+ const monitored = deliverMonitorNotices(ctx)
220
231
  const inbound = await deliverInbound()
221
- if (arrived.length === 0 && !exited && inbound === 0) return false
232
+ if (arrived.length === 0 && !exited && !monitored && inbound === 0) return false
222
233
  await ctx.emitEvent({
223
234
  type: 'iteration_completed',
224
235
  turnId: ctx.recorder.turnId,
@@ -268,6 +279,32 @@ export function deliverAwaitedJobExits(ctx: IterationContext): boolean {
268
279
  return true
269
280
  }
270
281
 
282
+ /**
283
+ * Put what a monitor the turn owns has said in front of the model.
284
+ *
285
+ * The same shape as {@link deliverAwaitedJobExits}: through the channel the
286
+ * events already travel on (`monitorNotices`, which `attachNotice` rides out on
287
+ * the next tool result), and as a `runtime-context` message when the turn made
288
+ * no tool call and so has no result to carry it. The record that they were
289
+ * delivered goes with the text, so an event already attached to a tool result
290
+ * earlier in the turn leaves nothing here and does not buy another turn.
291
+ *
292
+ * The message kind is `job-exit`: a monitor is a job, and no host reads a
293
+ * separate kind for it. What it is carrying (events, or the end) is in the text.
294
+ */
295
+ export function deliverMonitorNotices(ctx: IterationContext): boolean {
296
+ if (!ctx.monitorNotices) return false
297
+ const text = ctx.awaitedJobs?.takeMonitorDelivery(() => ctx.monitorNotices?.drain())
298
+ if (text === undefined) return false
299
+
300
+ ctx.log.info('Delivering monitor events the turn held itself open for', {
301
+ [NAMZU.TURN_ID]: ctx.recorder.turnId,
302
+ })
303
+ ctx.recorder.pushMessage(createRuntimeContextMessage(formatMonitorNote(text), 'job-exit'))
304
+ ctx.onMonitorNoticeDelivered?.()
305
+ return true
306
+ }
307
+
271
308
  /** Await arrived tracking writes within the caller's existing turn deadline. */
272
309
  export async function drainArrivedTaskCompletions(ctx: IterationContext): Promise<TaskHandle[]> {
273
310
  const inbox = ctx.completionInbox
@@ -418,6 +455,7 @@ export function deliverArrivedCompletions(ctx: IterationContext): void {
418
455
  * messages land in the order the work finished in.
419
456
  */
420
457
  export function deliverArrivedJobExits(ctx: IterationContext): void {
458
+ deliverArrivedMonitorNotices(ctx)
421
459
  const delivered = ctx.awaitedJobs?.takeDelivery(() => ctx.jobNotices?.drain())
422
460
  if (!delivered) return
423
461
 
@@ -434,3 +472,26 @@ export function deliverArrivedJobExits(ctx: IterationContext): void {
434
472
  ctx.recorder.pushMessage(createRuntimeContextMessage(formatJobNote(delivered.text), 'job-exit'))
435
473
  ctx.onJobNoticeDelivered?.()
436
474
  }
475
+
476
+ /**
477
+ * The monitor half of {@link deliverArrivedJobExits}: what a held monitor said
478
+ * after the hold stopped looking is still delivered on the way out, so the
479
+ * transcript has it and a continued thread opens with it. Only for monitors
480
+ * the turn holds itself open for; a session's host hears its own.
481
+ */
482
+ function deliverArrivedMonitorNotices(ctx: IterationContext): void {
483
+ if (!ctx.monitorNotices) return
484
+ const text = ctx.awaitedJobs?.takeMonitorDelivery(() => ctx.monitorNotices?.drain())
485
+ if (text === undefined) return
486
+
487
+ // Fix the turn's answer BEFORE appending anything after it; see
488
+ // `deliverArrivedCompletions` for why the tail walk needs it.
489
+ const answer = ctx.recorder.materializeResult()
490
+ if (answer.length > 0) ctx.recorder.setResult(answer, 'outstanding_work')
491
+
492
+ ctx.log.info('Delivering monitor events the turn would have settled over', {
493
+ [NAMZU.TURN_ID]: ctx.recorder.turnId,
494
+ })
495
+ ctx.recorder.pushMessage(createRuntimeContextMessage(formatMonitorNote(text), 'job-exit'))
496
+ ctx.onMonitorNoticeDelivered?.()
497
+ }
@@ -148,6 +148,15 @@ export interface IterationContext {
148
148
  readonly jobNotices?: SteeringChannel
149
149
  /** Acknowledge exit notices only after their text enters model context. */
150
150
  readonly onJobNoticeDelivered?: () => void
151
+ /**
152
+ * What the turn's monitors have said (and how they ended), drained into the
153
+ * next tool result like {@link jobNotices}. A separate channel because its
154
+ * text is the system-event envelope of untrusted process output, and
155
+ * because a host acknowledges it separately.
156
+ */
157
+ readonly monitorNotices?: SteeringChannel
158
+ /** Acknowledge monitor notices only after their text enters model context. */
159
+ readonly onMonitorNoticeDelivered?: () => void
151
160
  /**
152
161
  * Background jobs the model said it is waiting on, which is the only kind
153
162
  * the loop holds a finishing run open for.
@@ -13,7 +13,7 @@ import {
13
13
  } from '../../project-instructions.js'
14
14
  import { attachRepeatNotice } from '../../repeat-call.js'
15
15
  import { isReviewExempt } from '../../review-policy.js'
16
- import { attachNotice, attachSteering, formatJobNote } from '../../steering.js'
16
+ import { attachNotice, attachSteering, formatJobNote, formatMonitorNote } from '../../steering.js'
17
17
  import { type IterationContext, awaitDecisionDurably } from './context.js'
18
18
 
19
19
  interface VerificationAwareContext extends IterationContext {
@@ -240,17 +240,28 @@ export async function* runToolReview(
240
240
  // user message wedged between them is rejected by the provider. Same
241
241
  // delivery a denial already uses, without the refusal.
242
242
  let jobNoticeDelivered = false
243
+ let monitorNoticeDelivered = false
243
244
  for (const msg of attachRepeatNotice(
244
245
  attachNotice(
245
- attachSteering(batch.messages, ctx.steering, ctx.onSteeringDelivered),
246
- ctx.jobNotices,
247
- formatJobNote,
248
- // The exits that text accounts for have now been read, so the
249
- // record of them stops being pending work. Left standing, it
250
- // buys the model a turn the next time any job queues a notice.
246
+ attachNotice(
247
+ attachSteering(batch.messages, ctx.steering, ctx.onSteeringDelivered),
248
+ ctx.jobNotices,
249
+ formatJobNote,
250
+ // The exits that text accounts for have now been read, so the
251
+ // record of them stops being pending work. Left standing, it
252
+ // buys the model a turn the next time any job queues a notice.
253
+ () => {
254
+ ctx.awaitedJobs?.noticesDelivered()
255
+ jobNoticeDelivered = true
256
+ },
257
+ ),
258
+ ctx.monitorNotices,
259
+ formatMonitorNote,
260
+ // Same rule for what a monitor said: read here, so a turn holding
261
+ // itself open for the monitor does not spend a turn re-reading it.
251
262
  () => {
252
- ctx.awaitedJobs?.noticesDelivered()
253
- jobNoticeDelivered = true
263
+ ctx.awaitedJobs?.monitorNoticesDelivered()
264
+ monitorNoticeDelivered = true
254
265
  },
255
266
  ),
256
267
  notices,
@@ -258,6 +269,7 @@ export async function* runToolReview(
258
269
  ctx.recorder.pushMessage(msg)
259
270
  }
260
271
  if (jobNoticeDelivered) ctx.onJobNoticeDelivered?.()
272
+ if (monitorNoticeDelivered) ctx.onMonitorNoticeDelivered?.()
261
273
  // The complete tool-result batch is already in history before host policy
262
274
  // may react, so a replacement cannot split provider-required adjacency.
263
275
  // Commit each accepted observation before entering the next one. A single
@@ -89,8 +89,41 @@ export async function* releaseTurnResources(
89
89
  // Only jobs bound to this turn. Jobs a host bound to its session are
90
90
  // the host's to stop, when the session ends.
91
91
  if (backgroundJobs && (backgroundJobOwner ?? ctx.turnId) === ctx.turnId) {
92
+ // A monitor still running here is one the turn held itself open for and
93
+ // stopped waiting on (the hold hit its bound). The exit listener is
94
+ // already unsubscribed, so nobody would tell the model or the host it
95
+ // was stopped: say so here, from the release path itself.
96
+ const monitors = backgroundJobs
97
+ .list(ctx.turnId)
98
+ .filter((job) => job.kind === 'monitor' && job.status === 'running')
99
+ for (const job of monitors) {
100
+ ctx.log.warn('A monitor was still running when its turn ended and is being stopped', {
101
+ [NAMZU.TURN_ID]: ctx.turnId,
102
+ 'namzu.jobs.id': job.id,
103
+ 'namzu.monitor.description': job.description ?? '',
104
+ })
105
+ }
92
106
  try {
93
107
  const stopped = await backgroundJobs.killOwner(ctx.turnId)
108
+ for (const job of stopped) {
109
+ if (job.kind !== 'monitor') continue
110
+ await eventTranslator
111
+ .emitEvent({
112
+ type: 'background_job_exited',
113
+ turnId: ctx.turnId,
114
+ jobId: job.id,
115
+ command: job.command,
116
+ status: 'killed',
117
+ ...(job.exitCode !== undefined ? { exitCode: job.exitCode } : {}),
118
+ ...(job.signal ? { signal: job.signal } : {}),
119
+ monitor: {
120
+ description: job.description ?? '',
121
+ stopReason: job.stopReason ?? 'stopped',
122
+ },
123
+ })
124
+ .catch(() => {})
125
+ }
126
+ if (monitors.length > 0) yield* eventTranslator.drainPending()
94
127
  if (stopped.length > 0) {
95
128
  ctx.log.info('Background jobs stopped with the turn', {
96
129
  [NAMZU.TURN_ID]: ctx.turnId,
@@ -1,4 +1,10 @@
1
1
  import type { Message, UserMessage } from '../../types/message/index.js'
2
+ import {
3
+ MONITOR_EVENT_CONTENT_MAX_CHARS,
4
+ MONITOR_NOTIFICATION_MAX_CHARS,
5
+ } from '../jobs/monitor-stream.js'
6
+ import { type BackgroundJob, type MonitorEvent, describeMonitorEnd } from '../jobs/registry.js'
7
+ import { formatSystemEvent, oneMetadataLine } from '../system-events.js'
2
8
 
3
9
  /** User-role runtime reports are context, not new operator intent. */
4
10
  export function isOperatorUserMessage(message: Message): message is UserMessage {
@@ -135,6 +141,170 @@ export function formatJobNote(text: string): string {
135
141
  return `\n\n[Background job update]\n${text}`
136
142
  }
137
143
 
144
+ /**
145
+ * What the model reads when a monitor it started has something to say.
146
+ *
147
+ * One notification is bounded by {@link MONITOR_NOTIFICATION_MAX_CHARS}, at the
148
+ * granularity of a whole event: the earliest events say what the watch was and
149
+ * the latest say where it stands, and the middle is the part a reader can best
150
+ * do without. The cut is announced, never silent, and never inside an
151
+ * envelope: a half event would leave an unterminated frame and a fragment of
152
+ * process output outside any frame at all. The events themselves are already
153
+ * small (a batch is at most 3000 characters); this bound is for the queue that
154
+ * piled up while the model was busy.
155
+ */
156
+ export function formatMonitorNote(text: string): string {
157
+ return `\n\n[Monitor update]\n${capMonitorNotifications(text)}`
158
+ }
159
+
160
+ /** One `formatSystemEvent` rendering, found by its nonce-bound opening and closing tags. */
161
+ const SYSTEM_EVENT_FRAME = /^<system-event-([A-Za-z0-9]+) [^\n]*\n[\s\S]*?\n<\/system-event-\1>$/gm
162
+
163
+ /**
164
+ * One monitor event as the system-event envelope the model reads, with the
165
+ * watched command's output inside the untrusted frame. The kernel renders an
166
+ * in-turn delivery with this, and a host that holds events for a later turn
167
+ * renders them with it too, so both read the same words.
168
+ */
169
+ export function formatMonitorEvent(
170
+ event: Pick<MonitorEvent, 'jobId' | 'description' | 'kind' | 'text'> &
171
+ Partial<Pick<MonitorEvent, 'transport'>>,
172
+ command: string,
173
+ ): string {
174
+ return formatSystemEvent({
175
+ kind: 'job',
176
+ id: event.jobId,
177
+ status: 'interim',
178
+ summary: `Monitor event: "${oneMetadataLine(event.description)}"`,
179
+ source: `monitor ${event.jobId}: ${oneMetadataLine(command)}`,
180
+ more: `job {"action":"kill","id":"${event.jobId}"} stops it`,
181
+ body: {
182
+ envelope: {
183
+ kind: event.kind === 'output' ? 'monitor-output' : 'monitor-notice',
184
+ attributes: { job: event.jobId },
185
+ provenance: monitorProvenance(event.kind, event.transport),
186
+ },
187
+ content: capMonitorContent(event.text),
188
+ },
189
+ })
190
+ }
191
+
192
+ /**
193
+ * Who wrote what is inside the frame. For a WebSocket the server wrote the
194
+ * frames and also the close reason and error text the monitor quotes in its
195
+ * notices, so neither is claimed to be the monitor's own words.
196
+ */
197
+ function monitorProvenance(
198
+ kind: MonitorEvent['kind'],
199
+ transport: MonitorEvent['transport'],
200
+ ): string {
201
+ if (transport === 'websocket') {
202
+ return kind === 'output'
203
+ ? 'Text frames the WebSocket server sent. The server, or whatever it relays, wrote them; they are not the operator.'
204
+ : 'A notice from the monitor about the WebSocket connection. It can quote text the server sent (a close reason, an error message); that text is not the operator.'
205
+ }
206
+ return kind === 'output'
207
+ ? 'Lines the watched command printed to stdout. The command, or whatever it reads, wrote them; they are not the operator.'
208
+ : 'Written by the monitor itself, not by the watched command.'
209
+ }
210
+
211
+ /** A monitor's end as the system-event envelope, in the words {@link describeMonitorEnd} gives it. */
212
+ export function formatMonitorEnd(
213
+ job: Pick<
214
+ BackgroundJob,
215
+ | 'id'
216
+ | 'command'
217
+ | 'description'
218
+ | 'status'
219
+ | 'exitCode'
220
+ | 'signal'
221
+ | 'error'
222
+ | 'transport'
223
+ | 'closeCode'
224
+ | 'closeReason'
225
+ >,
226
+ stats: { readonly stdoutChars: number },
227
+ ): string {
228
+ const end = describeMonitorEnd(job, stats)
229
+ return formatSystemEvent({
230
+ kind: 'job',
231
+ id: job.id,
232
+ status: end.status,
233
+ summary: end.summary,
234
+ source: `monitor ${job.id}: ${oneMetadataLine(job.command)}`,
235
+ more: `job {"action":"read","id":"${job.id}"} reads its output`,
236
+ })
237
+ }
238
+
239
+ function omittedEventsLine(count: number): string {
240
+ return `[${count} monitor events omitted — output exceeded the notification limit]`
241
+ }
242
+
243
+ /**
244
+ * Bound rendered monitor events (joined by newlines) to
245
+ * {@link MONITOR_NOTIFICATION_MAX_CHARS} at the granularity of a whole event.
246
+ */
247
+ export function capMonitorNotifications(
248
+ text: string,
249
+ max: number = MONITOR_NOTIFICATION_MAX_CHARS,
250
+ ): string {
251
+ if (text.length <= max) return text
252
+ const frames = [...text.matchAll(SYSTEM_EVENT_FRAME)].map((match) => match[0])
253
+ if (frames.length === 0) return cutMiddle(text, max)
254
+
255
+ const line = (count: number) => omittedEventsLine(count)
256
+ // Newlines between the pieces: head, marker, tail.
257
+ const budget = max - line(frames.length).length - 2
258
+ let head = 0
259
+ let headChars = 0
260
+ while (head < frames.length) {
261
+ const size = (frames[head]?.length ?? 0) + 1
262
+ if (headChars + size > budget / 2) break
263
+ headChars += size
264
+ head++
265
+ }
266
+ let tail = 0
267
+ let tailChars = 0
268
+ while (head + tail < frames.length) {
269
+ const frame = frames[frames.length - 1 - tail]
270
+ const size = (frame?.length ?? 0) + 1
271
+ if (headChars + tailChars + size > budget) break
272
+ tailChars += size
273
+ tail++
274
+ }
275
+ const omitted = frames.length - head - tail
276
+ // A single event alone over the cap is dropped whole rather than cut: its
277
+ // content is shrunk before framing (see `capMonitorContent`), so this only
278
+ // means a caller handed in something that was not.
279
+ const kept = [
280
+ ...frames.slice(0, head),
281
+ ...(omitted > 0 ? [line(omitted)] : []),
282
+ ...frames.slice(frames.length - tail),
283
+ ]
284
+ return kept.join('\n')
285
+ }
286
+
287
+ /**
288
+ * Shrink one event's content, in the middle, BEFORE it is framed. Cutting the
289
+ * framed text afterwards would cut the envelope.
290
+ */
291
+ export function capMonitorContent(
292
+ text: string,
293
+ max: number = MONITOR_EVENT_CONTENT_MAX_CHARS,
294
+ ): string {
295
+ return text.length <= max ? text : cutMiddle(text, max)
296
+ }
297
+
298
+ function cutMiddle(text: string, max: number): string {
299
+ if (text.length <= max) return text
300
+ const marker = (omitted: number) => `\n...[${omitted} characters omitted]...\n`
301
+ // Reserve room for the marker itself so the result never exceeds `max`.
302
+ const room = Math.max(0, max - marker(text.length).length)
303
+ const head = Math.ceil(room / 2)
304
+ const tail = room - head
305
+ return `${text.slice(0, head)}${marker(text.length - head - tail)}${text.slice(text.length - tail)}`
306
+ }
307
+
138
308
  const STEERING_NOTE_HEADER =
139
309
  '\n\n[Message from the operator, received while this tool was running]\n'
140
310
 
@@ -46,6 +46,10 @@ export interface ToolingBootstrapConfig {
46
46
  backgroundJobOwner?: string
47
47
  /** Where `wait_for_job` records wait-intent; see the executor's own field. */
48
48
  onJobAwaited?: (id: string) => void
49
+ /** See `ToolExecutorConfig.monitorTimeoutCapMs`. */
50
+ monitorTimeoutCapMs?: number
51
+ /** See `ToolExecutorConfig.onMonitorStarted`. */
52
+ onMonitorStarted?: (id: string) => void
49
53
  /** Where the `skill` tool reads from. */
50
54
  skills?: SkillRegistryRef
51
55
  /** How this turn reaches the web. */
@@ -106,6 +110,10 @@ export class ToolingBootstrap {
106
110
  ...(config.backgroundJobs ? { backgroundJobs: config.backgroundJobs } : {}),
107
111
  ...(config.backgroundJobOwner ? { backgroundJobOwner: config.backgroundJobOwner } : {}),
108
112
  ...(config.onJobAwaited ? { onJobAwaited: config.onJobAwaited } : {}),
113
+ ...(config.monitorTimeoutCapMs !== undefined
114
+ ? { monitorTimeoutCapMs: config.monitorTimeoutCapMs }
115
+ : {}),
116
+ ...(config.onMonitorStarted ? { onMonitorStarted: config.onMonitorStarted } : {}),
109
117
  ...(config.skills ? { skills: config.skills } : {}),
110
118
  ...(config.web ? { web: config.web } : {}),
111
119
  ...(config.toolTimeoutMs !== undefined ? { toolTimeoutMs: config.toolTimeoutMs } : {}),
@@ -148,6 +148,23 @@ function neutralizeSystemEventDelimiter(text: string): string {
148
148
  return text.replace(SYSTEM_EVENT_KEYWORD, 'system_event')
149
149
  }
150
150
 
151
+ /** The longest a model-chosen value may be inside a metadata line. */
152
+ export const SYSTEM_EVENT_METADATA_MAX_CHARS = 200
153
+
154
+ /**
155
+ * A value the kernel did not author, made safe for ONE metadata line: every
156
+ * whitespace run (newlines included, so it cannot forge a `summary:` or
157
+ * `source:` line of its own) becomes a single space, and what is left is cut
158
+ * to `max` characters with an ellipsis.
159
+ */
160
+ export function oneMetadataLine(
161
+ value: string,
162
+ max: number = SYSTEM_EVENT_METADATA_MAX_CHARS,
163
+ ): string {
164
+ const line = value.replace(/\s+/g, ' ').trim()
165
+ return line.length <= max ? line : `${line.slice(0, Math.max(0, max - 1))}…`
166
+ }
167
+
151
168
  /** Escape a value so it cannot rewrite the tag it appears in. See `untrusted-envelope.ts`. */
152
169
  function escapeAttribute(value: string): string {
153
170
  return value
@@ -66,7 +66,7 @@ const MAX_BASH_TIMEOUT_MS = readPositiveIntEnv('NAMZU_BASH_MAX_TIMEOUT_MS', 10 *
66
66
  */
67
67
  export const DISABLE_BACKGROUND_TASKS_ENV = 'NAMZU_DISABLE_BACKGROUND_TASKS'
68
68
 
69
- function backgroundTasksDisabled(): boolean {
69
+ export function backgroundTasksDisabled(): boolean {
70
70
  const value = process.env[DISABLE_BACKGROUND_TASKS_ENV]?.trim().toLowerCase()
71
71
  return value !== undefined && value !== '' && value !== '0' && value !== 'false'
72
72
  }
@@ -442,7 +442,8 @@ export function execHostShell(
442
442
  })
443
443
  }
444
444
 
445
- function isDangerousCommand(command: string): boolean {
445
+ /** The dangerous-pattern check `bash` applies, shared so the `monitor` tool refuses the same lines. */
446
+ export function isDangerousCommand(command: string): boolean {
446
447
  return DANGEROUS_PATTERNS.some((pattern) => pattern.test(command))
447
448
  }
448
449
 
@@ -11,6 +11,12 @@ export {
11
11
  export { LSP_TOOL_NAME, LspTool, getCodeNavigationTools } from './lsp.js'
12
12
  export { GrepTool } from './grep.js'
13
13
  export { JobTool } from './job.js'
14
+ export {
15
+ MONITOR_CANNOT_DETACH,
16
+ MONITOR_TOOL_NAME,
17
+ MonitorTool,
18
+ getMonitorTools,
19
+ } from './monitor.js'
14
20
  export { WaitForJobTool } from './wait-for-job.js'
15
21
  export { SKILL_TOOL_NAME, SkillTool, createSkillTool, parseAllowedTools } from './skill.js'
16
22
  export type {
@@ -44,7 +44,7 @@ type JobInput = z.infer<typeof inputSchema>
44
44
  export const JobTool = defineTool({
45
45
  name: 'job',
46
46
  description:
47
- 'Reads, lists and stops background jobs started by bash with run_in_background. Do NOT call action "read" in a loop to find out whether a job finished: use wait_for_job instead — it blocks until the job ends (or goes quiet, or runs long) and returns the output in one call, with no waiting turns spent checking. Use "read" to see output while a job keeps running, or to pick up after a wait_for_job call gave up, passing the previous call\'s next_offset to see only new output.',
47
+ 'Reads, lists and stops background jobs started by bash with run_in_background, and monitors started by monitor (whose stderr, which is not an event, is read here). Do NOT call action "read" in a loop to find out whether a job finished: use wait_for_job instead — it blocks until the job ends (or goes quiet, or runs long) and returns the output in one call, with no waiting turns spent checking. Use "read" to see output while a job keeps running, or to pick up after a wait_for_job call gave up, passing the previous call\'s next_offset to see only new output.',
48
48
  inputSchema,
49
49
  category: 'shell',
50
50
  permissions: ['shell_execute'],
@@ -78,7 +78,16 @@ export const JobTool = defineTool({
78
78
  if (all.length === 0) return { success: true, output: 'No background jobs.' }
79
79
  return {
80
80
  success: true,
81
- output: all.map((job) => `${job.id} ${job.status.padEnd(8)} ${job.command}`).join('\n'),
81
+ // A monitor is named by what it watches: its command is the means,
82
+ // and the description is what the model wrote to tell its watches apart.
83
+ output: all
84
+ .map(
85
+ (job) =>
86
+ `${job.id} ${job.status.padEnd(8)} ${
87
+ job.kind === 'monitor' ? `monitor · ${job.description ?? job.command}` : job.command
88
+ }`,
89
+ )
90
+ .join('\n'),
82
91
  data: { jobs: all },
83
92
  }
84
93
  }