@namzu/sdk 52.2.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 (126) hide show
  1. package/CHANGELOG.md +6 -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/contracts/session/api.d.ts +1 -1
  9. package/dist/contracts/session/api.d.ts.map +1 -1
  10. package/dist/index.d.ts +4 -2
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +2 -1
  13. package/dist/index.js.map +1 -1
  14. package/dist/pals/channels/routing.d.ts +1 -1
  15. package/dist/pals/channels/schema.d.ts +7 -7
  16. package/dist/pals/communication/ingress-schema.d.ts +32 -32
  17. package/dist/public-runtime.d.ts +1 -1
  18. package/dist/public-runtime.d.ts.map +1 -1
  19. package/dist/public-runtime.js +1 -1
  20. package/dist/public-runtime.js.map +1 -1
  21. package/dist/public-tools.d.ts +2 -1
  22. package/dist/public-tools.d.ts.map +1 -1
  23. package/dist/public-tools.js +6 -1
  24. package/dist/public-tools.js.map +1 -1
  25. package/dist/runtime/jobs/awaited-jobs.d.ts +59 -2
  26. package/dist/runtime/jobs/awaited-jobs.d.ts.map +1 -1
  27. package/dist/runtime/jobs/awaited-jobs.js +108 -5
  28. package/dist/runtime/jobs/awaited-jobs.js.map +1 -1
  29. package/dist/runtime/jobs/monitor-stream.d.ts +96 -0
  30. package/dist/runtime/jobs/monitor-stream.d.ts.map +1 -0
  31. package/dist/runtime/jobs/monitor-stream.js +221 -0
  32. package/dist/runtime/jobs/monitor-stream.js.map +1 -0
  33. package/dist/runtime/jobs/registry.d.ts +96 -0
  34. package/dist/runtime/jobs/registry.d.ts.map +1 -1
  35. package/dist/runtime/jobs/registry.js +203 -20
  36. package/dist/runtime/jobs/registry.js.map +1 -1
  37. package/dist/runtime/query/executor.d.ts +9 -0
  38. package/dist/runtime/query/executor.d.ts.map +1 -1
  39. package/dist/runtime/query/executor.js +6 -0
  40. package/dist/runtime/query/executor.js.map +1 -1
  41. package/dist/runtime/query/index.d.ts +16 -0
  42. package/dist/runtime/query/index.d.ts.map +1 -1
  43. package/dist/runtime/query/index.js +93 -3
  44. package/dist/runtime/query/index.js.map +1 -1
  45. package/dist/runtime/query/iteration/outstanding-work.d.ts +14 -0
  46. package/dist/runtime/query/iteration/outstanding-work.d.ts.map +1 -1
  47. package/dist/runtime/query/iteration/outstanding-work.js +64 -3
  48. package/dist/runtime/query/iteration/outstanding-work.js.map +1 -1
  49. package/dist/runtime/query/iteration/phases/context.d.ts +9 -0
  50. package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
  51. package/dist/runtime/query/iteration/phases/context.js.map +1 -1
  52. package/dist/runtime/query/iteration/phases/tool-review.d.ts.map +1 -1
  53. package/dist/runtime/query/iteration/phases/tool-review.js +11 -2
  54. package/dist/runtime/query/iteration/phases/tool-review.js.map +1 -1
  55. package/dist/runtime/query/release-turn.d.ts.map +1 -1
  56. package/dist/runtime/query/release-turn.js +35 -0
  57. package/dist/runtime/query/release-turn.js.map +1 -1
  58. package/dist/runtime/query/steering.d.ts +35 -0
  59. package/dist/runtime/query/steering.d.ts.map +1 -1
  60. package/dist/runtime/query/steering.js +121 -0
  61. package/dist/runtime/query/steering.js.map +1 -1
  62. package/dist/runtime/query/tooling.d.ts +4 -0
  63. package/dist/runtime/query/tooling.d.ts.map +1 -1
  64. package/dist/runtime/query/tooling.js +4 -0
  65. package/dist/runtime/query/tooling.js.map +1 -1
  66. package/dist/runtime/system-events.d.ts +9 -0
  67. package/dist/runtime/system-events.d.ts.map +1 -1
  68. package/dist/runtime/system-events.js +12 -0
  69. package/dist/runtime/system-events.js.map +1 -1
  70. package/dist/tools/builtins/bash.d.ts +3 -0
  71. package/dist/tools/builtins/bash.d.ts.map +1 -1
  72. package/dist/tools/builtins/bash.js +3 -2
  73. package/dist/tools/builtins/bash.js.map +1 -1
  74. package/dist/tools/builtins/index.d.ts +1 -0
  75. package/dist/tools/builtins/index.d.ts.map +1 -1
  76. package/dist/tools/builtins/index.js +1 -0
  77. package/dist/tools/builtins/index.js.map +1 -1
  78. package/dist/tools/builtins/job.d.ts.map +1 -1
  79. package/dist/tools/builtins/job.js +6 -2
  80. package/dist/tools/builtins/job.js.map +1 -1
  81. package/dist/tools/builtins/monitor.d.ts +24 -0
  82. package/dist/tools/builtins/monitor.d.ts.map +1 -0
  83. package/dist/tools/builtins/monitor.js +167 -0
  84. package/dist/tools/builtins/monitor.js.map +1 -0
  85. package/dist/tools/builtins/wait-for-job.d.ts +1 -1
  86. package/dist/turn/reporter.d.ts.map +1 -1
  87. package/dist/turn/reporter.js +8 -0
  88. package/dist/turn/reporter.js.map +1 -1
  89. package/dist/types/session/events.d.ts +30 -4
  90. package/dist/types/session/events.d.ts.map +1 -1
  91. package/dist/types/session/events.js +6 -1
  92. package/dist/types/session/events.js.map +1 -1
  93. package/dist/types/session/records.d.ts +2 -2
  94. package/dist/types/session/records.d.ts.map +1 -1
  95. package/dist/types/session/records.js +3 -2
  96. package/dist/types/session/records.js.map +1 -1
  97. package/dist/types/tool/index.d.ts +25 -0
  98. package/dist/types/tool/index.d.ts.map +1 -1
  99. package/dist/types/tool/index.js.map +1 -1
  100. package/package.json +1 -1
  101. package/src/bridge/a2a/mapper.ts +3 -0
  102. package/src/bridge/sse/mapper.ts +18 -0
  103. package/src/contracts/session/api.ts +1 -0
  104. package/src/index.ts +23 -0
  105. package/src/public-runtime.ts +3 -0
  106. package/src/public-tools.ts +11 -0
  107. package/src/runtime/jobs/awaited-jobs.ts +127 -5
  108. package/src/runtime/jobs/monitor-stream.ts +269 -0
  109. package/src/runtime/jobs/registry.ts +286 -21
  110. package/src/runtime/query/executor.ts +15 -0
  111. package/src/runtime/query/index.ts +116 -3
  112. package/src/runtime/query/iteration/outstanding-work.ts +64 -3
  113. package/src/runtime/query/iteration/phases/context.ts +9 -0
  114. package/src/runtime/query/iteration/phases/tool-review.ts +21 -9
  115. package/src/runtime/query/release-turn.ts +33 -0
  116. package/src/runtime/query/steering.ts +144 -0
  117. package/src/runtime/query/tooling.ts +8 -0
  118. package/src/runtime/system-events.ts +17 -0
  119. package/src/tools/builtins/bash.ts +3 -2
  120. package/src/tools/builtins/index.ts +6 -0
  121. package/src/tools/builtins/job.ts +11 -2
  122. package/src/tools/builtins/monitor.ts +206 -0
  123. package/src/turn/reporter.ts +8 -0
  124. package/src/types/session/events.ts +31 -4
  125. package/src/types/session/records.ts +3 -2
  126. package/src/types/tool/index.ts +24 -1
@@ -7,7 +7,30 @@ import type { BackgroundJob, BackgroundJobRegistry } from './registry.js'
7
7
  * test that needs two exits and nothing else should not have to spawn
8
8
  * processes to produce them.
9
9
  */
10
- export type AwaitedJobSource = Pick<BackgroundJobRegistry, 'get' | 'onExit'>
10
+ export type AwaitedJobSource = Pick<BackgroundJobRegistry, 'get' | 'onExit'> &
11
+ Partial<Pick<BackgroundJobRegistry, 'onMonitorEvent'>>
12
+
13
+ /**
14
+ * Turn the adapter into the holder of a turn for the monitors the TURN owns.
15
+ *
16
+ * A monitor is the one kind of job whose point is that it keeps going, so
17
+ * "the model said it is waiting" cannot be the signal that holds a turn open
18
+ * for it: the model starts it and goes on. The signal is ownership instead. A
19
+ * turn that owns its jobs itself (a sub-agent, a plain SDK host: nobody
20
+ * outside the turn is listening) has no one else to hear a monitor's events,
21
+ * so ending while one runs would start a watch and discard everything it
22
+ * finds. A SESSION-owned monitor never comes through here: its host hears the
23
+ * events and wakes on them, and holding the turn for it would delay the end of
24
+ * every turn for as long as the watch lives.
25
+ */
26
+ export interface MonitorHold {
27
+ /**
28
+ * Whether the monitor notices the turn queued are still unread. The same
29
+ * second opinion `unreadNotice` is for exits: it can only narrow what counts
30
+ * as pending, never open a hold.
31
+ */
32
+ readonly unreadNotice: () => boolean
33
+ }
11
34
 
12
35
  /** Exits and the text that puts them in front of the model. See {@link AwaitedJobs.takeDelivery}. */
13
36
  export interface DeliverableJobExits {
@@ -49,6 +72,10 @@ export class AwaitedJobs {
49
72
  private exits: BackgroundJob[] = []
50
73
  private readonly arrivals = new Set<() => void>()
51
74
  private detach?: () => void
75
+ /** Running monitors the turn owns and holds itself open for. */
76
+ private readonly monitors = new Set<string>()
77
+ /** Monitor events and ends since the notices were last delivered. */
78
+ private monitorArrivals = 0
52
79
 
53
80
  constructor(
54
81
  private readonly source: AwaitedJobSource,
@@ -83,6 +110,8 @@ export class AwaitedJobs {
83
110
  * unread by definition.
84
111
  */
85
112
  private readonly unreadNotice?: () => boolean,
113
+ /** Present when the turn owns its monitors; see {@link MonitorHold}. */
114
+ private readonly monitorHold?: MonitorHold,
86
115
  ) {}
87
116
 
88
117
  /**
@@ -94,20 +123,109 @@ export class AwaitedJobs {
94
123
  */
95
124
  attach(): () => void {
96
125
  if (this.detach) return this.detach
97
- this.detach = this.source.onExit((job) => {
126
+ const stopMonitorEvents = this.monitorHold
127
+ ? this.source.onMonitorEvent?.((event) => {
128
+ if (event.owner !== this.owner || !this.monitors.has(event.jobId)) return
129
+ this.arrive()
130
+ })
131
+ : undefined
132
+ const stopExits = this.source.onExit((job) => {
98
133
  // `onExit` is a broadcast over a registry a host may share between
99
134
  // runs, and the filter is the same one the exit-notice channel
100
135
  // applies in `runtime/query/index.ts`.
101
136
  if (job.owner !== this.owner) return
137
+ // A monitor's end is news of its own kind, delivered with its events
138
+ // rather than paired with a job-exit notice. It also leaves the
139
+ // awaited set if the model happened to `wait_for_job` it, so the one
140
+ // end cannot be waited for twice.
141
+ if (this.monitors.delete(job.id)) {
142
+ this.outstanding.delete(job.id)
143
+ this.arrive()
144
+ return
145
+ }
102
146
  // `outstanding` is the gate, so an id nobody awaited is ignored and
103
147
  // a second announcement for the same job cannot queue twice.
104
148
  if (!this.outstanding.delete(job.id)) return
105
149
  this.exits.push(job)
106
150
  for (const wake of [...this.arrivals]) wake()
107
151
  })
152
+ this.detach = () => {
153
+ stopMonitorEvents?.()
154
+ stopExits()
155
+ }
108
156
  return this.detach
109
157
  }
110
158
 
159
+ private arrive(): void {
160
+ this.monitorArrivals++
161
+ for (const wake of [...this.arrivals]) wake()
162
+ }
163
+
164
+ /**
165
+ * Say that the turn owns this running monitor and must not end while it
166
+ * runs. Called by the executor when the bound registry starts one, and a
167
+ * no-op on a turn that does not own its monitors.
168
+ */
169
+ expectMonitor(id: string): void {
170
+ if (!this.monitorHold) return
171
+ let job: BackgroundJob
172
+ try {
173
+ job = this.source.get(id)
174
+ } catch {
175
+ return
176
+ }
177
+ if (job.owner !== this.owner || job.status !== 'running') return
178
+ this.monitors.add(id)
179
+ }
180
+
181
+ /** Monitor events or ends the model has not been shown yet. */
182
+ private get unreadMonitor(): boolean {
183
+ if (this.monitorArrivals === 0) return false
184
+ return this.monitorHold?.unreadNotice() ?? false
185
+ }
186
+
187
+ /**
188
+ * Milliseconds until the last held monitor expires, or `undefined` when the
189
+ * turn holds none. A monitor's own expiry is the bound on holding for it: a
190
+ * hold that ended earlier would walk away from a watch with time left, and
191
+ * one that ended later would be waiting for a stop that has already come.
192
+ */
193
+ monitorHoldMs(now: number = Date.now()): number | undefined {
194
+ let until: number | undefined
195
+ for (const id of this.monitors) {
196
+ let expiresAt: number | undefined
197
+ try {
198
+ expiresAt = this.source.get(id).expiresAt
199
+ } catch {
200
+ continue
201
+ }
202
+ if (expiresAt !== undefined) until = Math.max(until ?? 0, expiresAt)
203
+ }
204
+ return until === undefined ? undefined : Math.max(0, until - now)
205
+ }
206
+
207
+ /**
208
+ * Say that the queued monitor notices have been put in front of the model.
209
+ * The counterpart of {@link noticesDelivered}, for the tool-result path.
210
+ */
211
+ monitorNoticesDelivered(): void {
212
+ this.monitorArrivals = 0
213
+ }
214
+
215
+ /**
216
+ * Take the monitor notices together with the record that they were
217
+ * delivered, or neither. The pairing is {@link takeDelivery}'s, for the
218
+ * same reason: marking them delivered when no text was written would
219
+ * strand what arrived.
220
+ */
221
+ takeMonitorDelivery(notice: () => string | undefined): string | undefined {
222
+ if (this.monitorArrivals === 0) return undefined
223
+ const text = notice()
224
+ if (text === undefined) return undefined
225
+ this.monitorArrivals = 0
226
+ return text
227
+ }
228
+
111
229
  /**
112
230
  * Say that the model is waiting on this job's exit.
113
231
  *
@@ -150,7 +268,9 @@ export class AwaitedJobs {
150
268
 
151
269
  /** Whether an awaited job is still running, or one has exited unread. */
152
270
  get hasPendingWork(): boolean {
153
- return this.unreadExits || this.outstanding.size > 0
271
+ return (
272
+ this.unreadExits || this.outstanding.size > 0 || this.monitors.size > 0 || this.unreadMonitor
273
+ )
154
274
  }
155
275
 
156
276
  /**
@@ -163,8 +283,8 @@ export class AwaitedJobs {
163
283
  */
164
284
  waitForArrival(timeoutMs: number, signal?: AbortSignal): Promise<void> {
165
285
  if (signal?.aborted) return Promise.resolve()
166
- if (this.unreadExits) return Promise.resolve()
167
- if (this.outstanding.size === 0) return Promise.resolve()
286
+ if (this.unreadExits || this.unreadMonitor) return Promise.resolve()
287
+ if (this.outstanding.size === 0 && this.monitors.size === 0) return Promise.resolve()
168
288
 
169
289
  return new Promise((resolve) => {
170
290
  const finish = (): void => {
@@ -263,6 +383,8 @@ export class AwaitedJobs {
263
383
  this.detach?.()
264
384
  this.detach = undefined
265
385
  this.outstanding.clear()
386
+ this.monitors.clear()
387
+ this.monitorArrivals = 0
266
388
  this.exits = []
267
389
  // Release anyone still waiting. A closed adapter would otherwise hold
268
390
  // them to their own deadline for an exit that can no longer reach them.
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Turns a command's stdout into a few model-sized events.
3
+ *
4
+ * A watcher (`tail -f`, a poll loop, `gh run watch`) can print a line every
5
+ * millisecond, and every line the model is told about costs a context resend.
6
+ * So a monitor does not forward lines, it forwards BATCHES: lines are split,
7
+ * trimmed and truncated here, held for a short fixed window, and delivered
8
+ * together, and the batches themselves are rationed by a token bucket. A
9
+ * source that outruns the ration is first told so, then stopped, because a
10
+ * model that is flooded stops reading and one that is silenced does not know
11
+ * to look.
12
+ *
13
+ * Pure: no process, no registry, no model. The clock and the timer are
14
+ * injectable (and default to the globals read at call time, so fake timers
15
+ * work without any injection), which is what lets the tests advance a window
16
+ * by calling a function rather than by waiting for one.
17
+ *
18
+ * The constants and the wording come from the reference implementation of this
19
+ * tool; deviations are named where they are made.
20
+ */
21
+
22
+ /** A longer line keeps its first this-many characters. */
23
+ export const MONITOR_LINE_MAX_CHARS = 500
24
+ /** A joined batch longer than this keeps its first this-many characters. */
25
+ export const MONITOR_BATCH_MAX_CHARS = 3000
26
+ /**
27
+ * The first buffered line arms ONE timer for this long; everything that
28
+ * arrives before it fires rides in the same batch. A fixed window, not a
29
+ * debounce: a source that never pauses still gets a batch every window
30
+ * instead of never.
31
+ */
32
+ export const MONITOR_BATCH_WINDOW_MS = 200
33
+ /** A partial line (no newline yet) keeps its tail past this many bytes. */
34
+ export const MONITOR_PARTIAL_LINE_MAX_BYTES = 1024 * 1024
35
+ /** One token per BATCH, not per line. */
36
+ export const MONITOR_BUCKET_CAPACITY = 10
37
+ export const MONITOR_BUCKET_REFILL_MS = 2000
38
+ /** Suppression older than this, with another batch dropped, stops the monitor. */
39
+ export const MONITOR_SUPPRESSION_LIMIT_MS = 30_000
40
+ /** The effective ceiling on a monitor's life; a host may lower it, never raise it past the schema. */
41
+ export const MONITOR_TIMEOUT_CAP_MS = 1_800_000
42
+ export const MONITOR_TIMEOUT_DEFAULT_MS = 300_000
43
+ export const MONITOR_TIMEOUT_MIN_MS = 1000
44
+ /** The schema ceiling; anything between the cap and this is clamped, not refused. */
45
+ export const MONITOR_TIMEOUT_MAX_MS = 3_600_000
46
+ /** One notification handed to the model is cut to this many characters, in the middle. */
47
+ export const MONITOR_NOTIFICATION_MAX_CHARS = 100_000
48
+ /**
49
+ * One event's content is shrunk to this many characters BEFORE it is framed,
50
+ * so the notification cap never has to cut inside an envelope. A batch is at
51
+ * most 3000, so this only matters to a notice or a caller that outgrew that.
52
+ */
53
+ export const MONITOR_EVENT_CONTENT_MAX_CHARS = 20_000
54
+
55
+ const LINE_TRUNCATED = '...(truncated)'
56
+ const BATCH_TRUNCATED = '\n...(truncated)'
57
+
58
+ /** `30m`, `5m`, `90s`: whole minutes as minutes, anything else in seconds. */
59
+ export function formatMonitorDuration(ms: number): string {
60
+ const seconds = Math.max(1, Math.round(ms / 1000))
61
+ return seconds % 60 === 0 ? `${seconds / 60}m` : `${seconds}s`
62
+ }
63
+
64
+ export function suppressionNotice(count: number): string {
65
+ return `[${count} events suppressed — output rate too high. Consider stopping this monitor and starting it with a more selective command.]`
66
+ }
67
+
68
+ export function tooMuchOutputNotice(count: number, overMs: number): string {
69
+ return `[Monitor stopped — too much output (${count} events suppressed over ${formatMonitorDuration(overMs)}). Restart it with a more selective command.]`
70
+ }
71
+
72
+ export function expiryNotice(ms: number, delivered: number): string {
73
+ return delivered > 0
74
+ ? `[Monitor expired after ${formatMonitorDuration(ms)} with ${delivered} events delivered. Start it again if you still need the watch.]`
75
+ : `[Monitor expired after ${formatMonitorDuration(ms)} with no events delivered. Start it again if you still need the watch — and widen the filter if silence was unexpected.]`
76
+ }
77
+
78
+ export interface MonitorStreamStats {
79
+ /** Batches handed to `onBatch`. */
80
+ readonly eventsDelivered: number
81
+ /** Batches the bucket dropped, over the monitor's whole life. */
82
+ readonly suppressed: number
83
+ /** Characters pushed in, before any trimming: zero means the command never wrote to stdout. */
84
+ readonly stdoutChars: number
85
+ }
86
+
87
+ export interface MonitorStreamOptions {
88
+ /** One batch of lines, joined with `\n`. */
89
+ onBatch(text: string): void
90
+ /** Housekeeping text from the stream itself (the suppression notice). Not rationed. */
91
+ onNotice(text: string): void
92
+ /**
93
+ * The source was suppressed for too long and another batch was dropped. The
94
+ * stream is closed before this is called; stopping the process is the
95
+ * caller's.
96
+ */
97
+ onOverflow(info: { readonly suppressed: number; readonly overMs: number }): void
98
+ now?: () => number
99
+ setTimer?: (callback: () => void, ms: number) => unknown
100
+ clearTimer?: (handle: unknown) => void
101
+ }
102
+
103
+ export interface MonitorStream {
104
+ /** Feed raw stdout text; chunks may split a line anywhere. */
105
+ push(text: string): void
106
+ /** Emit the pending batch now, without the partial line. For expiry. */
107
+ flush(): void
108
+ /** The end of the stream: the partial line counts as a line, then everything pending is emitted. */
109
+ drain(): void
110
+ /** Stop for good. Whatever is pending is discarded. */
111
+ close(): void
112
+ stats(): MonitorStreamStats
113
+ }
114
+
115
+ /** The tail of `text` that fits `maxBytes`, starting on a whole code point. */
116
+ function keepTail(text: string, maxBytes: number): string {
117
+ const bytes = Buffer.from(text)
118
+ if (bytes.length <= maxBytes) return text
119
+ let start = bytes.length - maxBytes
120
+ while (start < bytes.length && ((bytes[start] ?? 0) & 0xc0) === 0x80) start++
121
+ return bytes.subarray(start).toString('utf8')
122
+ }
123
+
124
+ export function createMonitorStream(options: MonitorStreamOptions): MonitorStream {
125
+ const now = options.now ?? (() => Date.now())
126
+ const setTimer =
127
+ options.setTimer ??
128
+ ((callback: () => void, ms: number) => {
129
+ const handle = setTimeout(callback, ms)
130
+ // A pending batch must not keep a process alive; the job's own exit
131
+ // drains it.
132
+ handle.unref?.()
133
+ return handle
134
+ })
135
+ const clearTimer =
136
+ options.clearTimer ?? ((handle: unknown) => clearTimeout(handle as NodeJS.Timeout))
137
+
138
+ let closed = false
139
+ let partial = ''
140
+ let lines: string[] = []
141
+ let timer: unknown
142
+ let timerArmed = false
143
+
144
+ let tokens = MONITOR_BUCKET_CAPACITY
145
+ let refilledAt = now()
146
+
147
+ let eventsDelivered = 0
148
+ let suppressedTotal = 0
149
+ let stdoutChars = 0
150
+
151
+ // The suppression tracker. `count` is what the auto-stop reports; `unannounced`
152
+ // is what the next notice reports. They differ after a notice, because the
153
+ // tracker outlives it on purpose: a source that is told and keeps going must
154
+ // still reach the 30 s rule.
155
+ let suppressionStartedAt: number | undefined
156
+ let lastDropAt = 0
157
+ let count = 0
158
+ let unannounced = 0
159
+ let noticeEmitted = false
160
+
161
+ const refill = (at: number): void => {
162
+ const elapsed = at - refilledAt
163
+ if (elapsed <= 0) return
164
+ tokens = Math.min(MONITOR_BUCKET_CAPACITY, tokens + elapsed / MONITOR_BUCKET_REFILL_MS)
165
+ refilledAt = at
166
+ }
167
+
168
+ const deliver = (batch: string): void => {
169
+ const at = now()
170
+ // Reset only after a notice went out AND the source has been quiet for
171
+ // three refills. Resetting on quiet alone would let a source that is
172
+ // dropped every other window never reach the stop rule.
173
+ if (noticeEmitted && at - lastDropAt > 3 * MONITOR_BUCKET_REFILL_MS) {
174
+ suppressionStartedAt = undefined
175
+ count = 0
176
+ unannounced = 0
177
+ noticeEmitted = false
178
+ }
179
+ refill(at)
180
+ if (tokens >= 1) {
181
+ tokens -= 1
182
+ if (unannounced > 0) {
183
+ options.onNotice(suppressionNotice(unannounced))
184
+ unannounced = 0
185
+ noticeEmitted = true
186
+ }
187
+ eventsDelivered++
188
+ options.onBatch(batch)
189
+ return
190
+ }
191
+ // Whether the rule fires is decided by when suppression BEGAN, before
192
+ // this drop is counted, and the count reported includes it.
193
+ const began = suppressionStartedAt
194
+ suppressionStartedAt ??= at
195
+ lastDropAt = at
196
+ suppressedTotal++
197
+ count++
198
+ unannounced++
199
+ if (began !== undefined && at - began > MONITOR_SUPPRESSION_LIMIT_MS) {
200
+ stop()
201
+ options.onOverflow({ suppressed: count, overMs: at - began })
202
+ }
203
+ }
204
+
205
+ const emitPending = (): void => {
206
+ if (timerArmed) {
207
+ clearTimer(timer)
208
+ timerArmed = false
209
+ }
210
+ if (lines.length === 0) return
211
+ let batch = lines.join('\n')
212
+ lines = []
213
+ if (batch.length > MONITOR_BATCH_MAX_CHARS) {
214
+ batch = batch.slice(0, MONITOR_BATCH_MAX_CHARS) + BATCH_TRUNCATED
215
+ }
216
+ deliver(batch)
217
+ }
218
+
219
+ const addLine = (raw: string): void => {
220
+ const line = raw.trim()
221
+ if (line.length === 0) return
222
+ lines.push(
223
+ line.length > MONITOR_LINE_MAX_CHARS
224
+ ? line.slice(0, MONITOR_LINE_MAX_CHARS) + LINE_TRUNCATED
225
+ : line,
226
+ )
227
+ if (!timerArmed) {
228
+ timerArmed = true
229
+ timer = setTimer(() => {
230
+ timerArmed = false
231
+ if (!closed) emitPending()
232
+ }, MONITOR_BATCH_WINDOW_MS)
233
+ }
234
+ }
235
+
236
+ const stop = (): void => {
237
+ closed = true
238
+ if (timerArmed) {
239
+ clearTimer(timer)
240
+ timerArmed = false
241
+ }
242
+ lines = []
243
+ partial = ''
244
+ }
245
+
246
+ return {
247
+ push(text) {
248
+ if (closed || text.length === 0) return
249
+ stdoutChars += text.length
250
+ const buffered = partial + text
251
+ const parts = buffered.split('\n')
252
+ partial = keepTail(parts.pop() ?? '', MONITOR_PARTIAL_LINE_MAX_BYTES)
253
+ for (const part of parts) addLine(part)
254
+ },
255
+ flush() {
256
+ if (closed) return
257
+ emitPending()
258
+ },
259
+ drain() {
260
+ if (closed) return
261
+ const rest = partial
262
+ partial = ''
263
+ addLine(rest)
264
+ emitPending()
265
+ },
266
+ close: stop,
267
+ stats: () => ({ eventsDelivered, suppressed: suppressedTotal, stdoutChars }),
268
+ }
269
+ }