ai-or-die 0.1.79 → 0.1.80

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-or-die",
3
- "version": "0.1.79",
3
+ "version": "0.1.80",
4
4
  "description": "Universal AI coding terminal — Claude, Copilot, Gemini & more in your browser",
5
5
  "main": "src/server.js",
6
6
  "bin": {
@@ -17,7 +17,7 @@
17
17
  "test:browser": "npx playwright test --config e2e/playwright.config.js",
18
18
  "soak": "node test/longevity/harness/cli.js",
19
19
  "test:longevity-smoke": "mocha --exit --timeout 120000 test/longevity/smoke.test.js",
20
- "test:longevity:server": "mocha --exit --timeout 120000 --recursive --extension .test.js test/longevity/event-loop test/longevity/disk test/longevity/process test/longevity/browser-sampler.test.js test/longevity/cli.test.js test/longevity/gate-evaluator-directional.test.js test/longevity/gate-evaluator-vacuous.test.js test/longevity/resume.test.js test/longevity/smoke.test.js",
20
+ "test:longevity:server": "mocha --exit --timeout 120000 --recursive --extension .test.js test/longevity/event-loop test/longevity/disk test/longevity/process test/longevity/browser-sampler.test.js test/longevity/cli.test.js test/longevity/gate-evaluator-directional.test.js test/longevity/gate-evaluator-vacuous.test.js test/longevity/event-loop-tolerance.test.js test/longevity/resume.test.js test/longevity/smoke.test.js",
21
21
  "test:longevity:browser": "playwright test --config test/longevity/playwright.config.js",
22
22
  "test:longevity": "npm run test:longevity:server && npm run test:longevity:browser",
23
23
  "build:bundle": "node scripts/build-sea.js bundle",
@@ -275,6 +275,8 @@ class BaseBridge {
275
275
  const {
276
276
  workingDir = process.cwd(),
277
277
  dangerouslySkipPermissions = false,
278
+ permissionMode = undefined,
279
+ agentArgs = undefined,
278
280
  onOutput = () => {},
279
281
  onExit = () => {},
280
282
  onError = () => {},
@@ -292,7 +294,10 @@ class BaseBridge {
292
294
  console.log(`WARNING: Using ${this.dangerousFlag} flag`);
293
295
  }
294
296
 
295
- const args = this.buildArgs({ dangerouslySkipPermissions });
297
+ // F10: forward permissionMode/agentArgs so tool-specific buildArgs (claude)
298
+ // can emit --permission-mode + caller passthrough flags. Subclasses that
299
+ // don't override buildArgs (terminal/codex) ignore these.
300
+ const args = this.buildArgs({ dangerouslySkipPermissions, permissionMode, agentArgs });
296
301
 
297
302
  const env = {
298
303
  ...process.env,
@@ -1,4 +1,16 @@
1
1
  const BaseBridge = require('./base-bridge');
2
+ const { TRUST_PROMPT_REGEX } = require('./control/session-status');
3
+
4
+ // claude's --permission-mode allowlist (F10). The fleet/control plane forwards one
5
+ // of these; an unknown value is rejected up front rather than passed to claude (a
6
+ // flag silently ignored by a skewed claude is a dishonest "mode set").
7
+ const VALID_PERMISSION_MODES = ['plan', 'acceptEdits', 'default', 'bypassPermissions'];
8
+
9
+ function invalidArgument(message) {
10
+ const err = new Error(message);
11
+ err.code = 'INVALID_ARGUMENT';
12
+ return err;
13
+ }
2
14
 
3
15
  // Claude is ALWAYS launched through github-router (never the raw `claude`
4
16
  // binary): `npx -y github-router@latest claude --browse [claude flags]`. This
@@ -53,22 +65,79 @@ class ClaudeBridge extends BaseBridge {
53
65
  this._trustPromptHandled = new Map();
54
66
  }
55
67
 
68
+ /**
69
+ * F10 — the ONE canonical translation layer from a high-level launch intent
70
+ * (permissionMode + caller agentArgs) to claude's argv, appended AFTER the
71
+ * github-router launcher prefix (`npx -y github-router@latest claude --browse`).
72
+ *
73
+ * claude is launched through github-router, whose `claude` subcommand emits
74
+ * `--dangerously-skip-permissions` by default and DROPS it when a non-bypass
75
+ * `--permission-mode` is present. So here we only need to forward the mode flag;
76
+ * github-router reconciles the dangerous flag on its side.
77
+ *
78
+ * Conflict policy: `agentArgs` may NOT itself carry
79
+ * `--permission-mode` or `--dangerously-skip-permissions` — that would emit
80
+ * duplicate/conflicting flags. We throw INVALID_ARGUMENT so the create fails
81
+ * cleanly instead of launching an ambiguous claude. An unknown `permissionMode`
82
+ * is likewise rejected.
83
+ */
84
+ buildArgs(options = {}) {
85
+ const prefix = this._prefixArgs || [];
86
+ const out = [...prefix];
87
+ const { permissionMode, agentArgs, dangerouslySkipPermissions } = options;
88
+
89
+ if (agentArgs != null && !Array.isArray(agentArgs)) {
90
+ throw invalidArgument('agentArgs must be an array of strings');
91
+ }
92
+ const extra = Array.isArray(agentArgs) ? agentArgs.map((a) => String(a)) : [];
93
+ for (const a of extra) {
94
+ if (/^--permission-mode(=|$)/.test(a) || a === '--dangerously-skip-permissions') {
95
+ throw invalidArgument(
96
+ `agentArgs may not contain '${a}'; use permissionMode for permission control`
97
+ );
98
+ }
99
+ }
100
+
101
+ if (permissionMode != null && permissionMode !== '') {
102
+ const mode = String(permissionMode);
103
+ if (!VALID_PERMISSION_MODES.includes(mode)) {
104
+ throw invalidArgument(
105
+ `Unknown permissionMode '${mode}' (expected one of: ${VALID_PERMISSION_MODES.join(', ')})`
106
+ );
107
+ }
108
+ // permissionMode is the source of truth: emit the canonical flag and let
109
+ // github-router drop --dangerously-skip-permissions for non-bypass modes.
110
+ out.push('--permission-mode', mode);
111
+ } else if (dangerouslySkipPermissions && this.dangerousFlag) {
112
+ // Back-compat: no explicit mode → honor the legacy skip-permissions toggle.
113
+ out.push(this.dangerousFlag);
114
+ }
115
+
116
+ out.push(...extra);
117
+ return out;
118
+ }
119
+
56
120
  processOutput(sessionId, ptyProcess, dataBuffer) {
57
121
  if (this._trustPromptHandled.get(sessionId)) return;
58
122
  // Strip ANSI first: claude's Ink TUI interleaves escape codes between words,
59
- // so a clean-string `includes` misses the folder-trust modal (and the exact
60
- // wording shifts between claude versions). Match a stable substring of the
61
- // "Do you trust the files in this folder?" / "1. Yes, I trust this folder"
62
- // modal on the de-ANSI'd buffer, then accept the default (Enter = Yes). The
123
+ // so a clean-string match misses the folder-trust modal (and the exact wording
124
+ // shifts between claude versions). Match the shared TRUST_PROMPT_REGEX on the
125
+ // de-ANSI'd buffer but ONLY act when the modal's numbered "1. / 2." choice
126
+ // list is ALSO present. That structural guard (mirroring awaitingFromScreen)
127
+ // prevents a false positive from claude merely PRINTING a trust phrase in prose
128
+ // / a file / a commit message, which would otherwise inject a spurious keystroke
129
+ // into the live PTY. Then send the EXPLICIT "1" choice + Enter (not a bare Enter,
130
+ // which could land on a wrong default) — matching _controlMapResponseKeys. The
63
131
  // per-session guard makes this a one-shot. This is what lets HEADLESS /
64
132
  // fleet-spawned claude sessions (no human to click) get past trust.
65
133
  const plain = String(dataBuffer || '').replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '');
66
- if (/Do you trust the files in this folder|trust this folder/i.test(plain)) {
134
+ const numbered = /\b1\.\s/.test(plain) && /\b2\.\s/.test(plain);
135
+ if (numbered && TRUST_PROMPT_REGEX.test(plain)) {
67
136
  this._trustPromptHandled.set(sessionId, true);
68
137
  console.log(`Auto-accepting trust prompt for session ${sessionId}`);
69
138
  setTimeout(() => {
70
- try { ptyProcess.write('\r'); } catch (_) { /* pty may have exited */ }
71
- console.log(`Sent Enter to accept trust prompt for session ${sessionId}`);
139
+ try { ptyProcess.write('1\r'); } catch (_) { /* pty may have exited */ }
140
+ console.log(`Sent "1" + Enter to accept trust prompt for session ${sessionId}`);
72
141
  }, 500);
73
142
  }
74
143
  }
@@ -81,3 +150,4 @@ class ClaudeBridge extends BaseBridge {
81
150
 
82
151
  module.exports = ClaudeBridge;
83
152
  module.exports.resolveClaudeLauncher = resolveClaudeLauncher;
153
+ module.exports.VALID_PERMISSION_MODES = VALID_PERMISSION_MODES;
@@ -11,16 +11,39 @@
11
11
  // - EPOCH: a per-process id. An instance restart mints a new epoch, so a
12
12
  // client holding a pre-restart cursor is told `gap: restart` instead of
13
13
  // silently resuming a reset sequence (the ABA problem).
14
- // - GAP REPORTING: when the ring overflows past a client's cursor, `since()`
15
- // returns `gap: overflow` so the client resyncs (via list_sessions) instead
16
- // of believing nothing happened.
17
- // - The cursor is `{ epoch, seq }`; the model never threads it the fleet
18
- // layer manages it per-client (contract v1).
14
+ // - GAP REPORTING: when retention rolls past a client's cursor, `since()`
15
+ // returns `gap: overflow` so the client resyncs (via snapshot) instead of
16
+ // believing nothing happened.
17
+ // - The cursor is `{ epoch, seq }`; the model never threads it - the fleet
18
+ // layer manages it per-client (contract v1). Cursors are PER-WATCHER:
19
+ // `since()` / `waitFor()` are pure reads parameterised by the caller's
20
+ // cursor, with NO shared global watcher position, so many concurrent
21
+ // watchers each resume from their own `{epoch,seq}` without interfering
22
+ // (F22).
23
+ //
24
+ // RETENTION IS PER SESSION (F15). The naive single global ring let one chatty
25
+ // session roll the buffer and evict another session's last `turn_ended`, forcing
26
+ // an avoidable resync. Instead each session keeps its OWN bounded ring, so a busy
27
+ // session only evicts its own old events - never another session's turn boundary.
28
+ // A global monotonic `seq` still orders events across sessions for the cursor,
29
+ // and `_maxEvictedSeq` (the highest seq ever dropped from ANY ring) drives
30
+ // overflow detection: a cursor older than it has provably missed an event. Whole
31
+ // idle-session rings are evicted (LRU) only past a large session cap, also
32
+ // bumping `_maxEvictedSeq` so the drop is never silent.
19
33
 
20
34
  const { EventEmitter } = require('events');
21
35
  const crypto = require('crypto');
22
36
 
23
- const DEFAULT_MAX_EVENTS = 1000;
37
+ // Per-SESSION ring depth. ~85 turns of busy/idle/turn_ended history per session,
38
+ // so a session's last turn_ended survives any realistic fan-out burst by OTHER
39
+ // sessions. (Back-compat: the old `maxEvents` option maps to this.)
40
+ const DEFAULT_MAX_EVENTS_PER_SESSION = 256;
41
+ // Whole-session bucket cap. Past this many distinct sessions, the least-recently
42
+ // active session's ring is evicted wholesale (its events are old/dead). Sized for
43
+ // the scale bar (100+ live sessions) with generous headroom for exited ones.
44
+ const DEFAULT_MAX_SESSIONS = 1024;
45
+ // Bucket key for session-less events (defensive; current emitters always pass an id).
46
+ const GLOBAL_KEY = '__global__';
24
47
 
25
48
  // Event kinds the control plane emits (mirrors the contract's await_turn `kind`).
26
49
  const EVENT_KINDS = Object.freeze([
@@ -38,18 +61,28 @@ class ControlEventBus extends EventEmitter {
38
61
  constructor(options = {}) {
39
62
  super();
40
63
  this.setMaxListeners(0); // many concurrent long-polls
41
- this._maxEvents = options.maxEvents || DEFAULT_MAX_EVENTS;
64
+ // `maxEvents` is the legacy single-ring cap; it now sizes the PER-SESSION ring
65
+ // (a chatty session evicts only its own old events, never another's turn_ended).
66
+ this._maxPerSession = options.maxEventsPerSession || options.maxEvents || DEFAULT_MAX_EVENTS_PER_SESSION;
67
+ this._maxSessions = options.maxSessions || DEFAULT_MAX_SESSIONS;
42
68
  // A fresh, unguessable epoch per process. Math.random/Date are fine here
43
69
  // (normal server code), but a crypto id avoids cross-process collisions.
44
70
  this._epoch = options.epoch || crypto.randomBytes(8).toString('hex');
45
71
  this._seq = 0; // last assigned seq; first event is seq 1
46
- this._ring = []; // [{ seq, sessionId, kind, at, detail? }], ascending seq
72
+ this._buckets = new Map(); // sessionKey -> [{ seq, sessionId, kind, at, detail? }] ascending; Map order = LRU
73
+ this._maxEvictedSeq = 0; // highest seq dropped from ANY ring (GLOBAL overflow floor, for unfiltered watchers)
74
+ this._evictedBySession = new Map(); // sessionKey -> highest seq evicted from THAT bucket (filter-aware overflow)
47
75
  }
48
76
 
49
77
  get epoch() {
50
78
  return this._epoch;
51
79
  }
52
80
 
81
+ /** Per-session ring depth (the retention contract surfaced via /capabilities). */
82
+ get maxEventsPerSession() {
83
+ return this._maxPerSession;
84
+ }
85
+
53
86
  /** Append an event, assign it the next seq, and notify long-poll waiters. */
54
87
  append(sessionId, kind, detail) {
55
88
  if (!EVENT_KINDS.includes(kind)) {
@@ -57,22 +90,79 @@ class ControlEventBus extends EventEmitter {
57
90
  }
58
91
  const event = { seq: ++this._seq, sessionId: sessionId || null, kind, at: Date.now() };
59
92
  if (detail !== undefined) event.detail = detail;
60
- this._ring.push(event);
61
- if (this._ring.length > this._maxEvents) {
62
- this._ring.splice(0, this._ring.length - this._maxEvents);
93
+
94
+ const key = event.sessionId || GLOBAL_KEY;
95
+ let bucket = this._buckets.get(key);
96
+ if (bucket) {
97
+ this._buckets.delete(key); // re-insert to mark this session most-recently-active (LRU order)
98
+ } else {
99
+ bucket = [];
100
+ }
101
+ bucket.push(event);
102
+ if (bucket.length > this._maxPerSession) {
103
+ // Evict THIS session's oldest events only - never another session's.
104
+ const dropped = bucket.splice(0, bucket.length - this._maxPerSession);
105
+ this._bumpEvictedFor(key, dropped[dropped.length - 1].seq);
63
106
  }
107
+ this._buckets.set(key, bucket);
108
+
109
+ // Whole-session eviction: past the session cap, drop the least-recently-active
110
+ // session's ring entirely (its events are stale). Bump the floor so a watcher
111
+ // holding a cursor into that range gets an overflow gap, not a silent loss.
112
+ while (this._buckets.size > this._maxSessions) {
113
+ const oldestKey = this._buckets.keys().next().value;
114
+ const oldest = this._buckets.get(oldestKey);
115
+ this._buckets.delete(oldestKey);
116
+ if (oldest && oldest.length) this._bumpEvictedFor(oldestKey, oldest[oldest.length - 1].seq);
117
+ }
118
+
64
119
  this.emit('event', event);
65
120
  return event;
66
121
  }
67
122
 
123
+ _bumpEvicted(seq) {
124
+ if (seq > this._maxEvictedSeq) this._maxEvictedSeq = seq;
125
+ }
126
+
127
+ /** Bump BOTH the global overflow floor and the per-bucket evicted watermark. */
128
+ _bumpEvictedFor(key, seq) {
129
+ this._bumpEvicted(seq);
130
+ const cur = this._evictedBySession.get(key) || 0;
131
+ if (seq > cur) this._evictedBySession.set(key, seq);
132
+ }
133
+
68
134
  /** The cursor a fresh consumer should start from (i.e. "only new events"). */
69
135
  headCursor() {
70
136
  return { epoch: this._epoch, seq: this._seq };
71
137
  }
72
138
 
73
139
  /**
74
- * Events strictly after `cursor`, plus any gap markers. Pure read.
75
- * @param {?{epoch:string, seq:number}} cursor omitted/null start at head (no replay)
140
+ * Retained events with seq strictly greater than `minSeq`, merged across all
141
+ * session rings and returned ascending by seq. Each ring is already ascending,
142
+ * so we walk it from the tail and stop once we pass `minSeq` - only the new tail
143
+ * of each session is collected, not the whole fleet history.
144
+ */
145
+ _collectAfter(minSeq) {
146
+ const out = [];
147
+ for (const bucket of this._buckets.values()) {
148
+ for (let i = bucket.length - 1; i >= 0; i--) {
149
+ if (bucket[i].seq <= minSeq) break;
150
+ out.push(bucket[i]);
151
+ }
152
+ }
153
+ out.sort((a, b) => a.seq - b.seq);
154
+ return out;
155
+ }
156
+
157
+ /** All retained events, ascending by seq (diagnostics / restart resync). */
158
+ listEvents() {
159
+ return this._collectAfter(0);
160
+ }
161
+
162
+ /**
163
+ * Events strictly after `cursor`, plus any gap markers. Pure read - no shared
164
+ * cursor state, so concurrent watchers are independent (F22).
165
+ * @param {?{epoch:string, seq:number}} cursor omitted/null -> start at head (no replay)
76
166
  * @param {?{sessionIds?:string[], kinds?:string[]}} [filter]
77
167
  */
78
168
  since(cursor, filter) {
@@ -86,18 +176,34 @@ class ControlEventBus extends EventEmitter {
86
176
  // The instance restarted under this consumer. Surface a gap and hand back
87
177
  // everything we currently retain so the consumer can resync.
88
178
  gaps.push({ reason: 'restart' });
89
- events = this._ring.slice();
179
+ events = this._collectAfter(0);
90
180
  } else {
91
- const oldestSeq = this._ring.length ? this._ring[0].seq : this._seq + 1;
92
- if (cursor.seq < oldestSeq - 1) {
93
- // The ring rolled past the cursor: events were dropped.
94
- gaps.push({ reason: 'overflow', fromSeq: cursor.seq, toSeq: oldestSeq - 1 });
181
+ // Overflow is FILTER-AWARE: a session-filtered watcher must only see overflow
182
+ // caused by eviction in a session IT watches, not by another chatty session's
183
+ // eviction. Unfiltered watchers use the global floor.
184
+ const overflowFloor = (filter && Array.isArray(filter.sessionIds) && filter.sessionIds.length)
185
+ ? Math.max(0, ...filter.sessionIds.map((id) => this._evictedBySession.get(id || GLOBAL_KEY) || 0))
186
+ : this._maxEvictedSeq;
187
+ if (cursor.seq < overflowFloor) {
188
+ // At least one event the watcher cares about was evicted: it must resync
189
+ // via the snapshot endpoint.
190
+ gaps.push({ reason: 'overflow', fromSeq: cursor.seq, toSeq: overflowFloor });
95
191
  }
96
- events = this._ring.filter((e) => e.seq > cursor.seq);
192
+ // Collect AFTER the gap boundary so returned events never contradict the gap
193
+ // (an event with seq inside [cursor.seq, overflowFloor] would overlap the
194
+ // claimed-lost range). With no overflow this is just cursor.seq - unchanged.
195
+ events = this._collectAfter(Math.max(cursor.seq, overflowFloor));
97
196
  }
98
197
 
99
198
  const filtered = applyFilter(events, filter);
100
- const maxSeq = events.length ? events[events.length - 1].seq : this._seq;
199
+ // Advance the cursor to the global head (`_seq`) past EVERY event seen, so a
200
+ // filtered watcher never re-scans and a stale cursor can't re-trigger overflow.
201
+ // The newest event is always retained (a ring evicts only its own oldest, and
202
+ // the just-active bucket is never the LRU whole-session victim), so in every
203
+ // reachable state `events[last].seq === _seq` whenever anything is newer than
204
+ // the cursor; setting it unconditionally removes that fragile invariant-reasoning
205
+ // with zero behaviour change.
206
+ const maxSeq = this._seq;
101
207
  return { events: filtered, gaps, cursor: { epoch: this._epoch, seq: maxSeq }, more: false };
102
208
  }
103
209
 
@@ -108,8 +214,17 @@ class ControlEventBus extends EventEmitter {
108
214
  * @returns {Promise<{events:Array, gaps:Array, cursor:object, more:boolean}>}
109
215
  */
110
216
  waitFor(cursor, timeoutMs, filter) {
111
- const immediate = this.since(cursor, filter);
112
- if (immediate.events.length || immediate.gaps.length) return Promise.resolve(immediate);
217
+ // A fresh watcher (no cursor) must still RECEIVE the event that wakes the
218
+ // long-poll. since(undefined) returns []; without anchoring to the current
219
+ // head, the woken poll would resolve since(undefined) again and drop the very
220
+ // event it woke for. Anchor to head: only events AFTER wait-start are returned
221
+ // (no history replay), and the waking event is delivered.
222
+ const baseCursor = cursor || this.headCursor();
223
+ const immediate = this.since(baseCursor, filter);
224
+ // Return immediately when there's already something to deliver, OR when this is
225
+ // a non-blocking poll (timeoutMs <= 0): a 0-timeout caller wants "what's there
226
+ // now", and entering the wait with no armed timer (FIX D) would otherwise hang.
227
+ if (immediate.events.length || immediate.gaps.length || timeoutMs <= 0) return Promise.resolve(immediate);
113
228
 
114
229
  return new Promise((resolve) => {
115
230
  let done = false;
@@ -117,14 +232,16 @@ class ControlEventBus extends EventEmitter {
117
232
  if (done) return;
118
233
  done = true;
119
234
  this.removeListener('event', onEvent);
120
- clearTimeout(timer);
121
- resolve(this.since(cursor, filter));
235
+ if (timer) clearTimeout(timer);
236
+ resolve(this.since(baseCursor, filter));
122
237
  };
123
238
  const onEvent = (event) => {
124
239
  if (matches(event, filter)) finish();
125
240
  };
126
- const timer = setTimeout(finish, Math.max(0, timeoutMs || 0));
127
- if (timer.unref) timer.unref();
241
+ // FIX D: only arm the timer when a positive timeout is given; setTimeout(0)
242
+ // would resolve on the next macrotask and turn the long-poll into a hot loop.
243
+ const timer = timeoutMs > 0 ? setTimeout(finish, timeoutMs) : null;
244
+ if (timer && timer.unref) timer.unref();
128
245
  this.on('event', onEvent);
129
246
  });
130
247
  }
@@ -146,4 +263,4 @@ function applyFilter(events, filter) {
146
263
  return events.filter((e) => matches(e, filter));
147
264
  }
148
265
 
149
- module.exports = { ControlEventBus, EVENT_KINDS, DEFAULT_MAX_EVENTS };
266
+ module.exports = { ControlEventBus, EVENT_KINDS, DEFAULT_MAX_EVENTS_PER_SESSION, DEFAULT_MAX_SESSIONS };
@@ -67,16 +67,18 @@ function summaryFromStatus(id, session, status) {
67
67
 
68
68
  function parseCursor(raw) {
69
69
  if (!raw) return undefined;
70
- // Accept either "epoch:seq" or a base64url JSON blob.
70
+ // Accept either "epoch:seq" or a base64url JSON blob. A valid seq is a
71
+ // non-negative safe integer (FIX C: reject -5, 1.5, NaN so a present-but-invalid
72
+ // cursor is a client bug surfaced as 400, not a silent fresh tail-follow).
71
73
  if (typeof raw === 'string' && raw.includes(':')) {
72
74
  const idx = raw.lastIndexOf(':');
73
75
  const epoch = raw.slice(0, idx);
74
76
  const seq = Number(raw.slice(idx + 1));
75
- if (epoch && Number.isFinite(seq)) return { epoch, seq };
77
+ if (epoch && Number.isSafeInteger(seq) && seq >= 0) return { epoch, seq };
76
78
  }
77
79
  try {
78
80
  const obj = JSON.parse(Buffer.from(String(raw), 'base64').toString('utf8'));
79
- if (obj && typeof obj.epoch === 'string' && Number.isFinite(obj.seq)) return obj;
81
+ if (obj && typeof obj.epoch === 'string' && Number.isSafeInteger(obj.seq) && obj.seq >= 0) return obj;
80
82
  } catch {
81
83
  /* fall through */
82
84
  }
@@ -157,17 +159,52 @@ function createControlRouter(deps) {
157
159
  if (isRateLimitExempt(req) || !rateLimit.max || !rateLimit.windowMs) return next();
158
160
  const limited = checkRateLimit(rateLimitBuckets, rateLimit, rateLimitIdentity(req));
159
161
  if (limited) {
162
+ // F21: a CLASSIFIABLE 429 so the fleet client can back off precisely rather
163
+ // than hammer. Stable error.code='RATE_LIMITED' + retryAfterMs in the body,
164
+ // and the standard Retry-After header (whole seconds, min 1).
165
+ const retryAfterSec = Math.max(1, Math.ceil(limited.retryAfterMs / 1000));
166
+ res.set('Retry-After', String(retryAfterSec));
160
167
  return res.status(429).json({
161
168
  error: {
162
169
  code: 'RATE_LIMITED',
163
170
  message: 'Too many control requests; retry after the current rate-limit window',
164
171
  retryAfterMs: limited.retryAfterMs,
172
+ retryAfterSec,
165
173
  },
166
174
  });
167
175
  }
168
176
  next();
169
177
  });
170
178
 
179
+ // GET /capabilities — F19 cross-repo capability negotiation. The fleet client
180
+ // reads this ONCE per instance and fails closed on a missing capability, so a
181
+ // newer client never silently assumes an older instance supports a field/event.
182
+ router.get('/capabilities', (req, res, next) => {
183
+ try {
184
+ res.json(deps.capabilities ? deps.capabilities() : { capabilities: [], controlVersion: '0' });
185
+ } catch (err) {
186
+ next(err);
187
+ }
188
+ });
189
+
190
+ // GET /snapshot — F15 atomic batch resync. Returns every session's derived
191
+ // status PLUS the event cursor, captured atomically (cursor first, then
192
+ // statuses), so after a gap/overflow the controller resyncs in ONE call and
193
+ // resumes the long-poll from `cursor` with zero lost events (a boundary event
194
+ // may be redelivered — safe/idempotent — but never dropped).
195
+ router.get('/snapshot', async (req, res, next) => {
196
+ try {
197
+ const snap = deps.snapshot ? await deps.snapshot() : { sessions: [], cursor: null, capturedAt: Date.now() };
198
+ res.json({
199
+ sessions: snap.sessions || [],
200
+ cursor: snap.cursor ? encodeCursor(snap.cursor) : null,
201
+ capturedAt: snap.capturedAt,
202
+ });
203
+ } catch (err) {
204
+ next(err);
205
+ }
206
+ });
207
+
171
208
  // GET /sessions — list with derived status (light).
172
209
  router.get('/sessions', async (req, res, next) => {
173
210
  try {
@@ -215,6 +252,11 @@ function createControlRouter(deps) {
215
252
  if (err && err.code === 'INVALID_WORKDIR') {
216
253
  return res.status(403).json({ error: { code: 'CAPABILITY_DENIED', message: err.message } });
217
254
  }
255
+ if (err && err.code) {
256
+ return res.status(err.statusCode || statusForErrorCode(err.code)).json({
257
+ error: { code: err.code, message: err.message },
258
+ });
259
+ }
218
260
  next(err);
219
261
  }
220
262
  });
@@ -305,6 +347,10 @@ function createControlRouter(deps) {
305
347
  router.get('/events', async (req, res, next) => {
306
348
  try {
307
349
  const cursor = parseCursor(req.query.cursor);
350
+ // FIX C: ABSENT cursor → fresh watcher (ok); PRESENT-but-INVALID → client bug.
351
+ if (req.query.cursor && cursor === undefined) {
352
+ return res.status(400).json({ error: { code: 'INVALID_ARGUMENT', message: 'invalid cursor' } });
353
+ }
308
354
  const timeoutMs = clampInt(req.query.timeoutMs, DEFAULT_EVENTS_TIMEOUT_MS, 0, MAX_EVENTS_TIMEOUT_MS);
309
355
  const filter = {};
310
356
  if (req.query.sessionIds) filter.sessionIds = String(req.query.sessionIds).split(',').filter(Boolean);