aicodeman 1.12.2 → 1.14.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 (130) hide show
  1. package/README.md +93 -15
  2. package/dist/ai-checker-base.d.ts +2 -0
  3. package/dist/ai-checker-base.d.ts.map +1 -1
  4. package/dist/ai-checker-base.js +31 -3
  5. package/dist/ai-checker-base.js.map +1 -1
  6. package/dist/cli.d.ts.map +1 -1
  7. package/dist/cli.js +142 -14
  8. package/dist/cli.js.map +1 -1
  9. package/dist/config/agent-wait.d.ts +73 -0
  10. package/dist/config/agent-wait.d.ts.map +1 -0
  11. package/dist/config/agent-wait.js +99 -0
  12. package/dist/config/agent-wait.js.map +1 -0
  13. package/dist/config/service-names.d.ts +23 -0
  14. package/dist/config/service-names.d.ts.map +1 -0
  15. package/dist/config/service-names.js +29 -0
  16. package/dist/config/service-names.js.map +1 -0
  17. package/dist/daemon-control.d.ts +126 -0
  18. package/dist/daemon-control.d.ts.map +1 -0
  19. package/dist/daemon-control.js +433 -0
  20. package/dist/daemon-control.js.map +1 -0
  21. package/dist/hooks-config.d.ts +28 -6
  22. package/dist/hooks-config.d.ts.map +1 -1
  23. package/dist/hooks-config.js +214 -28
  24. package/dist/hooks-config.js.map +1 -1
  25. package/dist/service-installer.d.ts +96 -0
  26. package/dist/service-installer.d.ts.map +1 -0
  27. package/dist/service-installer.js +333 -0
  28. package/dist/service-installer.js.map +1 -0
  29. package/dist/session-cli-builder.d.ts.map +1 -1
  30. package/dist/session-cli-builder.js +6 -2
  31. package/dist/session-cli-builder.js.map +1 -1
  32. package/dist/tmux-manager.d.ts.map +1 -1
  33. package/dist/tmux-manager.js +5 -1
  34. package/dist/tmux-manager.js.map +1 -1
  35. package/dist/web/public/admin-ui.js.gz +0 -0
  36. package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
  37. package/dist/web/public/{app.2e9ffd31.js → app.b24f795e.js} +5 -7
  38. package/dist/web/public/app.b24f795e.js.br +0 -0
  39. package/dist/web/public/app.b24f795e.js.gz +0 -0
  40. package/dist/web/public/constants.e7175b95.js.gz +0 -0
  41. package/dist/web/public/cron-ui.js.gz +0 -0
  42. package/dist/web/public/entrance-animations.js.gz +0 -0
  43. package/dist/web/public/i18n.da34cfaf.js +1 -0
  44. package/dist/web/public/i18n.da34cfaf.js.br +0 -0
  45. package/dist/web/public/i18n.da34cfaf.js.gz +0 -0
  46. package/dist/web/public/image-input.ee16ad88.js.gz +0 -0
  47. package/dist/web/public/index.html +13 -6
  48. package/dist/web/public/index.html.br +0 -0
  49. package/dist/web/public/index.html.gz +0 -0
  50. package/dist/web/public/input-cjk.63794d0b.js.gz +0 -0
  51. package/dist/web/public/keyboard-accessory.9936227d.js.gz +0 -0
  52. package/dist/web/public/mobile-handlers.32fdd57f.js.gz +0 -0
  53. package/dist/web/public/mobile-overview.js.gz +0 -0
  54. package/dist/web/public/mobile.32cacde5.css.gz +0 -0
  55. package/dist/web/public/notification-manager.5d229063.js.gz +0 -0
  56. package/dist/web/public/orchestrator-panel.js.gz +0 -0
  57. package/dist/web/public/panels-ui.1d4203e0.js.gz +0 -0
  58. package/dist/web/public/ralph-panel.6de2d0f8.js.gz +0 -0
  59. package/dist/web/public/ralph-wizard.13a1831e.js.gz +0 -0
  60. package/dist/web/public/respawn-ui.ff0dae4c.js.gz +0 -0
  61. package/dist/web/public/sanitize-html.bc7078d6.js.gz +0 -0
  62. package/dist/web/public/session-ui.1356a4f4.js.gz +0 -0
  63. package/dist/web/public/settings-ui.ea63d5db.js +67 -0
  64. package/dist/web/public/settings-ui.ea63d5db.js.br +0 -0
  65. package/dist/web/public/settings-ui.ea63d5db.js.gz +0 -0
  66. package/dist/web/public/styles.b10e5edd.css +1 -0
  67. package/dist/web/public/styles.b10e5edd.css.br +0 -0
  68. package/dist/web/public/styles.b10e5edd.css.gz +0 -0
  69. package/dist/web/public/subagent-windows.4cc8b005.js.gz +0 -0
  70. package/dist/web/public/sw.js.gz +0 -0
  71. package/dist/web/public/{terminal-ui.78c71f3a.js → terminal-ui.d5ae6dcf.js} +2 -2
  72. package/dist/web/public/terminal-ui.d5ae6dcf.js.br +0 -0
  73. package/dist/web/public/terminal-ui.d5ae6dcf.js.gz +0 -0
  74. package/dist/web/public/ultracode-panel.js.gz +0 -0
  75. package/dist/web/public/ultracode-windows.js.gz +0 -0
  76. package/dist/web/public/upload.html.gz +0 -0
  77. package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
  78. package/dist/web/public/vendor/marked.min.js.gz +0 -0
  79. package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
  80. package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
  81. package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
  82. package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
  83. package/dist/web/public/vendor/xterm-zerolag-input.137ad9f0.js.gz +0 -0
  84. package/dist/web/public/vendor/xterm.css.gz +0 -0
  85. package/dist/web/public/vendor/xterm.min.js.gz +0 -0
  86. package/dist/web/public/voice-input.085e9e73.js.gz +0 -0
  87. package/dist/web/public/webview-tabs.js +1 -2
  88. package/dist/web/public/webview-tabs.js.br +0 -0
  89. package/dist/web/public/webview-tabs.js.gz +0 -0
  90. package/dist/web/routes/hook-event-routes.d.ts.map +1 -1
  91. package/dist/web/routes/hook-event-routes.js +22 -0
  92. package/dist/web/routes/hook-event-routes.js.map +1 -1
  93. package/dist/web/routes/session-routes.d.ts +4 -0
  94. package/dist/web/routes/session-routes.d.ts.map +1 -1
  95. package/dist/web/routes/session-routes.js +499 -17
  96. package/dist/web/routes/session-routes.js.map +1 -1
  97. package/dist/web/schemas.d.ts +53 -0
  98. package/dist/web/schemas.d.ts.map +1 -1
  99. package/dist/web/schemas.js +59 -0
  100. package/dist/web/schemas.js.map +1 -1
  101. package/dist/web/self-update.d.ts.map +1 -1
  102. package/dist/web/self-update.js +4 -4
  103. package/dist/web/self-update.js.map +1 -1
  104. package/dist/web/server.d.ts.map +1 -1
  105. package/dist/web/server.js +14 -0
  106. package/dist/web/server.js.map +1 -1
  107. package/dist/web/session-listener-wiring.d.ts.map +1 -1
  108. package/dist/web/session-listener-wiring.js +28 -0
  109. package/dist/web/session-listener-wiring.js.map +1 -1
  110. package/dist/web/session-wait-registry.d.ts +407 -0
  111. package/dist/web/session-wait-registry.d.ts.map +1 -0
  112. package/dist/web/session-wait-registry.js +895 -0
  113. package/dist/web/session-wait-registry.js.map +1 -0
  114. package/package.json +2 -1
  115. package/skills/codeman/SKILL.md +274 -0
  116. package/skills/codeman/reference/endpoints.md +214 -0
  117. package/skills/codeman/reference/recipes.md +249 -0
  118. package/dist/web/public/app.2e9ffd31.js.br +0 -0
  119. package/dist/web/public/app.2e9ffd31.js.gz +0 -0
  120. package/dist/web/public/i18n.0fbea500.js +0 -1
  121. package/dist/web/public/i18n.0fbea500.js.br +0 -0
  122. package/dist/web/public/i18n.0fbea500.js.gz +0 -0
  123. package/dist/web/public/settings-ui.01a1ae7d.js +0 -67
  124. package/dist/web/public/settings-ui.01a1ae7d.js.br +0 -0
  125. package/dist/web/public/settings-ui.01a1ae7d.js.gz +0 -0
  126. package/dist/web/public/styles.50cd52bd.css +0 -1
  127. package/dist/web/public/styles.50cd52bd.css.br +0 -0
  128. package/dist/web/public/styles.50cd52bd.css.gz +0 -0
  129. package/dist/web/public/terminal-ui.78c71f3a.js.br +0 -0
  130. package/dist/web/public/terminal-ui.78c71f3a.js.gz +0 -0
@@ -0,0 +1,895 @@
1
+ /**
2
+ * @fileoverview Blocking-wait registry backing the agent wait primitives.
3
+ *
4
+ * Codeman's only "tell me when" channel today is SSE, which an agent driving the
5
+ * API from a shell tool cannot practically consume (it would have to hold a
6
+ * streaming connection and parse events inline). This registry is the piece that
7
+ * lets a request *block* until something happens instead, so an orchestrating
8
+ * agent can `curl` and wait. Plan: `docs/agent-control-plan.md`.
9
+ *
10
+ * Two waiter kinds:
11
+ * - **signal waiters** resolve on the first of a requested set of lifecycle
12
+ * signals (`idle`, `working`, `stop`, `blocked`, `exit`).
13
+ * - **output waiters** resolve when a literal string appears in a session's
14
+ * ANSI-stripped output.
15
+ *
16
+ * ## What "ANSI-stripped output" means here
17
+ *
18
+ * `normalizeForMatch()` defines it, and it is a superset of `stripAnsi()`: that helper
19
+ * knows three escape families and leaves the rest, which is enough for `ESC ( B` from a
20
+ * stock bash prompt to break `match=tnode:` on a prompt that reads `tnode:`. Everything
21
+ * downstream derives from one call to it, so the carry, the haystack and the snippet
22
+ * window are all the same text. The returned `snippet` is then RENDERED from that window
23
+ * (control bytes removed, blank runs collapsed) and so is not a byte-for-byte quotation
24
+ * of what was matched — see `findMatch`.
25
+ *
26
+ * ## Signal provenance (why `stop` is the good one)
27
+ *
28
+ * `idle` / `working` / `exit` come from `Session`'s own events (heuristic: output
29
+ * stabilization plus prompt detection), so `idle` can flap mid-turn when a spinner
30
+ * pauses. `stop` and `blocked` come from Claude Code hooks (`POST /api/hook-event`)
31
+ * and are definitive. Only `claude` mode installs those hooks, so `stop` and
32
+ * `blocked` never fire for anything else: callers must be rejected up front rather
33
+ * than left to hit the timeout. `hooksAvailableForMode()` keeps that rule in one
34
+ * place, and it is keyed on the MODE rather than on `isExternalCliMode()` because
35
+ * `shell` is not an external CLI yet installs no hooks either.
36
+ *
37
+ * ## Ordering contract for the wiring
38
+ *
39
+ * A session that exits must `notifySignal(id, 'exit')` BEFORE `cancelAll(id)`, so
40
+ * a caller waiting on `exit` gets `signal: 'exit'` rather than `ended: true`.
41
+ *
42
+ * ## Lifetime discipline (24h sessions)
43
+ *
44
+ * Every waiter owns exactly one timer, cleared on resolve, and waiter sets are
45
+ * deleted when they empty. Session teardown must call `cancelAll()` and shutdown
46
+ * `stop()`: timers are deliberately NOT unref'd, because an unref'd timer can let
47
+ * the process exit mid-wait and strand the HTTP response.
48
+ *
49
+ * Two things keep a slot from outliving its caller:
50
+ * - `abortSignal`, so a client that hangs up frees its waiter immediately instead of
51
+ * holding one for the rest of its timeout. Routes wire it to `req.raw.on('close')`.
52
+ * - the latched stopped flag, so a request that lands in the window between shutdown
53
+ * starting and the HTTP server actually closing cannot register a fresh waiter that
54
+ * nothing will ever cancel.
55
+ *
56
+ * This module holds no IO and no `Session` reference, which is what keeps it
57
+ * unit-testable in isolation.
58
+ */
59
+ import { stripAnsi } from '../utils/index.js';
60
+ import { MAX_WAITERS_PER_SESSION, MAX_WAITERS_PER_OWNER, MAX_WAITERS_TOTAL, MAX_SNIPPET_CONTEXT, } from '../config/agent-wait.js';
61
+ /** Every valid signal, in documentation order. */
62
+ export const WAIT_SIGNALS = ['idle', 'working', 'stop', 'blocked', 'exit'];
63
+ /**
64
+ * Applied when a caller omits `until`. `stop` first because it is the definitive
65
+ * end-of-turn signal, `idle` as the fallback for sessions that emit no hooks, and
66
+ * `exit` so a worker that CRASHES resolves the wait promptly instead of burning
67
+ * the caller's whole timeout on something that can no longer happen.
68
+ */
69
+ export const DEFAULT_WAIT_SIGNALS = ['stop', 'idle', 'exit'];
70
+ const SIGNAL_SET = new Set(WAIT_SIGNALS);
71
+ /**
72
+ * Parse an `until` query value: a comma-separated string, an array of strings, or
73
+ * absent. Invalid tokens are REPORTED rather than dropped: silently falling back
74
+ * to the default would leave an agent believing it is waiting for `stop` when a
75
+ * typo means it is waiting for something else entirely.
76
+ *
77
+ * @param raw - `"stop,idle"`, `["stop","idle"]`, or undefined
78
+ * @returns valid signals plus any unrecognized tokens
79
+ */
80
+ export function parseWaitSignals(raw) {
81
+ const tokens = [];
82
+ if (typeof raw === 'string') {
83
+ tokens.push(...raw.split(','));
84
+ }
85
+ else if (Array.isArray(raw)) {
86
+ for (const entry of raw) {
87
+ if (typeof entry === 'string')
88
+ tokens.push(...entry.split(','));
89
+ }
90
+ }
91
+ const signals = [];
92
+ const invalid = [];
93
+ const seen = new Set();
94
+ for (const token of tokens) {
95
+ const value = token.trim().toLowerCase();
96
+ if (!value)
97
+ continue;
98
+ if (!SIGNAL_SET.has(value)) {
99
+ if (!invalid.includes(value))
100
+ invalid.push(value);
101
+ continue;
102
+ }
103
+ if (seen.has(value))
104
+ continue;
105
+ seen.add(value);
106
+ signals.push(value);
107
+ }
108
+ return { signals, invalid };
109
+ }
110
+ /**
111
+ * The signal a session is *already* emitting, given its status, so a wait can
112
+ * resolve immediately instead of hanging until something changes.
113
+ *
114
+ * ⚠️ **What this CANNOT tell you: whether the session is alive.** It sees only the
115
+ * four-value `SessionStatus` enum, and that enum does not distinguish a finished
116
+ * worker from a dead one. Both PTY `onExit` handlers in `session.ts` (Claude
117
+ * interactive and shell) park the session at `'idle'`, and a PTY exit does not remove
118
+ * the session from the map, so a crashed worker reports `'idle'` indefinitely and this
119
+ * function will answer `'idle'` for it forever. `'stopped'` is set only on a spawn
120
+ * failure and by `stop(killMux:true)` (which deletes the session moments later), so in
121
+ * practice the `'stopped' → 'exit'` arm almost never fires for a session that merely
122
+ * died.
123
+ *
124
+ * Read the return value as "what state is it in", never as "is it still running". A
125
+ * caller that must tell a completed turn from a corpse has to consult liveness itself
126
+ * (`session.pid`, which is null after a PTY exit) and cannot get it from here. The
127
+ * live `exit` signal is still delivered by `notifySignal(id, 'exit')` at the moment
128
+ * the PTY dies; this function is only about the state a LATER caller finds.
129
+ *
130
+ * `stopped` and `error` both map to `exit` because both mean "not running", so a
131
+ * caller that does catch one of them does not block. `blocked` is deliberately
132
+ * underivable here, it exists only as a hook event until it becomes a real
133
+ * `SessionStatus` (see the deferred Part 3 in the plan).
134
+ */
135
+ export function signalForStatus(status) {
136
+ switch (status) {
137
+ case 'idle':
138
+ return 'idle';
139
+ case 'busy':
140
+ return 'working';
141
+ case 'stopped':
142
+ case 'error':
143
+ return 'exit';
144
+ default:
145
+ return null;
146
+ }
147
+ }
148
+ /** Signals that arrive only via Claude Code hooks, so only `claude` mode can emit them. */
149
+ const HOOK_ONLY_SIGNALS = ['stop', 'blocked'];
150
+ /**
151
+ * Whether a session in this mode ever POSTs Codeman hook events, and therefore
152
+ * whether `stop` / `blocked` can ever fire for it.
153
+ *
154
+ * True for `claude` and nothing else. The tempting predicate is
155
+ * `!isExternalCliMode(mode)`, and it is WRONG: that helper covers only
156
+ * opencode/codex/gemini/antigravity, so `shell` falls through it — and a shell session
157
+ * is a plain bash PTY with no Claude Code and no hooks installed. `until=stop` on one
158
+ * was accepted and then blocked for the caller's whole timeout, which is precisely the
159
+ * infinite-wait-dressed-as-a-timeout this guard exists to prevent.
160
+ */
161
+ export function hooksAvailableForMode(mode) {
162
+ return mode === 'claude';
163
+ }
164
+ /**
165
+ * Turn a raw `until` / `wait` value into the set to wait on, applying both rules
166
+ * every wait endpoint shares:
167
+ *
168
+ * 1. An unknown token is an ERROR, never a silent fallback to the default.
169
+ * 2. Hook-only signals are rejected when asked for EXPLICITLY on a mode that emits
170
+ * no hooks, but merely dropped from the DEFAULT set: omitting the parameter must
171
+ * never 400.
172
+ *
173
+ * Shared by `GET .../wait` and the `wait` field on `POST .../input` so the two can
174
+ * not drift; the second-guessing that produces is worse than the duplication.
175
+ *
176
+ * @param raw - the caller's value (comma string, array, `true` for "the default")
177
+ * @param options - `mode` decides whether the hook-only signals are available, and
178
+ * names the mode in the error message so the caller can see why
179
+ */
180
+ export function resolveWaitSignals(raw, options) {
181
+ const parsed = parseWaitSignals(raw);
182
+ if (parsed.invalid.length > 0) {
183
+ return {
184
+ until: [],
185
+ error: `Unknown wait signal(s): ${parsed.invalid.join(', ')}. Valid: ${WAIT_SIGNALS.join(', ')}`,
186
+ };
187
+ }
188
+ const unsupported = new Set(hooksAvailableForMode(options.mode) ? [] : HOOK_ONLY_SIGNALS);
189
+ if (parsed.signals.length === 0) {
190
+ return { until: DEFAULT_WAIT_SIGNALS.filter((signal) => !unsupported.has(signal)), error: null };
191
+ }
192
+ const rejected = parsed.signals.filter((signal) => unsupported.has(signal));
193
+ if (rejected.length > 0) {
194
+ return {
195
+ until: [],
196
+ error: `Signal(s) ${rejected.join(', ')} never fire for ${options.mode} sessions (no Claude Code hooks). Use idle or exit.`,
197
+ };
198
+ }
199
+ return { until: parsed.signals, error: null };
200
+ }
201
+ /**
202
+ * Thrown when a waiter would exceed a configured cap.
203
+ *
204
+ * `scope` is what the route needs to answer honestly: `'session'` is the caller's own
205
+ * session being oversubscribed (409 SESSION_BUSY), while `'owner'` and `'total'` are
206
+ * caps the caller may have no part in and cannot fix by switching sessions, so those
207
+ * map to 429 RATE_LIMITED. Reporting a process-wide cap as SESSION_BUSY tells the
208
+ * caller the wrong session is at fault.
209
+ */
210
+ export class WaitCapacityError extends Error {
211
+ scope;
212
+ name = 'WaitCapacityError';
213
+ constructor(message,
214
+ /** Which cap was hit, so the route can say something useful. */
215
+ scope) {
216
+ super(message);
217
+ this.scope = scope;
218
+ }
219
+ }
220
+ // ─── Registry ────────────────────────────────────────────────────────────────
221
+ /**
222
+ * Holds pending waiters keyed by session id.
223
+ *
224
+ * One instance is shared process-wide (`sessionWaits` below); tests construct
225
+ * their own so no state leaks between cases.
226
+ */
227
+ export class SessionWaitRegistry {
228
+ signalWaiters = new Map();
229
+ outputWaiters = new Map();
230
+ /**
231
+ * Live waiter count per owner. A derived count would mean walking every waiter in
232
+ * the process on each registration, so it is maintained incrementally instead:
233
+ * incremented once at registration, decremented once in the remove* helpers, which
234
+ * are the only paths that take a waiter out of a set.
235
+ */
236
+ ownerCounts = new Map();
237
+ /**
238
+ * Trailing bytes of a PTY chunk that look like the START of an ANSI sequence whose
239
+ * terminator has not arrived yet, held back until the next chunk completes it. Keyed
240
+ * by session because every output waiter on a session sees the same stream, which
241
+ * keeps `notifyOutput` at ONE `stripAnsi` per chunk rather than one per waiter.
242
+ * Deleted with the session's waiter set.
243
+ */
244
+ pendingAnsi = new Map();
245
+ maxPerSession;
246
+ maxPerOwner;
247
+ maxTotal;
248
+ /** Latched by `stop()` / `cancelEverything()`. Never cleared: shutdown is one-way. */
249
+ stopped = false;
250
+ constructor(options = {}) {
251
+ this.maxPerSession = options.maxWaitersPerSession ?? MAX_WAITERS_PER_SESSION;
252
+ this.maxPerOwner = options.maxWaitersPerOwner ?? MAX_WAITERS_PER_OWNER;
253
+ this.maxTotal = options.maxWaitersTotal ?? MAX_WAITERS_TOTAL;
254
+ }
255
+ // ── Counts (also the cap accounting) ──
256
+ /** Pending signal waiters for a session. */
257
+ signalWaiterCount(sessionId) {
258
+ return this.signalWaiters.get(sessionId)?.size ?? 0;
259
+ }
260
+ /** Pending output waiters for a session. */
261
+ outputWaiterCount(sessionId) {
262
+ return this.outputWaiters.get(sessionId)?.size ?? 0;
263
+ }
264
+ /** Pending waiters of both kinds for a session. */
265
+ waiterCount(sessionId) {
266
+ return this.signalWaiterCount(sessionId) + this.outputWaiterCount(sessionId);
267
+ }
268
+ /** Pending waiters of both kinds across every session. */
269
+ totalWaiterCount() {
270
+ let total = 0;
271
+ for (const set of this.signalWaiters.values())
272
+ total += set.size;
273
+ for (const set of this.outputWaiters.values())
274
+ total += set.size;
275
+ return total;
276
+ }
277
+ /** Pending waiters of both kinds charged to one owner. */
278
+ ownerWaiterCount(owner) {
279
+ return this.ownerCounts.get(owner) ?? 0;
280
+ }
281
+ /** True once `stop()` / `cancelEverything()` has run: no new waiter can register. */
282
+ get isStopped() {
283
+ return this.stopped;
284
+ }
285
+ /**
286
+ * Throw if registering one more waiter would exceed a cap.
287
+ *
288
+ * PUBLIC so a route can check BEFORE doing expensive setup work. `/wait-output`'s
289
+ * `from=buffer` mode is the case that matters: reading `session.terminalBuffer`
290
+ * joins the whole 32MB accumulator, and paying that for a request that is about to
291
+ * be refused turns the cap into an amplifier instead of a protection. Both
292
+ * `waitForSignal` and `waitForOutput` call it themselves too, so a caller that skips
293
+ * the pre-check is still bounded.
294
+ *
295
+ * @throws {WaitCapacityError} naming the cap that was hit in `scope`.
296
+ */
297
+ assertCapacity(sessionId, owner) {
298
+ if (this.totalWaiterCount() >= this.maxTotal) {
299
+ throw new WaitCapacityError(`Too many concurrent waits (scope: total, max ${this.maxTotal})`, 'total');
300
+ }
301
+ if (owner !== undefined && this.ownerWaiterCount(owner) >= this.maxPerOwner) {
302
+ throw new WaitCapacityError(`Too many concurrent waits for this user (scope: owner, max ${this.maxPerOwner})`, 'owner');
303
+ }
304
+ if (this.waiterCount(sessionId) >= this.maxPerSession) {
305
+ throw new WaitCapacityError(`Too many concurrent waits on this session (scope: session, max ${this.maxPerSession})`, 'session');
306
+ }
307
+ }
308
+ chargeOwner(owner) {
309
+ if (owner === undefined)
310
+ return;
311
+ this.ownerCounts.set(owner, (this.ownerCounts.get(owner) ?? 0) + 1);
312
+ }
313
+ releaseOwner(owner) {
314
+ if (owner === undefined)
315
+ return;
316
+ const next = (this.ownerCounts.get(owner) ?? 0) - 1;
317
+ if (next > 0)
318
+ this.ownerCounts.set(owner, next);
319
+ else
320
+ this.ownerCounts.delete(owner);
321
+ }
322
+ /**
323
+ * Wire a caller's abort signal to `onAbort`, returning the detach function.
324
+ * Detaching matters even though request signals are short-lived: without it a
325
+ * caller reusing one controller across several waits accumulates listeners on it.
326
+ */
327
+ attachAbort(signal, onAbort) {
328
+ if (!signal)
329
+ return undefined;
330
+ signal.addEventListener('abort', onAbort, { once: true });
331
+ return () => signal.removeEventListener('abort', onAbort);
332
+ }
333
+ // ── Signal waits ──
334
+ /**
335
+ * Block until one of `until` fires, the timeout elapses, or the session ends.
336
+ *
337
+ * Resolves immediately (`immediate: true`) when the session's `currentSignal` is
338
+ * already in `until` and `requireTransition` is not set.
339
+ *
340
+ * @throws {WaitCapacityError} when a waiter cap is already reached.
341
+ */
342
+ waitForSignal(sessionId, options) {
343
+ const until = new Set(options.until);
344
+ const base = { signal: null, timedOut: false, immediate: false, waitedMs: 0, timeoutMs: options.timeoutMs };
345
+ // Nobody is listening / nothing can ever resolve: answer without taking a slot,
346
+ // and without a capacity throw the caller could not act on either way.
347
+ if (options.abortSignal?.aborted)
348
+ return Promise.resolve({ ...base, ended: true, aborted: true });
349
+ if (this.stopped)
350
+ return Promise.resolve({ ...base, ended: true, aborted: false });
351
+ if (!options.requireTransition && options.currentSignal && until.has(options.currentSignal)) {
352
+ return Promise.resolve({
353
+ signal: options.currentSignal,
354
+ timedOut: false,
355
+ immediate: true,
356
+ ended: false,
357
+ aborted: false,
358
+ waitedMs: 0,
359
+ timeoutMs: options.timeoutMs,
360
+ });
361
+ }
362
+ this.assertCapacity(sessionId, options.owner);
363
+ return new Promise((resolve) => {
364
+ let set = this.signalWaiters.get(sessionId);
365
+ if (!set) {
366
+ set = new Set();
367
+ this.signalWaiters.set(sessionId, set);
368
+ }
369
+ const waiter = {
370
+ until,
371
+ startedAt: Date.now(),
372
+ timeoutMs: options.timeoutMs,
373
+ owner: options.owner,
374
+ timer: setTimeout(() => {
375
+ this.removeSignalWaiter(sessionId, waiter);
376
+ resolve({
377
+ signal: null,
378
+ timedOut: true,
379
+ immediate: false,
380
+ ended: false,
381
+ aborted: false,
382
+ waitedMs: Date.now() - waiter.startedAt,
383
+ timeoutMs: waiter.timeoutMs,
384
+ });
385
+ }, options.timeoutMs),
386
+ settle: resolve,
387
+ };
388
+ set.add(waiter);
389
+ this.chargeOwner(options.owner);
390
+ waiter.detachAbort = this.attachAbort(options.abortSignal, () => {
391
+ this.removeSignalWaiter(sessionId, waiter);
392
+ resolve({
393
+ signal: null,
394
+ timedOut: false,
395
+ immediate: false,
396
+ ended: true,
397
+ aborted: true,
398
+ waitedMs: Date.now() - waiter.startedAt,
399
+ timeoutMs: waiter.timeoutMs,
400
+ });
401
+ });
402
+ });
403
+ }
404
+ /**
405
+ * Deliver a lifecycle signal. Every waiter for this session that asked for it
406
+ * resolves; the rest keep waiting.
407
+ *
408
+ * @returns how many waiters were resolved (0 is the overwhelmingly common case,
409
+ * which is why this is safe to call from the session event hot path).
410
+ */
411
+ notifySignal(sessionId, signal) {
412
+ const set = this.signalWaiters.get(sessionId);
413
+ if (!set || set.size === 0)
414
+ return 0;
415
+ // Snapshot: settling mutates the set.
416
+ let resolved = 0;
417
+ for (const waiter of [...set]) {
418
+ if (!waiter.until.has(signal))
419
+ continue;
420
+ this.removeSignalWaiter(sessionId, waiter);
421
+ waiter.settle({
422
+ signal,
423
+ timedOut: false,
424
+ immediate: false,
425
+ ended: false,
426
+ aborted: false,
427
+ waitedMs: Date.now() - waiter.startedAt,
428
+ timeoutMs: waiter.timeoutMs,
429
+ });
430
+ resolved++;
431
+ }
432
+ return resolved;
433
+ }
434
+ removeSignalWaiter(sessionId, waiter) {
435
+ clearTimeout(waiter.timer);
436
+ waiter.detachAbort?.();
437
+ const set = this.signalWaiters.get(sessionId);
438
+ if (!set)
439
+ return;
440
+ // Guard on the delete: a second removal (abort racing a resolve) must not
441
+ // decrement the owner count twice and hand out a slot that is still in use.
442
+ if (!set.delete(waiter))
443
+ return;
444
+ this.releaseOwner(waiter.owner);
445
+ if (set.size === 0)
446
+ this.signalWaiters.delete(sessionId);
447
+ }
448
+ // ── Output waits ──
449
+ /**
450
+ * Block until `match` appears in the session's output.
451
+ *
452
+ * **Literal matching only, deliberately.** `search-service.ts` avoids regex so
453
+ * there is no ReDoS surface, and this endpoint is more exposed still: the
454
+ * pattern is caller-supplied and the input is a live stream. herdr can offer
455
+ * `--regex` because Rust's regex crate is linear-time with no backtracking;
456
+ * JavaScript's `RegExp` backtracks.
457
+ *
458
+ * ⚠️ "New output" is not the same as "output produced after you asked". tmux
459
+ * REPAINTS the visible screen (on attach, resize, or a TUI redraw), and a repaint
460
+ * arrives as ordinary `terminal` data, so text that was already on screen can
461
+ * match a `from=now` wait. Observed live: a marker echoed a minute earlier matched
462
+ * instantly on a fresh wait. Callers must use a marker unique to the call
463
+ * (`echo DONE_$RANDOM`), not a generic one like `BUILD OK`.
464
+ *
465
+ * @throws {WaitCapacityError} when a waiter cap is already reached.
466
+ */
467
+ waitForOutput(sessionId, options) {
468
+ const nocase = options.nocase === true;
469
+ const needle = options.match;
470
+ const needleCmp = nocase ? needle.toLowerCase() : needle;
471
+ const base = {
472
+ matched: false,
473
+ timedOut: false,
474
+ immediate: false,
475
+ snippet: null,
476
+ waitedMs: 0,
477
+ timeoutMs: options.timeoutMs,
478
+ };
479
+ if (options.abortSignal?.aborted)
480
+ return Promise.resolve({ ...base, ended: true, aborted: true });
481
+ if (this.stopped)
482
+ return Promise.resolve({ ...base, ended: true, aborted: false });
483
+ // Capacity FIRST, deliberately: the `from=buffer` scan below strips ANSI over (and
484
+ // for nocase lowercases again) up to MAX_BUFFER_SCAN_BYTES, and the route has
485
+ // already joined the session's whole terminal buffer to produce it. Doing that for
486
+ // a request that is about to be refused is unbounded work at request rate with the
487
+ // caps providing no backpressure at all.
488
+ this.assertCapacity(sessionId, options.owner);
489
+ // `from=buffer`: scan what is already there before blocking.
490
+ let carry = '';
491
+ if (options.initialText) {
492
+ const text = normalizeForMatch(options.initialText);
493
+ const hit = findMatch(text, needleCmp, nocase);
494
+ if (hit) {
495
+ return Promise.resolve({
496
+ matched: true,
497
+ timedOut: false,
498
+ immediate: true,
499
+ ended: false,
500
+ aborted: false,
501
+ snippet: hit,
502
+ waitedMs: 0,
503
+ timeoutMs: options.timeoutMs,
504
+ });
505
+ }
506
+ carry = tailFor(text, needleCmp.length);
507
+ }
508
+ return new Promise((resolve) => {
509
+ let set = this.outputWaiters.get(sessionId);
510
+ if (!set) {
511
+ set = new Set();
512
+ this.outputWaiters.set(sessionId, set);
513
+ }
514
+ const waiter = {
515
+ needle,
516
+ needleCmp,
517
+ nocase,
518
+ carry,
519
+ startedAt: Date.now(),
520
+ timeoutMs: options.timeoutMs,
521
+ owner: options.owner,
522
+ timer: setTimeout(() => {
523
+ this.removeOutputWaiter(sessionId, waiter);
524
+ resolve({
525
+ matched: false,
526
+ timedOut: true,
527
+ immediate: false,
528
+ ended: false,
529
+ aborted: false,
530
+ snippet: null,
531
+ waitedMs: Date.now() - waiter.startedAt,
532
+ timeoutMs: waiter.timeoutMs,
533
+ });
534
+ }, options.timeoutMs),
535
+ settle: resolve,
536
+ };
537
+ set.add(waiter);
538
+ this.chargeOwner(options.owner);
539
+ waiter.detachAbort = this.attachAbort(options.abortSignal, () => {
540
+ this.removeOutputWaiter(sessionId, waiter);
541
+ resolve({
542
+ matched: false,
543
+ timedOut: false,
544
+ immediate: false,
545
+ ended: true,
546
+ aborted: true,
547
+ snippet: null,
548
+ waitedMs: Date.now() - waiter.startedAt,
549
+ timeoutMs: waiter.timeoutMs,
550
+ });
551
+ });
552
+ });
553
+ }
554
+ /**
555
+ * Feed a raw PTY chunk to this session's output waiters.
556
+ *
557
+ * Called from the always-attached `terminal` listener, so it sits on the PTY hot
558
+ * path: it must stay a single Map lookup when nobody is waiting, which is why the
559
+ * no-waiter check comes before the ANSI strip.
560
+ *
561
+ * ⚠️ A PTY read boundary can fall INSIDE an escape sequence, which is routine under
562
+ * tmux (it emits SGR runs constantly). `stripAnsi` needs a complete sequence, so a
563
+ * split one used to survive the strip, land in the carry, and split the needle: the
564
+ * same text matched or not depending on where the kernel happened to cut the read.
565
+ * The incomplete tail is therefore held back in `pendingAnsi` and stripped together
566
+ * with the next chunk. That is the same class of bug the carry buffer exists to
567
+ * solve, one level down.
568
+ *
569
+ * @returns how many waiters matched.
570
+ */
571
+ notifyOutput(sessionId, chunk) {
572
+ const set = this.outputWaiters.get(sessionId);
573
+ if (!set || set.size === 0)
574
+ return 0;
575
+ const pending = this.pendingAnsi.get(sessionId);
576
+ const split = splitTrailingEscape(pending ? pending + chunk : chunk);
577
+ if (split.pending)
578
+ this.pendingAnsi.set(sessionId, split.pending);
579
+ else if (pending !== undefined)
580
+ this.pendingAnsi.delete(sessionId);
581
+ const text = normalizeForMatch(split.text);
582
+ if (!text)
583
+ return 0;
584
+ let resolved = 0;
585
+ for (const waiter of [...set]) {
586
+ // Prepend the tail of what this waiter already scanned so a match spanning
587
+ // two chunks is still found. Re-scanning the carry cannot double-fire: a
588
+ // needle wholly inside the carry would have matched on the previous pass.
589
+ const hay = waiter.carry + text;
590
+ const hit = findMatch(hay, waiter.needleCmp, waiter.nocase);
591
+ if (hit) {
592
+ this.removeOutputWaiter(sessionId, waiter);
593
+ waiter.settle({
594
+ matched: true,
595
+ timedOut: false,
596
+ immediate: false,
597
+ ended: false,
598
+ aborted: false,
599
+ snippet: hit,
600
+ waitedMs: Date.now() - waiter.startedAt,
601
+ timeoutMs: waiter.timeoutMs,
602
+ });
603
+ resolved++;
604
+ continue;
605
+ }
606
+ waiter.carry = tailFor(hay, waiter.needleCmp.length);
607
+ }
608
+ return resolved;
609
+ }
610
+ removeOutputWaiter(sessionId, waiter) {
611
+ clearTimeout(waiter.timer);
612
+ waiter.detachAbort?.();
613
+ const set = this.outputWaiters.get(sessionId);
614
+ if (!set)
615
+ return;
616
+ if (!set.delete(waiter))
617
+ return;
618
+ this.releaseOwner(waiter.owner);
619
+ if (set.size === 0) {
620
+ this.outputWaiters.delete(sessionId);
621
+ // The held-back escape tail belongs to the waiter set; with nobody watching it
622
+ // would be a per-session string kept alive for the life of the process.
623
+ this.pendingAnsi.delete(sessionId);
624
+ }
625
+ }
626
+ // ── Teardown ──
627
+ /**
628
+ * Resolve every waiter for a session with `ended: true`.
629
+ *
630
+ * Call on session deletion and PTY teardown. A session that EXITS should
631
+ * `notifySignal(id, 'exit')` first, so an `until=exit` caller sees the signal
632
+ * rather than a bare `ended`.
633
+ *
634
+ * @returns how many waiters were resolved.
635
+ */
636
+ cancelAll(sessionId) {
637
+ let resolved = 0;
638
+ const signals = this.signalWaiters.get(sessionId);
639
+ if (signals) {
640
+ for (const waiter of [...signals]) {
641
+ this.removeSignalWaiter(sessionId, waiter);
642
+ waiter.settle({
643
+ signal: null,
644
+ timedOut: false,
645
+ immediate: false,
646
+ ended: true,
647
+ aborted: false,
648
+ waitedMs: Date.now() - waiter.startedAt,
649
+ timeoutMs: waiter.timeoutMs,
650
+ });
651
+ resolved++;
652
+ }
653
+ }
654
+ const outputs = this.outputWaiters.get(sessionId);
655
+ if (outputs) {
656
+ for (const waiter of [...outputs]) {
657
+ this.removeOutputWaiter(sessionId, waiter);
658
+ waiter.settle({
659
+ matched: false,
660
+ timedOut: false,
661
+ immediate: false,
662
+ ended: true,
663
+ aborted: false,
664
+ snippet: null,
665
+ waitedMs: Date.now() - waiter.startedAt,
666
+ timeoutMs: waiter.timeoutMs,
667
+ });
668
+ resolved++;
669
+ }
670
+ }
671
+ this.pendingAnsi.delete(sessionId);
672
+ return resolved;
673
+ }
674
+ /**
675
+ * Resolve every waiter in the process and refuse every later one.
676
+ *
677
+ * Shutdown must call this: waiter timers are not unref'd, so a pending 10-minute
678
+ * wait would otherwise hold the event loop open and strand its HTTP response.
679
+ *
680
+ * ⚠️ The latch is the point, not just the sweep. `server.stop()` cancels waiters
681
+ * well before `app.close()`, with several awaits in between (orchestrator teardown,
682
+ * scheduled-run stops that each await a session stop and a tmux kill), and the
683
+ * listener is still accepting requests throughout. A `GET .../wait?timeout=600000`
684
+ * landing in that window used to register a fresh waiter that nothing would ever
685
+ * cancel, stalling shutdown for up to `MAX_WAIT_MS` — exactly the outcome cancelling
686
+ * was meant to prevent. Once latched, a wait resolves at once with `ended: true`.
687
+ */
688
+ stop() {
689
+ this.stopped = true;
690
+ return this.cancelEverything();
691
+ }
692
+ /**
693
+ * Resolve every waiter in the process. Latches the stopped flag, so this is
694
+ * shutdown-shaped; `stop()` is the same call under the name that says so.
695
+ */
696
+ cancelEverything() {
697
+ this.stopped = true;
698
+ let resolved = 0;
699
+ const ids = new Set([...this.signalWaiters.keys(), ...this.outputWaiters.keys()]);
700
+ for (const id of ids)
701
+ resolved += this.cancelAll(id);
702
+ return resolved;
703
+ }
704
+ }
705
+ // ─── Shared helpers ──────────────────────────────────────────────────────────
706
+ /**
707
+ * Escape sequences `stripAnsi` leaves behind, removed so the text this module MATCHES
708
+ * against is what the pane visually shows.
709
+ *
710
+ * `ANSI_ESCAPE_PATTERN_FULL` covers three families — `ESC [ … letter`,
711
+ * `ESC ] … (BEL|ST)`, and `ESC =` / `ESC >` — and nothing else. The gap is not
712
+ * theoretical: a stock bash prompt emits `arkon@tnode\x1b(B\x1b[m:`, the CSI is removed
713
+ * and the charset-select `ESC ( B` is not, so `match=tnode:` **fails on a prompt that
714
+ * plainly reads `tnode:`** while `match=(B` succeeds. That is the single most likely
715
+ * thing an orchestrating agent tries, and the failure is silent (a full-length timeout).
716
+ *
717
+ * Two branches, in order:
718
+ * - `ESC P|X|^|_ … (BEL|ST)` — DCS / SOS / PM / APC string sequences. The terminator is
719
+ * REQUIRED, exactly as `stripAnsi`'s OSC branch requires one: matching an unterminated
720
+ * string type with a greedy run would delete every byte after it to the end of the
721
+ * window, which is real output an agent is waiting for.
722
+ * - `ESC <0x20-0x2f>* <0x30-0x7e>` — the ECMA-48 escape-sequence grammar (zero or more
723
+ * intermediate bytes then a final byte). This is what covers `ESC ( B`, `ESC ) 0`,
724
+ * `ESC c`, `ESC 7` / `ESC 8`, `ESC # 8` and a stray `ESC \`.
725
+ *
726
+ * Linear-time by the same argument that holds for `stripAnsi`: every starred class is
727
+ * disjoint from what follows it, and `ESC` is excluded from all of them, so two
728
+ * candidate runs can never overlap and there is no backtracking to exploit.
729
+ *
730
+ * ⚠️ This duplicates ANSI knowledge that would be better held once in
731
+ * `src/utils/regex-patterns.ts`. It lives here deliberately: `stripAnsi` has many
732
+ * consumers (respawn and usage-limit pattern matching, the terminal buffer, search) and
733
+ * widening it changes all of them at once, which is not a change to make as a side
734
+ * effect of fixing this endpoint. See the follow-up note in
735
+ * `tmp/agent-wait-review/report-fix-registry.md`.
736
+ */
737
+ // eslint-disable-next-line no-control-regex -- matching raw terminal control bytes is the point
738
+ const ANSI_ESCAPE_RESIDUE = /\x1b(?:[PX^_][^\x07\x1b]*(?:\x07|\x1b\\)|[\x20-\x2f]*[\x30-\x7e])/g;
739
+ /**
740
+ * The text this module matches against: ANSI-stripped, then residue-stripped.
741
+ *
742
+ * Everything downstream (the carry, the haystack, the snippet window) is derived from
743
+ * the output of this function, so there is exactly one definition of "the matched
744
+ * stream" and `findMatch` never sees an escape byte.
745
+ */
746
+ function normalizeForMatch(raw) {
747
+ return stripAnsi(raw).replace(ANSI_ESCAPE_RESIDUE, '');
748
+ }
749
+ /**
750
+ * C0 controls, DEL and the C1 block, minus tab / newline / carriage return. Removed
751
+ * from the snippet so no byte that can reprogram the reading agent's terminal, or
752
+ * begin a sequence that does, survives into the JSON response.
753
+ *
754
+ * Still needed after `normalizeForMatch`, which removes escape SEQUENCES: a bare BEL,
755
+ * NUL or backspace carries no ESC and reaches the haystack intact.
756
+ */
757
+ // eslint-disable-next-line no-control-regex -- removing raw terminal control bytes is the point
758
+ const SNIPPET_CONTROL_BYTES = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g;
759
+ /**
760
+ * Find `needleCmp` in `hay` and return a bounded window of surrounding text,
761
+ * or null when absent. The snippet comes from the ORIGINAL-case text even in
762
+ * nocase mode, so callers see what the terminal actually printed.
763
+ *
764
+ * ## Two texts, and they are not the same string
765
+ *
766
+ * Precision here matters, because an agent that cannot match something reaches for the
767
+ * snippet to work out why:
768
+ *
769
+ * - **Matched**: `hay`, which is `normalizeForMatch()` output — ANSI-stripped and
770
+ * residue-stripped, with nothing else touched. `matched` is decided against exactly
771
+ * this, so it is byte-for-byte what a needle must appear in.
772
+ * - **Returned**: a bounded window of that same text, then RENDERED for a human or an
773
+ * LLM to read: leftover control bytes removed, blank runs collapsed, trimmed.
774
+ *
775
+ * So the snippet is a rendering of the matched window, not a quotation of it. The
776
+ * remaining differences cannot change whether a printable needle matched, but they are
777
+ * real: a needle containing `\n\n` or a raw control byte is matchable and would not
778
+ * appear verbatim in the snippet.
779
+ *
780
+ * Blank runs are collapsed for READABILITY ONLY. A real pane pads with dozens of `\r\n`
781
+ * between the prompt and the match, which would otherwise fill the whole context window
782
+ * with nothing and make the snippet useless to the agent reading it.
783
+ *
784
+ * Control bytes are dropped because, unlike `GET .../terminal`, this field is documented
785
+ * as stripped and its consumer is an agent piping it through `jq` into its OWN terminal
786
+ * pane, so a worker printing attacker-influenced bytes could otherwise reset or garble
787
+ * the orchestrator's display. Whitespace is preserved; every other C0/C1 control and DEL
788
+ * is removed.
789
+ */
790
+ function findMatch(hay, needleCmp, nocase) {
791
+ const cmp = nocase ? hay.toLowerCase() : hay;
792
+ const idx = cmp.indexOf(needleCmp);
793
+ if (idx === -1)
794
+ return null;
795
+ // ⚠️ `idx` indexes `cmp`, and the snippet is sliced out of `hay`. Those agree only
796
+ // while lowercasing preserves length, which it usually but not always does:
797
+ // 'İ' (U+0130) lowercases to TWO code units, so terminal output containing Turkish
798
+ // text, a filename or a git author line shifts every later index and the window
799
+ // slides off the match entirely. A length compare detects it for the cost of one
800
+ // integer read, so the common path stays exactly one `indexOf` and no allocation.
801
+ const drift = cmp.length - hay.length;
802
+ const start = drift === 0 ? idx : originalIndex(hay, idx, drift);
803
+ const matchEnd = drift === 0 ? idx + needleCmp.length : originalIndex(hay, idx + needleCmp.length, drift);
804
+ return hay
805
+ .slice(Math.max(0, start - MAX_SNIPPET_CONTEXT), Math.min(hay.length, matchEnd + MAX_SNIPPET_CONTEXT))
806
+ .replace(SNIPPET_CONTROL_BYTES, '')
807
+ .replace(/[\r\n]{2,}/g, '\n')
808
+ .trim();
809
+ }
810
+ /**
811
+ * Map an index in `hay.toLowerCase()` back to the equivalent index in `hay`, for the
812
+ * rare case where the two differ in length by `drift`.
813
+ *
814
+ * `f(i) = hay.slice(0, i).toLowerCase().length` is non-decreasing and satisfies
815
+ * `i <= f(i) <= i + drift` (no lowercase mapping ever shortens a string), which bounds
816
+ * the answer to `[cmpIdx - drift, cmpIdx]` and lets a binary search find it in
817
+ * `log2(drift)` steps rather than walking the string. Ties break low, so an index that
818
+ * lands mid-expansion resolves to the character that expanded, which keeps the window
819
+ * around the match rather than past it.
820
+ */
821
+ function originalIndex(hay, cmpIdx, drift) {
822
+ let lo = Math.max(0, cmpIdx - drift);
823
+ let hi = Math.min(hay.length, cmpIdx);
824
+ while (lo < hi) {
825
+ const mid = Math.ceil((lo + hi) / 2);
826
+ if (hay.slice(0, mid).toLowerCase().length <= cmpIdx)
827
+ lo = mid;
828
+ else
829
+ hi = mid - 1;
830
+ }
831
+ return lo;
832
+ }
833
+ /**
834
+ * Tail of `text` to carry into the next chunk. Long enough to complete a straddling
835
+ * match and to give a later snippet some leading context.
836
+ *
837
+ * Callers pass the COMPARISON needle's length, not the original's: under `nocase` the
838
+ * text that matches can be longer than the needle as typed (a needle of `İ` compares
839
+ * as two code units, so two code units of pane output can satisfy one needle
840
+ * character), and a carry sized from the shorter one drops a straddling match.
841
+ */
842
+ function tailFor(text, needleLength) {
843
+ const keep = Math.max(needleLength - 1, MAX_SNIPPET_CONTEXT);
844
+ return keep >= text.length ? text : text.slice(text.length - keep);
845
+ }
846
+ /**
847
+ * Longest tail held back as a possibly-incomplete escape sequence.
848
+ *
849
+ * Generous enough for a real OSC title (`ESC ] 0 ; <path> BEL`) to survive a chunk
850
+ * split, small enough that an unterminated sequence cannot withhold meaningful output:
851
+ * past this the tail is treated as ordinary text and released, so a malformed stream
852
+ * degrades to the old behavior instead of stalling every match on the session.
853
+ */
854
+ const MAX_PENDING_ESCAPE = 512;
855
+ /**
856
+ * A tail that is a strict PREFIX of a sequence `normalizeForMatch` would remove: a lone
857
+ * ESC, an unterminated `ESC [ …`, an unterminated string type (`ESC ] P X ^ _ …`), or
858
+ * intermediate bytes still waiting for their final byte (`ESC (` before its `B`).
859
+ * Anything else is either complete or not an escape at all, and both are safe to strip
860
+ * now.
861
+ *
862
+ * ⚠️ The third branch must stay in lockstep with `ANSI_ESCAPE_RESIDUE`'s second one.
863
+ * When the residue pass learned about `ESC ( B`, a chunk cut between the `(` and the `B`
864
+ * became a NEW way to smuggle an escape into the haystack, since neither half is a
865
+ * complete sequence on its own — the same bug one level down, which is what this
866
+ * function exists to prevent.
867
+ *
868
+ * Deliberately without the `g` flag: a global pattern would carry `lastIndex` between
869
+ * calls (see the repo-wide global-regex hazard) and this runs once per PTY chunk.
870
+ */
871
+ // eslint-disable-next-line no-control-regex -- matching raw terminal control bytes is the point
872
+ const INCOMPLETE_ANSI_TAIL = /^\x1b(?:\[[0-9;?]*|[\]PX^_][^\x07\x1b]*|[\x20-\x2f]*)$/;
873
+ /**
874
+ * Split a raw chunk into the part that is safe to ANSI-strip now and a trailing
875
+ * fragment to hold for the next chunk.
876
+ *
877
+ * Only the LAST `ESC` can be incomplete: anything before it is followed by an escape
878
+ * introducer, so `stripAnsi` will consume or reject it on this pass either way.
879
+ */
880
+ function splitTrailingEscape(raw) {
881
+ const esc = raw.lastIndexOf('\x1b');
882
+ if (esc === -1)
883
+ return { text: raw, pending: '' };
884
+ const tail = raw.slice(esc);
885
+ if (tail.length > MAX_PENDING_ESCAPE || !INCOMPLETE_ANSI_TAIL.test(tail)) {
886
+ return { text: raw, pending: '' };
887
+ }
888
+ return { text: raw.slice(0, esc), pending: tail };
889
+ }
890
+ /**
891
+ * Process-wide registry used by the routes and the session/hook wiring.
892
+ * Tests should construct their own `SessionWaitRegistry` instead of touching this.
893
+ */
894
+ export const sessionWaits = new SessionWaitRegistry();
895
+ //# sourceMappingURL=session-wait-registry.js.map