@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.
- package/README.md +33 -0
- package/dist/commands/deploy.js +439 -0
- package/dist/core/edits/dispatch.js +185 -0
- package/dist/core/edits/index.js +15 -0
- package/dist/core/edits/layer-a-apply.js +217 -0
- package/dist/core/edits/layer-b-apply.js +211 -0
- package/dist/core/edits/layer-c-apply.js +160 -0
- package/dist/core/edits/layer-d-ast.js +29 -0
- package/dist/core/edits/marker-parser.js +401 -0
- package/dist/core/edits/security-gate.js +223 -0
- package/dist/core/engine/native-pugi.js +6 -1
- package/dist/core/engine/tool-bridge.js +33 -1
- package/dist/core/repl/cancellation.js +98 -0
- package/dist/core/repl/dispatch-fsm.js +220 -0
- package/dist/core/repl/privacy-banner.js +4 -4
- package/dist/core/repl/session.js +556 -0
- package/dist/core/repl/slash-commands.js +12 -3
- package/dist/runtime/cli.js +193 -1
- package/dist/runtime/commands/config.js +136 -0
- package/dist/tools/file-tools.js +90 -0
- package/dist/tui/input-box.js +48 -5
- package/dist/tui/repl.js +24 -2
- package/dist/tui/status-bar.js +63 -3
- package/package.json +2 -2
|
@@ -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
|