@pugi/cli 0.1.0-alpha.18 → 0.1.0-alpha.20

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.
@@ -37,6 +37,8 @@ import { getJobRegistry } from '../jobs/registry.js';
37
37
  import { extractAskTags, extractPlanReviewTags, signatureForAsk, } from './ask.js';
38
38
  import { existsSync, readdirSync, statSync } from 'node:fs';
39
39
  import { resolve as resolvePath } from 'node:path';
40
+ import { CancellationToken } from './cancellation.js';
41
+ import { DispatchFSM } from './dispatch-fsm.js';
40
42
  const MAX_TRANSCRIPT_ROWS = 500;
41
43
  const MAX_TOOL_CALLS = 200;
42
44
  const MAX_RECONNECT_ATTEMPTS = 10;
@@ -134,6 +136,22 @@ export class ReplSession {
134
136
  * with `[store]` errors on every keystroke.
135
137
  */
136
138
  storeErrorEmitted = false;
139
+ /**
140
+ * Privacy mode fetched on bootstrap from /api/admin/privacy/mode and
141
+ * surfaced via `renderPrivacyBanner` (one-line system message after
142
+ * splash). Cached on the session so the in-REPL `/privacy` slash
143
+ * command can render the live mode without a second round-trip on
144
+ * the input box's thread. Null means "not yet fetched" (still
145
+ * connecting) OR "fetch failed" (offline / unauthenticated). The
146
+ * `/privacy` slash falls back to the contract doc with an "unknown"
147
+ * banner when null.
148
+ *
149
+ * Triple-review P1 fix (2026-05-25): the prior build defined
150
+ * `renderPrivacyBanner` but never called it, and `/privacy` always
151
+ * rendered with `null` mode. The contract was advertised but the
152
+ * operator had no mode visibility.
153
+ */
154
+ privacyMode = null;
137
155
  /**
138
156
  * α6.5 Tier 0 / Tier 1 / chokidar wiring. The bootstrap builds the
139
157
  * skeleton + working set + watcher once and hands them to the
@@ -204,6 +222,67 @@ export class ReplSession {
204
222
  * complete envelope lands OR the turn ends. (α6.3.)
205
223
  */
206
224
  askBuffer = new Map();
225
+ /**
226
+ * α6.9 dispatch FSM. One instance owned by the session; transitions
227
+ * are mirrored into `state.dispatchState` via an onEnter listener so
228
+ * subscribers see every change. Resets to `idle` after a terminal
229
+ * transition (`completed` / `failed` / `aborted`) so the next brief
230
+ * starts clean. Idle is the start state.
231
+ */
232
+ // Not readonly: resetFsmToIdle swaps the instance in place on the
233
+ // next brief after a terminal transition (the FSM does not allow
234
+ // direct `completed -> awaiting_response`, so we mint a fresh
235
+ // machine). External read-only access stays via `getDispatchState()`
236
+ // accessor - callers cannot reach into this private field.
237
+ fsm = new DispatchFSM();
238
+ /**
239
+ * α6.9 cancellation token for the currently in-flight dispatch.
240
+ * Minted on `dispatchBrief()` and released on terminal transitions.
241
+ * When non-null, calling `cancel()` aborts the token, closes the SSE
242
+ * stream, and transitions the FSM to `aborting` → `aborted`.
243
+ *
244
+ * Null when `dispatchState === 'idle'` — no dispatch to cancel.
245
+ */
246
+ currentDispatchToken = null;
247
+ /**
248
+ * R2 P1 fix (Codex triple-review 2026-05-25): monotonic dispatch
249
+ * sequence id. Incremented on every `dispatchBrief()`. The
250
+ * agent.spawned handler stamps the current value into
251
+ * `taskDispatchSeq[event.taskId]`. Terminal handlers
252
+ * (agent.completed / blocked / failed) check the stamped value
253
+ * against `dispatchSeq` and short-circuit if the event belongs to
254
+ * a SUPERSEDED dispatch. Without this gate a stale terminal event
255
+ * from a prior dispatch would drive the new FSM to terminal and
256
+ * null the new dispatch's token, leaving brief #2 uncancellable.
257
+ */
258
+ dispatchSeq = 0;
259
+ /**
260
+ * Per-task dispatch-sequence stamp. Populated on agent.spawned with
261
+ * the live `dispatchSeq` value. Consulted by terminal handlers to
262
+ * decide whether the event belongs to the current dispatch (seq
263
+ * matches) or a superseded one (seq is stale). Entries are NOT
264
+ * removed on terminal events because a late event from the same
265
+ * task should still be classified correctly; the map is small
266
+ * (one entry per spawned subagent in the session lifetime) and
267
+ * cleared on close().
268
+ */
269
+ taskDispatchSeq = new Map();
270
+ /**
271
+ * R3 P1 fix (Codex triple-review 2026-05-25): wall-clock guard used to
272
+ * drop SSE events whose `event.timestamp` predates the current
273
+ * dispatch. The R2 seq gate alone fails when a LATE `agent.spawned`
274
+ * from brief #1 arrives AFTER brief #2 mints a new dispatch token:
275
+ * the late spawn would stamp the OLD taskId with the NEW dispatchSeq
276
+ * (because `this.dispatchSeq` already advanced), so any subsequent
277
+ * terminal event for that task would look like it belongs to the new
278
+ * dispatch and would null the new token.
279
+ *
280
+ * Recorded inside `dispatchBrief()` BEFORE bumping `dispatchSeq`, so
281
+ * any event with `event.timestamp` strictly before this value is
282
+ * guaranteed to belong to a superseded dispatch and is dropped silently.
283
+ * Zero means "no dispatch has started yet" and disables the gate.
284
+ */
285
+ currentDispatchStartTime = 0;
207
286
  /**
208
287
  * Tracks taskIds that had an `<pugi-ask>` or `<pugi-plan-review>`
209
288
  * envelope mid-stream the last time the parser ran on the buffer. If
@@ -241,7 +320,20 @@ export class ReplSession {
241
320
  pendingAskSource: null,
242
321
  pendingPlanReview: null,
243
322
  pendingPlanReviewSource: null,
323
+ dispatchState: 'idle',
324
+ dispatchToolLabel: null,
244
325
  };
326
+ // α6.9: mirror every FSM transition into the public state so the
327
+ // status-bar surface can rerender on the next frame. Local listener
328
+ // is intentionally cheap — just a patch + clear the per-state tool
329
+ // label when leaving `tool_running`.
330
+ this.fsm.onEnter('idle', () => this.patch({ dispatchState: 'idle', dispatchToolLabel: null }));
331
+ this.fsm.onEnter('awaiting_response', () => this.patch({ dispatchState: 'awaiting_response', dispatchToolLabel: null }));
332
+ this.fsm.onEnter('tool_running', () => this.patch({ dispatchState: 'tool_running' }));
333
+ this.fsm.onEnter('aborting', () => this.patch({ dispatchState: 'aborting', dispatchToolLabel: null }));
334
+ this.fsm.onEnter('aborted', () => this.patch({ dispatchState: 'aborted', dispatchToolLabel: null }));
335
+ this.fsm.onEnter('completed', () => this.patch({ dispatchState: 'completed', dispatchToolLabel: null }));
336
+ this.fsm.onEnter('failed', () => this.patch({ dispatchState: 'failed', dispatchToolLabel: null }));
245
337
  }
246
338
  /* ------------- subscribe / state -------------- */
247
339
  subscribe(callback) {
@@ -270,18 +362,72 @@ export class ReplSession {
270
362
  });
271
363
  this.patch({ sessionId, connection: 'connecting' });
272
364
  this.openStream();
365
+ // alpha 6.13 privacy banner. Fire-and-forget - never blocks the
366
+ // input box on the network round-trip. The banner is a single
367
+ // system-line so the operator sees the active mode under the
368
+ // splash without an extra slash command. Mode is cached on the
369
+ // session so `/privacy` later renders the live value without a
370
+ // second fetch. Failure to fetch (offline, unauthenticated,
371
+ // admin-api down) is silent - the operator can still type
372
+ // `/privacy` to see the contract.
373
+ void this.fetchAndAnnouncePrivacyMode().catch(() => undefined);
273
374
  }
274
375
  catch (error) {
275
376
  this.appendSystemLine(`Could not open Pugi session: ${this.errorMessage(error)}`);
276
377
  this.patch({ connection: 'offline' });
277
378
  }
278
379
  }
380
+ /**
381
+ * Fetch the tenant's current privacy mode from
382
+ * `GET /api/admin/privacy/mode`, cache it on the session, and emit
383
+ * a one-line system banner so the operator sees their active mode
384
+ * right under the bootstrap splash. Failure is silent - missing
385
+ * banner is preferable to a noisy "could not fetch privacy mode"
386
+ * line on every login.
387
+ *
388
+ * Triple-review P1 fix (2026-05-25): without this call,
389
+ * `renderPrivacyBanner` was defined but never reached the wire, and
390
+ * `/privacy` always rendered with `null` mode.
391
+ */
392
+ async fetchAndAnnouncePrivacyMode() {
393
+ const { renderPrivacyBanner, isPrivacyMode } = await import('./privacy-banner.js');
394
+ try {
395
+ const url = `${this.options.apiUrl.replace(/\/+$/, '')}/api/admin/privacy/mode`;
396
+ const res = await fetch(url, {
397
+ headers: {
398
+ authorization: `Bearer ${this.options.apiKey}`,
399
+ accept: 'application/json',
400
+ },
401
+ });
402
+ if (!res.ok) {
403
+ // Silent fail - banner is decoration, not a blocking surface.
404
+ return;
405
+ }
406
+ const payload = (await res.json());
407
+ const mode = payload.mode;
408
+ if (typeof mode === 'string' && isPrivacyMode(mode)) {
409
+ this.privacyMode = mode;
410
+ this.appendSystemLine(renderPrivacyBanner(mode));
411
+ }
412
+ }
413
+ catch {
414
+ // Silent fail - offline / DNS / unauth all collapse to no banner.
415
+ }
416
+ }
279
417
  /**
280
418
  * Tear down the SSE stream and stop the reconnect timer. The session
281
419
  * id stays valid server-side; `pugi resume <id>` reopens later.
282
420
  */
283
421
  close() {
284
422
  this.closed = true;
423
+ // α6.9: fire the cancellation token before tearing down the stream
424
+ // so any in-flight tool sees the abort signal AND any pending
425
+ // PostBrief promise can short-circuit. Idempotent — token.abort()
426
+ // is a no-op when already aborted.
427
+ if (this.currentDispatchToken) {
428
+ this.currentDispatchToken.abort();
429
+ this.currentDispatchToken = null;
430
+ }
285
431
  if (this.streamHandle) {
286
432
  this.streamHandle.close();
287
433
  this.streamHandle = undefined;
@@ -290,6 +436,11 @@ export class ReplSession {
290
436
  clearTimeout(this.reconnectTimer);
291
437
  this.reconnectTimer = undefined;
292
438
  }
439
+ // R2 P1: drop the per-task seq stamps on close(). The map is
440
+ // small but clearing prevents accidental seq-comparison drift if
441
+ // a resurrected session reuses taskIds (admin-api currently mints
442
+ // unique ids, but the gate stays robust on either contract).
443
+ this.taskDispatchSeq.clear();
293
444
  // Detach watcher listeners so any chokidar event landing between
294
445
  // session.close() and the bootstrap-owned watcher.close() does NOT
295
446
  // run a handler on a dead session. The handlers themselves also
@@ -301,6 +452,101 @@ export class ReplSession {
301
452
  this.watcher.off('capExceeded', this.filewatchCapHandler);
302
453
  }
303
454
  }
455
+ /* ------------- α6.9 cancellation surface -------------- */
456
+ /**
457
+ * Operator-driven abort for the in-flight dispatch. Idempotent — a
458
+ * second call while already in `aborting` / `aborted` is a no-op.
459
+ *
460
+ * Steps (in order):
461
+ *
462
+ * 1. Snapshot the current state. If terminal or idle, no-op.
463
+ * 2. Transition the FSM to `aborting` so the bottom-bar shows the
464
+ * pending shutdown immediately (the operator gets feedback
465
+ * before any IO completes).
466
+ * 3. Abort the cancellation token. This fans out to every listener
467
+ * that was attached during the dispatch — chiefly the SSE
468
+ * stream wrapper (which calls `streamHandle.close()`) and any
469
+ * mid-flight tool executor that polled `isAborted`.
470
+ * 4. Append a system line so the conversation reads "Aborted." at
471
+ * the operator's last input position.
472
+ * 5. Transition to `aborted` (terminal). The next operator brief
473
+ * mints a fresh token + transitions back to
474
+ * `awaiting_response`.
475
+ *
476
+ * Returns `true` when an abort was actually issued (state was
477
+ * non-terminal + non-idle), `false` otherwise.
478
+ */
479
+ cancel() {
480
+ const current = this.fsm.current;
481
+ if (this.fsm.isTerminal || current === 'idle')
482
+ return false;
483
+ // Step 2: transient state (UI sees `aborting` between abort signal
484
+ // and full shutdown).
485
+ this.fsm.transition('aborting', 'operator_abort');
486
+ // Step 3: fire the token so any mid-flight tool executor that
487
+ // polled `isAborted` shuts down. Token is single-use — clear the
488
+ // ref AFTER both the abort fan-out AND the stream teardown so any
489
+ // onAbort listener calling getCurrentDispatchToken() during the
490
+ // teardown observes the (now-aborted) token rather than null.
491
+ // Contract: listeners fire first, stream closes second, token
492
+ // field nulls third. (P2 cancel-ordering fix.)
493
+ const token = this.currentDispatchToken;
494
+ if (token) {
495
+ token.abort();
496
+ }
497
+ // Step 3b: tear down the SSE stream so no further `agent.step`
498
+ // events update the agent tree under the aborted dispatch. The
499
+ // stream reopens on the NEXT brief via `openStream()` (driven by
500
+ // dispatchBrief's reset-to-idle path). The session id stays valid
501
+ // server-side; admin-api keeps the dispatch around for forensic
502
+ // replay but the client stops listening.
503
+ if (this.streamHandle) {
504
+ this.streamHandle.close();
505
+ this.streamHandle = undefined;
506
+ }
507
+ // P2 Codex: clear the SSE cursor on deliberate cancel. The
508
+ // admin-api Last-Event-ID replay would otherwise re-feed any
509
+ // buffered events from the cancelled dispatch into the next
510
+ // brief's stream, polluting the agent tree with ghosts of the
511
+ // aborted turn. The new brief mints a fresh server-side cursor
512
+ // anyway, so `undefined` is the correct subscribe arg.
513
+ this.lastEventId = undefined;
514
+ // Null the token AFTER stream teardown (see step 3 comment).
515
+ this.currentDispatchToken = null;
516
+ // Mark any agents that are still "running" as failed/aborted so
517
+ // the agent-tree pane reflects reality. We use the existing
518
+ // `failed` status (the tree pane already knows how to render it)
519
+ // with a clear detail string so the operator sees WHY.
520
+ this.patch({
521
+ agents: this.state.agents.map((a) => a.status === 'queued' || a.status === 'thinking'
522
+ ? { ...a, status: 'failed', detail: 'aborted by operator' }
523
+ : a),
524
+ });
525
+ // Step 4: visible operator-facing confirmation. Brand voice gate:
526
+ // single ASCII line, no em-dashes, no emoji.
527
+ this.appendSystemLine('Aborted.');
528
+ // Step 5: terminal transition. The FSM will accept a fresh
529
+ // dispatch on the next brief.
530
+ this.fsm.transition('aborted', 'operator_abort');
531
+ // Clear briefStartedAtEpochMs so the status-bar clock resets.
532
+ this.patch({ briefStartedAtEpochMs: undefined });
533
+ return true;
534
+ }
535
+ /**
536
+ * Current FSM state. Surfaced for the REPL UI to read on first paint
537
+ * (subscribe + initial state are decoupled).
538
+ */
539
+ getDispatchState() {
540
+ return this.fsm.current;
541
+ }
542
+ /**
543
+ * Current cancellation token. Returned for the tool execution path
544
+ * (file-tools.ts) so it can pass the token down into a ToolContext
545
+ * extension. Null when no dispatch is in flight.
546
+ */
547
+ getCurrentDispatchToken() {
548
+ return this.currentDispatchToken;
549
+ }
304
550
  /* ------------- input handling -------------- */
305
551
  /**
306
552
  * Run one line of operator input through the slash command parser
@@ -396,12 +642,40 @@ export class ReplSession {
396
642
  this.patch({ pendingAsk: askTag, pendingAskSource: 'local' });
397
643
  return verdict;
398
644
  }
645
+ case 'privacy': {
646
+ // alpha 6.13: print the full mode contract + current banner
647
+ // inline. The current mode is resolved lazily by the helper -
648
+ // when unauthenticated or offline the banner falls back to
649
+ // "(unknown - mode lookup pending)" and the contract doc still
650
+ // renders so the operator can read the alternatives.
651
+ await this.dispatchPrivacy();
652
+ return verdict;
653
+ }
399
654
  case 'stub': {
400
655
  this.appendSystemLine(verdict.message);
401
656
  return verdict;
402
657
  }
403
658
  }
404
659
  }
660
+ /**
661
+ * In-REPL `/privacy` - alpha 6.13. Prints the full 3-mode contract
662
+ * doc + the current mode banner inline. The current mode is fetched
663
+ * via the admin-api /api/admin/privacy/mode endpoint when the
664
+ * operator is authenticated; otherwise the banner falls back to
665
+ * "(unknown)" and the contract doc still renders so the operator
666
+ * can compare modes without leaving the REPL.
667
+ */
668
+ async dispatchPrivacy() {
669
+ const { renderPrivacyContractDoc } = await import('./privacy-banner.js');
670
+ // Triple-review P1 fix (2026-05-25): use the bootstrap-cached mode
671
+ // so the operator sees the LIVE current mode in the banner header
672
+ // instead of "(unknown)". The fetch happens once on session start;
673
+ // if it failed (offline / unauth) the cache stays null and the
674
+ // banner falls back to "(unknown)" - same UX as before, just with
675
+ // the happy path actually delivering the mode.
676
+ const doc = renderPrivacyContractDoc(this.privacyMode);
677
+ this.appendSystemLine(doc);
678
+ }
405
679
  /**
406
680
  * In-REPL `/resume` - α6.4. Lists the 10 most recent sessions from
407
681
  * the local SessionStore and prints them as a numbered system menu.
@@ -863,6 +1137,92 @@ export class ReplSession {
863
1137
  }
864
1138
  this.appendOperatorLine(brief);
865
1139
  this.patch({ briefStartedAtEpochMs: this.now() });
1140
+ // α6.9 + R3 P1 (Codex triple-review 2026-05-25): supersede the
1141
+ // prior dispatch when one is in flight. Steps in order:
1142
+ //
1143
+ // 1. Abort the old CancellationToken so any in-flight tool
1144
+ // holding `ctx.cancellation` sees `isAborted = true` and bails
1145
+ // (the R2 fix; preserves the file-tools cancellation gate).
1146
+ // 2. Drive the OLD FSM through `aborting -> aborted` terminal.
1147
+ // This is load-bearing for the R3 race: a LATE event arriving
1148
+ // on the old FSM (`agent.spawned`, `agent.step`, terminal,
1149
+ // etc.) before the timestamp gate trips would otherwise still
1150
+ // attempt to transition the new FSM. Driving the old FSM to a
1151
+ // terminal state means the FSM check in
1152
+ // `advanceFsmOnDispatchEnd` (`isTerminal`) short-circuits as a
1153
+ // defense-in-depth layer.
1154
+ // 3. `resetFsmToIdle()` mints a fresh FSM so the new dispatch
1155
+ // starts clean. The FSM legal-transition matrix forbids
1156
+ // `aborted -> awaiting_response`, so the reset is required.
1157
+ // 4. Record `currentDispatchStartTime` BEFORE bumping
1158
+ // `dispatchSeq` + clearing `taskDispatchSeq`. The timestamp
1159
+ // gate in `handleServerEvent` checks
1160
+ // `event.timestamp < currentDispatchStartTime` to drop late
1161
+ // events from any superseded dispatch (including the late
1162
+ // `agent.spawned` that the R2 seq gate could not catch).
1163
+ // 5. Clear `taskDispatchSeq` so any stamp left over from the old
1164
+ // dispatch cannot influence seq comparisons for the new turn.
1165
+ // 6. Bump `dispatchSeq` and mint a fresh `CancellationToken`.
1166
+ //
1167
+ // If no prior dispatch is in flight (clean idle / terminal entry),
1168
+ // the supersede block is skipped; we only reset the FSM if it sits
1169
+ // in a terminal state from the prior turn.
1170
+ if (this.currentDispatchToken) {
1171
+ // Step 1: abort the old token. Listeners (including the
1172
+ // file-tools cancellation gate) fan out before we replace the
1173
+ // field below.
1174
+ this.currentDispatchToken.abort();
1175
+ // Step 2: walk the old FSM to terminal. Guard against an FSM
1176
+ // that already sits in `aborting` or a terminal state - the
1177
+ // legal-transition matrix forbids `aborting -> aborting` and
1178
+ // forbids any outgoing transition from terminal states.
1179
+ if (!this.fsm.isTerminal) {
1180
+ if (this.fsm.current !== 'aborting') {
1181
+ this.fsm.transition('aborting', 'superseded_by_new_brief');
1182
+ }
1183
+ this.fsm.transition('aborted', 'superseded_by_new_brief');
1184
+ }
1185
+ // Step 3: fresh FSM so the new dispatch can walk
1186
+ // `idle -> awaiting_response` cleanly.
1187
+ this.resetFsmToIdle();
1188
+ }
1189
+ else if (this.fsm.isTerminal) {
1190
+ // Prior turn ended naturally (completed / failed / aborted) with
1191
+ // no live token left around. Reset only the FSM.
1192
+ this.resetFsmToIdle();
1193
+ }
1194
+ // Step 4: record the dispatch start time BEFORE bumping the seq.
1195
+ // The timestamp gate in `handleServerEvent` reads this value to
1196
+ // decide whether an inbound event predates the live dispatch and
1197
+ // should be dropped. Recording before the seq bump is critical:
1198
+ // any concurrent SSE event landing between this line and the seq
1199
+ // bump must still see a strictly-monotonic timestamp boundary.
1200
+ this.currentDispatchStartTime = this.now();
1201
+ // Step 5: clear the per-task seq stamps. Any leftover stamp from a
1202
+ // superseded dispatch would otherwise look like it matched the new
1203
+ // dispatchSeq (because the late `agent.spawned` for that taskId
1204
+ // could stamp the OLD taskId with the NEW seq), nulling the new
1205
+ // token via a stale terminal event. The timestamp gate is the
1206
+ // primary defense; the clear is belt-and-braces.
1207
+ this.taskDispatchSeq.clear();
1208
+ // Step 6: bump seq + mint fresh token.
1209
+ this.dispatchSeq += 1;
1210
+ this.currentDispatchToken = new CancellationToken();
1211
+ // The FSM is now `idle` (either fresh-start or post-reset). Walk
1212
+ // to `awaiting_response` so the bottom-bar surface picks up the
1213
+ // new dispatch state immediately.
1214
+ if (this.fsm.current === 'idle') {
1215
+ this.fsm.transition('awaiting_response', 'brief_dispatched');
1216
+ }
1217
+ // α6.9: re-open the SSE stream if a prior `cancel()` tore it
1218
+ // down. Without this, the new brief would dispatch on admin-api
1219
+ // but the client would never observe `agent.spawned` / `step` /
1220
+ // `completed` — the operator would see a stalled status bar
1221
+ // forever. Idempotent: openStream() short-circuits when a handle
1222
+ // already exists or the session is closed.
1223
+ if (!this.streamHandle && !this.closed) {
1224
+ this.openStream();
1225
+ }
866
1226
  try {
867
1227
  await this.options.transport.postBrief({
868
1228
  apiUrl: this.options.apiUrl,
@@ -873,8 +1233,79 @@ export class ReplSession {
873
1233
  }
874
1234
  catch (error) {
875
1235
  this.appendSystemLine(`Brief dispatch refused: ${this.errorMessage(error)}`);
1236
+ // α6.9: a failed brief POST never produced a turn, so we move
1237
+ // the FSM straight to `failed` so the bottom-bar surfaces the
1238
+ // outcome and the next brief can mint a fresh token.
1239
+ this.markDispatchFailed('post_brief_failed');
876
1240
  }
877
1241
  }
1242
+ /**
1243
+ * α6.9: reset the FSM to `idle` after a terminal transition so the
1244
+ * next brief can start. The FSM does not allow direct
1245
+ * `completed -> awaiting_response`, so we mint a fresh FSM by
1246
+ * overwriting the field. Listeners on the old FSM are dropped (they
1247
+ * cannot fire again — terminal states have no outgoing transitions).
1248
+ * The state.dispatchState patch happens via the new FSM's listeners
1249
+ * which we re-attach immediately.
1250
+ */
1251
+ resetFsmToIdle() {
1252
+ // Re-attach the same listeners on a fresh FSM instance. We cannot
1253
+ // mutate `this.fsm` because it is `readonly`; but we mark it as
1254
+ // mutable for this single reset path.
1255
+ const next = new DispatchFSM();
1256
+ next.onEnter('idle', () => this.patch({ dispatchState: 'idle', dispatchToolLabel: null }));
1257
+ next.onEnter('awaiting_response', () => this.patch({ dispatchState: 'awaiting_response', dispatchToolLabel: null }));
1258
+ next.onEnter('tool_running', () => this.patch({ dispatchState: 'tool_running' }));
1259
+ next.onEnter('aborting', () => this.patch({ dispatchState: 'aborting', dispatchToolLabel: null }));
1260
+ next.onEnter('aborted', () => this.patch({ dispatchState: 'aborted', dispatchToolLabel: null }));
1261
+ next.onEnter('completed', () => this.patch({ dispatchState: 'completed', dispatchToolLabel: null }));
1262
+ next.onEnter('failed', () => this.patch({ dispatchState: 'failed', dispatchToolLabel: null }));
1263
+ // Swap the instance - the FSM does not allow direct
1264
+ // `<terminal> -> awaiting_response`, so the next brief needs a
1265
+ // fresh machine to walk from `idle`. Clean assignment (no cast)
1266
+ // because `fsm` is no longer declared readonly.
1267
+ this.fsm = next;
1268
+ // State patch so subscribers see the idle transition immediately.
1269
+ this.patch({ dispatchState: 'idle', dispatchToolLabel: null });
1270
+ }
1271
+ /**
1272
+ * α6.9: short-circuit the FSM to `failed` on a non-recoverable
1273
+ * dispatch error (network refusal, malformed event, etc). Idempotent
1274
+ * — a second call from a terminal state is a no-op.
1275
+ */
1276
+ markDispatchFailed(reason) {
1277
+ if (this.fsm.isTerminal)
1278
+ return;
1279
+ if (this.fsm.current === 'idle')
1280
+ return;
1281
+ // From `awaiting_response` or `tool_running` we can transition to
1282
+ // `failed` directly per the legal-transition matrix. From `aborting`
1283
+ // the only legal move is `aborted`, so skip — the abort path is
1284
+ // already in motion.
1285
+ if (this.fsm.current === 'aborting')
1286
+ return;
1287
+ this.fsm.transition('failed', reason);
1288
+ // α6.9 P1 fix (Claude triple-review): postBrief threw between
1289
+ // openStream() and dispatch registration server-side. The local
1290
+ // SSE handle is open but listening for events under a dispatchId
1291
+ // the admin-api never created. If we leave it open, any inbound
1292
+ // event for a future dispatch on the same session would drive
1293
+ // the FSM from terminal `failed` -> illegal target and throw
1294
+ // IllegalDispatchTransitionError. Tear down so the next brief
1295
+ // re-opens cleanly via dispatchBrief's openStream() gate.
1296
+ //
1297
+ // R2 P2 fix (Claude triple-review 2026-05-25): tear down the
1298
+ // stream BEFORE nulling the token. Same ordering contract as
1299
+ // `cancel()`: any onAbort listener fired during teardown should
1300
+ // observe the (now-aborted) token via getCurrentDispatchToken()
1301
+ // rather than null. Nulling the token first would race the
1302
+ // teardown's listener fan-out against a stale null read.
1303
+ if (this.streamHandle) {
1304
+ this.streamHandle.close();
1305
+ this.streamHandle = undefined;
1306
+ }
1307
+ this.currentDispatchToken = null;
1308
+ }
878
1309
  async dispatchStop(persona) {
879
1310
  const sessionId = this.state.sessionId;
880
1311
  if (!sessionId) {
@@ -1050,6 +1481,31 @@ export class ReplSession {
1050
1481
  }
1051
1482
  /* ------------- event reducer -------------- */
1052
1483
  handleServerEvent(event) {
1484
+ // R3 P1 fix (Codex triple-review 2026-05-25): wall-clock gate that
1485
+ // drops events from a SUPERSEDED dispatch. The R2 seq gate alone
1486
+ // could not catch a LATE `agent.spawned` for an old taskId arriving
1487
+ // AFTER `dispatchBrief` already bumped `dispatchSeq`. The late
1488
+ // spawn would stamp the OLD taskId with the NEW seq, so the
1489
+ // subsequent terminal event for that task looked current and
1490
+ // nulled the freshly minted token. Comparing `event.timestamp`
1491
+ // against `currentDispatchStartTime` (recorded BEFORE the seq
1492
+ // bump) catches the late event before it can corrupt the seq map
1493
+ // or drive the live FSM.
1494
+ //
1495
+ // `Date.parse` returns NaN on malformed input; we treat NaN as
1496
+ // "unknown timestamp, do not drop" so a transport bug never
1497
+ // silently swallows events. Zero `currentDispatchStartTime` means
1498
+ // no dispatch has started yet (start() path) — same fail-open.
1499
+ const eventTs = event.timestamp ? Date.parse(event.timestamp) : 0;
1500
+ if (Number.isFinite(eventTs)
1501
+ && eventTs > 0
1502
+ && this.currentDispatchStartTime > 0
1503
+ && eventTs < this.currentDispatchStartTime) {
1504
+ // Late event from a superseded dispatch. Drop silently — the
1505
+ // operator already saw the new brief land, and the new dispatch
1506
+ // owns the surface now.
1507
+ return;
1508
+ }
1053
1509
  switch (event.type) {
1054
1510
  case 'agent.spawned': {
1055
1511
  const persona = safePersonaName(event.role);
@@ -1086,6 +1542,12 @@ export class ReplSession {
1086
1542
  else {
1087
1543
  this.patch({ agents: [node, ...this.state.agents] });
1088
1544
  }
1545
+ // R2 P1 fix (Codex triple-review 2026-05-25): stamp the live
1546
+ // dispatch sequence onto this taskId so terminal handlers can
1547
+ // tell apart a "current dispatch" event from a "superseded
1548
+ // dispatch" event. See `dispatchSeq` + `taskDispatchSeq`
1549
+ // field comments.
1550
+ this.taskDispatchSeq.set(event.taskId, this.dispatchSeq);
1089
1551
  // The conversation pane already prefixes persona rows with the
1090
1552
  // persona name in the persona's hue colour. Skip embedding the
1091
1553
  // name in the body text to avoid the `Marcus Marcus dispatched`
@@ -1128,6 +1590,12 @@ export class ReplSession {
1128
1590
  });
1129
1591
  if (synthesised) {
1130
1592
  this.appendToolCall(synthesised);
1593
+ // α6.9: a fresh tool call moves the FSM to `tool_running`
1594
+ // when the dispatch is still active. The status-bar surface
1595
+ // also gets a short label (`tool: read`, `tool: bash`, etc).
1596
+ // Aborting / terminal states are not allowed to transition
1597
+ // here — we silently skip rather than throw.
1598
+ this.advanceFsmOnToolStart(synthesised.tool);
1131
1599
  }
1132
1600
  this.patch({
1133
1601
  agents: this.state.agents.map((a) => a.taskId === event.taskId
@@ -1164,6 +1632,12 @@ export class ReplSession {
1164
1632
  ? { ...a, status: 'shipped', detail: 'shipped' }
1165
1633
  : a),
1166
1634
  });
1635
+ // α6.9: transition the FSM to `completed` when no other
1636
+ // dispatch is still in flight. The check uses the agents list
1637
+ // POST-patch so any sibling task in `queued` / `thinking` keeps
1638
+ // the dispatch alive; the FSM only goes terminal when the last
1639
+ // agent ships.
1640
+ this.advanceFsmOnDispatchEnd('completed', 'agent_completed', event.taskId);
1167
1641
  if (target) {
1168
1642
  // If the persona actually produced a reply via incremental
1169
1643
  // agent.step events, render that reply in the transcript so
@@ -1224,6 +1698,11 @@ export class ReplSession {
1224
1698
  if (target) {
1225
1699
  this.appendPersonaLine(target.personaSlug, `blocked: ${event.detail}`);
1226
1700
  }
1701
+ // α6.9: `blocked` is a graceful refusal, not a crash — treat it
1702
+ // as a `completed` outcome from the FSM's perspective so the
1703
+ // operator sees the bottom-bar settle back to `idle` after the
1704
+ // last block clears.
1705
+ this.advanceFsmOnDispatchEnd('completed', 'agent_blocked', event.taskId);
1227
1706
  return;
1228
1707
  }
1229
1708
  case 'agent.failed': {
@@ -1242,10 +1721,87 @@ export class ReplSession {
1242
1721
  if (target) {
1243
1722
  this.appendPersonaLine(target.personaSlug, `failed: ${event.error}`);
1244
1723
  }
1724
+ // α6.9: terminal `failed` transition when no sibling task
1725
+ // remains. Same defer-until-last-agent semantics as
1726
+ // `completed` so the bottom-bar surface tracks the dispatch
1727
+ // collectively.
1728
+ this.advanceFsmOnDispatchEnd('failed', 'agent_failed', event.taskId);
1245
1729
  return;
1246
1730
  }
1247
1731
  }
1248
1732
  }
1733
+ /**
1734
+ * α6.9 helper: advance the FSM to `tool_running` when a tool call
1735
+ * lands mid-dispatch. Guarded against terminal / aborting states so
1736
+ * a late tool event after `cancel()` does not throw on an illegal
1737
+ * transition. The `tool` label drives the bottom-bar's
1738
+ * `tool: <kind>` granularity.
1739
+ */
1740
+ advanceFsmOnToolStart(tool) {
1741
+ const current = this.fsm.current;
1742
+ if (this.fsm.isTerminal)
1743
+ return;
1744
+ if (current === 'aborting')
1745
+ return;
1746
+ if (current === 'idle')
1747
+ return;
1748
+ // Only `awaiting_response -> tool_running` is a hard move. From
1749
+ // `tool_running` we patch the label without a state transition so
1750
+ // a multi-tool turn shows the latest tool's label without churning
1751
+ // the FSM through transient idle states.
1752
+ if (current === 'awaiting_response') {
1753
+ this.fsm.transition('tool_running', `tool_${tool}`);
1754
+ }
1755
+ this.patch({ dispatchToolLabel: `tool: ${tool}` });
1756
+ }
1757
+ /**
1758
+ * α6.9 helper: advance the FSM toward a terminal outcome when the
1759
+ * LAST in-flight agent's lifecycle ends. The dispatch is "still
1760
+ * running" when any other agent in the tree is in `queued` /
1761
+ * `thinking`; the FSM only goes terminal when the last one settles.
1762
+ *
1763
+ * Idempotent + guarded against illegal transitions: a late event
1764
+ * after a manual `cancel()` finds the FSM already in `aborted` and
1765
+ * is silently dropped.
1766
+ */
1767
+ advanceFsmOnDispatchEnd(outcome, reason, taskId) {
1768
+ // R2 P1 fix (Codex triple-review 2026-05-25): a terminal event
1769
+ // for a SUPERSEDED dispatch must NOT advance the live FSM or null
1770
+ // the live token. If the event carries a taskId and the stamped
1771
+ // dispatchSeq for that task is older than the current dispatchSeq,
1772
+ // the event belongs to a prior dispatch that was replaced by a
1773
+ // newer `dispatchBrief()`. Silently drop the FSM advance.
1774
+ if (taskId !== undefined) {
1775
+ const taskSeq = this.taskDispatchSeq.get(taskId);
1776
+ if (taskSeq !== undefined && taskSeq < this.dispatchSeq) {
1777
+ // Stale dispatch event - the live dispatch is a newer turn.
1778
+ // Skip the FSM advance + token null so brief #N+1 stays
1779
+ // cancellable and the new turn's lifecycle is not corrupted.
1780
+ return;
1781
+ }
1782
+ }
1783
+ if (this.fsm.isTerminal)
1784
+ return;
1785
+ if (this.fsm.current === 'aborting')
1786
+ return;
1787
+ if (this.fsm.current === 'idle')
1788
+ return;
1789
+ // Defer until every agent has settled so a multi-agent dispatch
1790
+ // collectively transitions once on the LAST settle.
1791
+ const stillActive = this.state.agents.some((a) => a.status === 'queued' || a.status === 'thinking');
1792
+ if (stillActive)
1793
+ return;
1794
+ // From `tool_running` we must walk through `awaiting_response`
1795
+ // first (the legal-transition matrix forbids
1796
+ // tool_running -> completed). The intermediate step is a one-tick
1797
+ // pass through and immediately settles to terminal.
1798
+ if (this.fsm.current === 'tool_running') {
1799
+ this.fsm.transition('awaiting_response', `${reason}_drained`);
1800
+ }
1801
+ this.fsm.transition(outcome, reason);
1802
+ this.currentDispatchToken = null;
1803
+ this.patch({ briefStartedAtEpochMs: undefined });
1804
+ }
1249
1805
  /* ------------- transcript helpers -------------- */
1250
1806
  /**
1251
1807
  * Look up the persona slug for a running task. Used by the tool call