@pugi/cli 0.1.0-alpha.19 → 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.
@@ -241,6 +241,9 @@ export class NativePugiEngineAdapter {
241
241
  for (const event of buffer)
242
242
  yield event;
243
243
  // Translate the loop outcome into an EngineResult.
244
+ // `aborted` (α6.9: operator cancelled mid-tool) maps to `blocked`
245
+ // because the operator chose the outcome, same shape as
246
+ // budget_exhausted / tool_refused.
244
247
  const status = outcome.status === 'completed'
245
248
  ? 'done'
246
249
  : outcome.status === 'failed'
@@ -252,7 +255,9 @@ export class NativePugiEngineAdapter {
252
255
  ? '[budget_exhausted] '
253
256
  : outcome.status === 'tool_refused'
254
257
  ? '[plan_mode_refused] '
255
- : '[failed] ';
258
+ : outcome.status === 'aborted'
259
+ ? '[operator_aborted] '
260
+ : '[failed] ';
256
261
  const filesChangedList = Array.from(filesChanged).sort();
257
262
  appendSessionMirror(sessionEventsPath, {
258
263
  type: 'outcome',
@@ -1,4 +1,4 @@
1
- import { editTool, globTool, grepTool, readTool, writeTool, } from '../../tools/file-tools.js';
1
+ import { editTool, globTool, grepTool, OperatorAbortedError, readTool, writeTool, } from '../../tools/file-tools.js';
2
2
  import { bashToolSync } from '../../tools/bash.js';
3
3
  /**
4
4
  * Tool-bridge: turns the abstract tool registry into:
@@ -147,6 +147,14 @@ export function buildExecutor(input) {
147
147
  // outcome, not a failure, because plan mode is doing its job.
148
148
  throw new Error(`PLAN_MODE_REFUSED: ${name} is not allowed in plan mode`);
149
149
  }
150
+ // α6.9: refuse cancelled-token tool dispatch BEFORE PreToolUse
151
+ // hooks fire so a cancelled brief never reaches user-defined
152
+ // hook scripts. Sentinel `OPERATOR_ABORTED:<tool>` is recognised
153
+ // by `runEngineLoop` as a terminal-cancel signal so the loop
154
+ // returns control to the caller rather than retrying the model.
155
+ if (ctx.cancellation && ctx.cancellation.isAborted) {
156
+ throw new Error(`OPERATOR_ABORTED: ${name} refused — operator cancelled the dispatch.`);
157
+ }
150
158
  // Fire PreToolUse hooks. The match grammar takes the tool name and
151
159
  // (when extractable) the target path. Each new tool dispatch starts a
152
160
  // fresh dedup batch so a hook fires once per dispatch, not once per
@@ -194,6 +202,30 @@ export function buildExecutor(input) {
194
202
  return result;
195
203
  }
196
204
  catch (error) {
205
+ // α6.9: re-shape OperatorAbortedError throws from the
206
+ // file-tools layer into the same `OPERATOR_ABORTED:` sentinel
207
+ // the upstream cancellation gate uses so `runEngineLoop` sees
208
+ // a consistent terminal-cancel signal regardless of whether
209
+ // the abort landed pre-dispatch or mid-tool (e.g. inside the
210
+ // grep file-loop).
211
+ if (error instanceof OperatorAbortedError) {
212
+ if (hooks && sessionId) {
213
+ const path = extractToolPath(name, argsRaw);
214
+ await hooks.fire({
215
+ sessionId,
216
+ event: 'PostToolUseFailure',
217
+ tool: name,
218
+ path,
219
+ payload: {
220
+ tool: name,
221
+ arguments: argsRaw,
222
+ ok: false,
223
+ error: `OPERATOR_ABORTED: ${name}`,
224
+ },
225
+ });
226
+ }
227
+ throw new Error(`OPERATOR_ABORTED: ${name} aborted mid-execution.`);
228
+ }
197
229
  if (hooks && sessionId) {
198
230
  const path = extractToolPath(name, argsRaw);
199
231
  await hooks.fire({
@@ -133,7 +133,7 @@ export class DispatchFSM {
133
133
  * listener still fires.
134
134
  *
135
135
  * Re-entrant calls (a listener calls `transition()` again) are
136
- * queued the inner transition runs after the outer listener drain
136
+ * queued - the inner transition runs after the outer listener drain
137
137
  * completes, in FIFO order. See `firing` field comment.
138
138
  */
139
139
  transition(next, reason) {
@@ -143,12 +143,28 @@ export class DispatchFSM {
143
143
  }
144
144
  this.firing = true;
145
145
  try {
146
- this.commit(next, reason);
147
- while (this.pending.length > 0) {
148
- const queued = this.pending.shift();
149
- if (!queued)
150
- break;
151
- this.commit(queued.next, queued.reason);
146
+ // R2 P2 fix (Codex triple-review 2026-05-25): if `commit` throws -
147
+ // either the first move or any queued nested move - we MUST flush
148
+ // the pending queue before propagating the error. Otherwise a
149
+ // later `transition()` call would drain stale entries left over
150
+ // from the failed turn, firing onEnter listeners under a wholly
151
+ // different state context. The queue is "poisoned" the moment any
152
+ // entry throws; the safest move is to drop the rest and surface
153
+ // the failure to the caller.
154
+ try {
155
+ this.commit(next, reason);
156
+ while (this.pending.length > 0) {
157
+ const queued = this.pending.shift();
158
+ if (!queued)
159
+ break;
160
+ this.commit(queued.next, queued.reason);
161
+ }
162
+ }
163
+ catch (err) {
164
+ // Drop every still-queued entry so the next external transition
165
+ // starts on a clean queue. See the inline rationale above.
166
+ this.pending.length = 0;
167
+ throw err;
152
168
  }
153
169
  }
154
170
  finally {
@@ -156,7 +172,7 @@ export class DispatchFSM {
156
172
  }
157
173
  }
158
174
  /**
159
- * Inner commit step legality check + state mutation + listener
175
+ * Inner commit step - legality check + state mutation + listener
160
176
  * drain. Called by `transition()` directly for the first move and
161
177
  * for every queued nested move thereafter. Errors propagate to the
162
178
  * outer `transition()` caller (illegal transitions still throw).
@@ -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;
@@ -220,6 +222,67 @@ export class ReplSession {
220
222
  * complete envelope lands OR the turn ends. (α6.3.)
221
223
  */
222
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;
223
286
  /**
224
287
  * Tracks taskIds that had an `<pugi-ask>` or `<pugi-plan-review>`
225
288
  * envelope mid-stream the last time the parser ran on the buffer. If
@@ -257,7 +320,20 @@ export class ReplSession {
257
320
  pendingAskSource: null,
258
321
  pendingPlanReview: null,
259
322
  pendingPlanReviewSource: null,
323
+ dispatchState: 'idle',
324
+ dispatchToolLabel: null,
260
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 }));
261
337
  }
262
338
  /* ------------- subscribe / state -------------- */
263
339
  subscribe(callback) {
@@ -344,6 +420,14 @@ export class ReplSession {
344
420
  */
345
421
  close() {
346
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
+ }
347
431
  if (this.streamHandle) {
348
432
  this.streamHandle.close();
349
433
  this.streamHandle = undefined;
@@ -352,6 +436,11 @@ export class ReplSession {
352
436
  clearTimeout(this.reconnectTimer);
353
437
  this.reconnectTimer = undefined;
354
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();
355
444
  // Detach watcher listeners so any chokidar event landing between
356
445
  // session.close() and the bootstrap-owned watcher.close() does NOT
357
446
  // run a handler on a dead session. The handlers themselves also
@@ -363,6 +452,101 @@ export class ReplSession {
363
452
  this.watcher.off('capExceeded', this.filewatchCapHandler);
364
453
  }
365
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
+ }
366
550
  /* ------------- input handling -------------- */
367
551
  /**
368
552
  * Run one line of operator input through the slash command parser
@@ -953,6 +1137,92 @@ export class ReplSession {
953
1137
  }
954
1138
  this.appendOperatorLine(brief);
955
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
+ }
956
1226
  try {
957
1227
  await this.options.transport.postBrief({
958
1228
  apiUrl: this.options.apiUrl,
@@ -963,8 +1233,79 @@ export class ReplSession {
963
1233
  }
964
1234
  catch (error) {
965
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');
966
1240
  }
967
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
+ }
968
1309
  async dispatchStop(persona) {
969
1310
  const sessionId = this.state.sessionId;
970
1311
  if (!sessionId) {
@@ -1140,6 +1481,31 @@ export class ReplSession {
1140
1481
  }
1141
1482
  /* ------------- event reducer -------------- */
1142
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
+ }
1143
1509
  switch (event.type) {
1144
1510
  case 'agent.spawned': {
1145
1511
  const persona = safePersonaName(event.role);
@@ -1176,6 +1542,12 @@ export class ReplSession {
1176
1542
  else {
1177
1543
  this.patch({ agents: [node, ...this.state.agents] });
1178
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);
1179
1551
  // The conversation pane already prefixes persona rows with the
1180
1552
  // persona name in the persona's hue colour. Skip embedding the
1181
1553
  // name in the body text to avoid the `Marcus Marcus dispatched`
@@ -1218,6 +1590,12 @@ export class ReplSession {
1218
1590
  });
1219
1591
  if (synthesised) {
1220
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);
1221
1599
  }
1222
1600
  this.patch({
1223
1601
  agents: this.state.agents.map((a) => a.taskId === event.taskId
@@ -1254,6 +1632,12 @@ export class ReplSession {
1254
1632
  ? { ...a, status: 'shipped', detail: 'shipped' }
1255
1633
  : a),
1256
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);
1257
1641
  if (target) {
1258
1642
  // If the persona actually produced a reply via incremental
1259
1643
  // agent.step events, render that reply in the transcript so
@@ -1314,6 +1698,11 @@ export class ReplSession {
1314
1698
  if (target) {
1315
1699
  this.appendPersonaLine(target.personaSlug, `blocked: ${event.detail}`);
1316
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);
1317
1706
  return;
1318
1707
  }
1319
1708
  case 'agent.failed': {
@@ -1332,9 +1721,86 @@ export class ReplSession {
1332
1721
  if (target) {
1333
1722
  this.appendPersonaLine(target.personaSlug, `failed: ${event.error}`);
1334
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);
1729
+ return;
1730
+ }
1731
+ }
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.
1335
1780
  return;
1336
1781
  }
1337
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 });
1338
1804
  }
1339
1805
  /* ------------- transcript helpers -------------- */
1340
1806
  /**
@@ -44,7 +44,7 @@ import { dispatchEdit, } from '../core/edits/index.js';
44
44
  * packages/pugi-sdk/package.json); the publish workflow validates the
45
45
  * three are in lockstep.
46
46
  */
47
- const PUGI_CLI_VERSION = "0.1.0-alpha.19";
47
+ const PUGI_CLI_VERSION = "0.1.0-alpha.20";
48
48
  const handlers = {
49
49
  accounts,
50
50
  agents: dispatchAgents,
@@ -6,6 +6,35 @@ import { decidePermission } from '../core/permission.js';
6
6
  import { createReadRecord, hashContent } from '../core/file-cache.js';
7
7
  import { resolveWorkspacePath } from '../core/path-security.js';
8
8
  import { recordFileMutation, recordToolCall, recordToolResult } from '../core/session.js';
9
+ /**
10
+ * α6.9 WriteGate marker — thrown by `gateOnCancellation` when the
11
+ * caller supplied a cancellation token that has already aborted. The
12
+ * tool dispatch loop in `tool-bridge.ts` recognises the name and folds
13
+ * the throw into a `status: 'aborted'` tool result rather than a hard
14
+ * error so the loop terminates cleanly.
15
+ */
16
+ export class OperatorAbortedError extends Error {
17
+ constructor(toolName) {
18
+ super(`operator_aborted: ${toolName} refused — operator cancelled the dispatch.`);
19
+ this.name = 'OperatorAbortedError';
20
+ }
21
+ }
22
+ /**
23
+ * α6.9 WriteGate: refuse the tool dispatch when the active
24
+ * cancellation token has aborted. Idempotent (the token's `isAborted`
25
+ * is a getter, no side effects). Returns void on the happy path so the
26
+ * tool can proceed; throws `OperatorAbortedError` when cancelled.
27
+ *
28
+ * The audit trail still gets the call: `recordToolCall` already fired
29
+ * upstream of this guard so the abort + reason are persisted. The
30
+ * matching `recordToolResult` is fired by the caller in its catch
31
+ * block with `status: 'cancelled'` (see existing path for `error`).
32
+ */
33
+ export function gateOnCancellation(ctx, toolName) {
34
+ if (ctx.cancellation && ctx.cancellation.isAborted) {
35
+ throw new OperatorAbortedError(toolName);
36
+ }
37
+ }
9
38
  /**
10
39
  * Re-check the permission decision against the *resolved* real path so
11
40
  * a workspace-local symlink (`alias -> .env`) cannot bypass the protected
@@ -42,6 +71,13 @@ function permissionGatedResolve(ctx, inputPath, action, toolName) {
42
71
  }
43
72
  export function readTool(ctx, path) {
44
73
  const toolCallId = recordToolCall(ctx.session, 'read', path);
74
+ // α6.9 WriteGate: fail fast on operator cancel BEFORE permission
75
+ // decision so a half-second post-cancel race never lands the read.
76
+ if (ctx.cancellation && ctx.cancellation.isAborted) {
77
+ const reason = 'operator_aborted: read refused';
78
+ recordToolResult(ctx.session, toolCallId, 'cancelled', reason);
79
+ throw new OperatorAbortedError('read');
80
+ }
45
81
  const decision = decidePermission({ tool: 'read', kind: 'read', target: path }, ctx.settings, ctx.root);
46
82
  if (decision.decision !== 'allow') {
47
83
  const reason = `Permission ${decision.decision} for read ${path}: ${decision.reason}`;
@@ -64,6 +100,14 @@ export function readTool(ctx, path) {
64
100
  }
65
101
  export function writeTool(ctx, path, content) {
66
102
  const toolCallId = recordToolCall(ctx.session, 'write', path);
103
+ // α6.9 WriteGate: refuse the write when the operator has cancelled
104
+ // the dispatch. The audit log captures the cancellation reason so a
105
+ // post-mortem can distinguish operator_aborted from settings-deny.
106
+ if (ctx.cancellation && ctx.cancellation.isAborted) {
107
+ const reason = 'operator_aborted: write refused';
108
+ recordToolResult(ctx.session, toolCallId, 'cancelled', reason);
109
+ throw new OperatorAbortedError('write');
110
+ }
67
111
  const decision = decidePermission({ tool: 'write', kind: 'edit', target: path }, ctx.settings, ctx.root);
68
112
  if (decision.decision !== 'allow') {
69
113
  const reason = `Permission ${decision.decision} for write ${path}: ${decision.reason}`;
@@ -95,6 +139,15 @@ export function writeTool(ctx, path, content) {
95
139
  }
96
140
  export function editTool(ctx, path, oldString, newString) {
97
141
  const toolCallId = recordToolCall(ctx.session, 'edit', path);
142
+ // α6.9 WriteGate: refuse the edit when the operator has cancelled
143
+ // the dispatch. Edits are higher-risk than reads — surface the abort
144
+ // BEFORE we even consult permissions so a cancel-during-tool-loop
145
+ // never partially mutates the workspace.
146
+ if (ctx.cancellation && ctx.cancellation.isAborted) {
147
+ const reason = 'operator_aborted: edit refused';
148
+ recordToolResult(ctx.session, toolCallId, 'cancelled', reason);
149
+ throw new OperatorAbortedError('edit');
150
+ }
98
151
  const decision = decidePermission({ tool: 'edit', kind: 'edit', target: path }, ctx.settings, ctx.root);
99
152
  if (decision.decision !== 'allow') {
100
153
  const reason = `Permission ${decision.decision} for edit ${path}: ${decision.reason}`;
@@ -140,6 +193,14 @@ export function editTool(ctx, path, oldString, newString) {
140
193
  }
141
194
  export function globTool(ctx, pattern) {
142
195
  const toolCallId = recordToolCall(ctx.session, 'glob', pattern);
196
+ // α6.9 WriteGate: cancel-aware short-circuit. Glob is read-only but
197
+ // can be expensive on large trees; respecting the abort here keeps
198
+ // the tool loop responsive when the operator hits Ctrl+C mid-scan.
199
+ if (ctx.cancellation && ctx.cancellation.isAborted) {
200
+ const reason = 'operator_aborted: glob refused';
201
+ recordToolResult(ctx.session, toolCallId, 'cancelled', reason);
202
+ throw new OperatorAbortedError('glob');
203
+ }
143
204
  // Pugi globs are workspace-scoped. Reject any pattern that could enumerate
144
205
  // outside the workspace:
145
206
  // 1. absolute paths (`/etc/**/*`) — globSync resolves these against `/`
@@ -169,11 +230,28 @@ export function globTool(ctx, pattern) {
169
230
  }
170
231
  export function grepTool(ctx, query) {
171
232
  const toolCallId = recordToolCall(ctx.session, 'grep', query);
233
+ // α6.9 WriteGate: refuse before scanning. Grep walks the whole
234
+ // workspace and can take seconds on a large repo; check abort first
235
+ // so a cancel mid-scan returns immediately rather than after the
236
+ // full walk completes.
237
+ if (ctx.cancellation && ctx.cancellation.isAborted) {
238
+ const reason = 'operator_aborted: grep refused';
239
+ recordToolResult(ctx.session, toolCallId, 'cancelled', reason);
240
+ throw new OperatorAbortedError('grep');
241
+ }
172
242
  const files = globTool(ctx, '**/*').filter((path) => !path.endsWith('/'));
173
243
  const matches = [];
174
244
  for (const path of files) {
175
245
  if (matches.length >= 200)
176
246
  break;
247
+ // α6.9 WriteGate: poll abort inside the file loop so a cancel
248
+ // arriving mid-scan terminates early. The per-file branch keeps
249
+ // the responsiveness bounded by the slowest single-file read.
250
+ if (ctx.cancellation && ctx.cancellation.isAborted) {
251
+ const reason = `operator_aborted: grep stopped mid-scan after ${matches.length} matches`;
252
+ recordToolResult(ctx.session, toolCallId, 'cancelled', reason);
253
+ throw new OperatorAbortedError('grep');
254
+ }
177
255
  // Permission gate every file read individually — grep used to bypass
178
256
  // `decidePermission` and could surface lines from protected files
179
257
  // (.env, *.sql, *.pem, ~/.ssh/**) when invoked from a directory walk.
@@ -241,6 +319,18 @@ export const BASH_DEFAULT_TIMEOUT_MS = 30_000;
241
319
  export const BASH_CHILD_MAXBUFFER = 10 * 1024 * 1024;
242
320
  export function bashTool(ctx, command, options = {}) {
243
321
  const toolCallId = recordToolCall(ctx.session, 'bash', command);
322
+ // α6.9 WriteGate: bash is the highest-risk tool surface. Refuse
323
+ // before the destructive-pattern classifier even runs so a
324
+ // cancelled dispatch never spawns a child process. Note: this is
325
+ // pre-spawn cancellation only; once the /bin/sh -c process is
326
+ // running, the synchronous spawnSync wait blocks until it exits or
327
+ // the 30s timeout fires. Phase 2 will wire SIGTERM forwarding via
328
+ // an async wrapper.
329
+ if (ctx.cancellation && ctx.cancellation.isAborted) {
330
+ const reason = 'operator_aborted: bash refused';
331
+ recordToolResult(ctx.session, toolCallId, 'cancelled', reason);
332
+ throw new OperatorAbortedError('bash');
333
+ }
244
334
  const decision = decidePermission({ tool: 'bash', kind: 'bash', target: command }, ctx.settings, ctx.root);
245
335
  if (decision.decision !== 'allow') {
246
336
  const reason = `Permission ${decision.decision} for bash: ${decision.reason}`;
@@ -131,14 +131,57 @@ export function InputBox(props) {
131
131
  useInput((input, key) => {
132
132
  if (key.ctrl && input === 'c') {
133
133
  const t = now();
134
- if (typeof lastCtrlCAt === 'number' && t - lastCtrlCAt <= CTRL_C_DOUBLE_TAP_MS) {
134
+ // α6.9: Claude Code-style double-press semantics. First Ctrl+C
135
+ // ALWAYS attempts to cancel an in-flight dispatch (when the
136
+ // session reports non-idle); second Ctrl+C within 1s exits the
137
+ // process. If onCancel is omitted (legacy callers, tests), the
138
+ // old behaviour is preserved: first Ctrl+C clears the buffer +
139
+ // arms the exit timer, second Ctrl+C exits.
140
+ const withinDoubleTapWindow = typeof lastCtrlCAt === 'number' && t - lastCtrlCAt <= CTRL_C_DOUBLE_TAP_MS;
141
+ if (withinDoubleTapWindow) {
142
+ // Second press inside the window — always exit. This matches
143
+ // Claude Code: even mid-dispatch, the second Ctrl+C wins so
144
+ // the operator can always escape a stuck REPL.
135
145
  props.onExit();
136
146
  return;
137
147
  }
148
+ // First press in a fresh window. If the host wired a cancel
149
+ // surface and there is something to cancel, abort the dispatch.
150
+ // The buffer is left untouched on a cancel (the operator's
151
+ // current input is NOT trashed by an accidental Ctrl+C while a
152
+ // tool is running).
153
+ //
154
+ // Three-valued onCancel return (see prop docstring):
155
+ // - true → dispatch cancelled, keep buffer, arm exit timer
156
+ // - false → idle, clear buffer (legacy), arm exit timer
157
+ // - undefined → handler bypassed (modal owns input); NO state
158
+ // change at all. Buffer stays, exit timer NOT
159
+ // armed (otherwise the modal would silently
160
+ // promote a Ctrl+C to "press again to exit",
161
+ // which is wrong context for a modal cancel).
162
+ let cancelResult;
163
+ if (props.onCancel) {
164
+ cancelResult = props.onCancel();
165
+ }
166
+ if (cancelResult === undefined && props.onCancel) {
167
+ // Bypass path - modal owns the input. Drop the press silently
168
+ // so the modal's own cancel surface (Esc / its own Ctrl+C
169
+ // binding inside the modal component) takes effect on its own
170
+ // terms. P2 fix: previously this fell through to the
171
+ // legacy buffer-clear + setLastCtrlCAt path and wiped modal
172
+ // draft text on first Ctrl+C.
173
+ return;
174
+ }
138
175
  setLastCtrlCAt(t);
139
- setLine('');
140
- setCursor(0);
141
- setSearch(undefined);
176
+ // Legacy behaviour: on idle (or no onCancel wired), clear the
177
+ // buffer + reset search so the operator's screen is calm before
178
+ // they confirm exit. When we DID cancel a live dispatch, keep
179
+ // the buffer so a half-typed brief is not lost.
180
+ if (cancelResult !== true) {
181
+ setLine('');
182
+ setCursor(0);
183
+ setSearch(undefined);
184
+ }
142
185
  return;
143
186
  }
144
187
  // Search-mode key handling. Ctrl+R / Ctrl+S cycle, Enter accepts,
@@ -456,7 +499,7 @@ export function InputBox(props) {
456
499
  : Math.min(paletteIndex, paletteView.rows.length - 1);
457
500
  const divider = '─'.repeat(innerWidth);
458
501
  const focusedMatch = search ? search.matches[search.focusedIndex] : undefined;
459
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "cyan", dimColor: true, children: divider }), _jsx(Box, { paddingX: 1, flexDirection: "column", children: search ? (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: '(reverse-i-search) ' }), _jsx(Text, { children: `\`${search.query}\`: ` }), _jsx(Text, { color: "yellow", children: focusedMatch ? focusedMatch.brief : '(no match)' })] }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: `Ctrl+R next · Ctrl+S prev · Enter accept · Esc cancel · ${search.matches.length} match${search.matches.length === 1 ? '' : 'es'}` }) })] })) : (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: '› ' }), _jsx(Text, { children: renderLineWithCursor(line, cursor, cursorVisible) })] })) }), _jsx(Text, { color: "cyan", dimColor: true, children: divider }), line.length > innerWidth - 4 ? (_jsxs(Box, { children: [_jsx(Text, { color: "gray", children: '┊ ' }), _jsx(Text, { dimColor: true, children: 'line wraps - Enter still submits' })] })) : null, _jsx(SlashPalette, { rows: paletteView.rows, focusedIndex: clampedPaletteIndex, totalBeforeLimit: paletteView.totalBeforeLimit }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: '↑/↓ history · Ctrl+R search · / commands · Enter brief · Esc cancel · Ctrl+C ×2 exit' }) })] }));
502
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "cyan", dimColor: true, children: divider }), _jsx(Box, { paddingX: 1, flexDirection: "column", children: search ? (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: '(reverse-i-search) ' }), _jsx(Text, { children: `\`${search.query}\`: ` }), _jsx(Text, { color: "yellow", children: focusedMatch ? focusedMatch.brief : '(no match)' })] }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: `Ctrl+R next · Ctrl+S prev · Enter accept · Esc cancel · ${search.matches.length} match${search.matches.length === 1 ? '' : 'es'}` }) })] })) : (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: '› ' }), _jsx(Text, { children: renderLineWithCursor(line, cursor, cursorVisible) })] })) }), _jsx(Text, { color: "cyan", dimColor: true, children: divider }), line.length > innerWidth - 4 ? (_jsxs(Box, { children: [_jsx(Text, { color: "gray", children: '┊ ' }), _jsx(Text, { dimColor: true, children: 'line wraps - Enter still submits' })] })) : null, _jsx(SlashPalette, { rows: paletteView.rows, focusedIndex: clampedPaletteIndex, totalBeforeLimit: paletteView.totalBeforeLimit }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: '↑/↓ history · Ctrl+R search · / commands · Enter brief · Esc cancel · Ctrl+C abort / ×2 exit' }) })] }));
460
503
  }
461
504
  /**
462
505
  * Render the line with the cursor glyph inserted at `cursor`. The cursor
package/dist/tui/repl.js CHANGED
@@ -156,11 +156,33 @@ export function Repl(props) {
156
156
  const handlePlanReviewResolve = useCallback((result) => {
157
157
  void props.session.resolvePlanReview(result);
158
158
  }, [props.session]);
159
- return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [props.updateBanner ? _jsx(UpdateBanner, { result: props.updateBanner }) : null, splashVisible ? (_jsx(ReplSplash, { cliVersion: state.cliVersion, workspaceLabel: state.workspaceLabel, plan: props.splashPlan, model: props.splashModel, tenant: props.splashTenant, onDismiss: dismissSplash, mascotPrePrinted: props.mascotPrePrinted === true })) : null, _jsx(Header, { state: state }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: overlay === 'help' ? (_jsx(HelpOverlay, {})) : overlay === 'roster' ? (_jsx(RosterOverlay, {})) : overlay === 'farewell' ? (_jsx(FarewellOverlay, {})) : (_jsx(MainArea, { state: state, personaNames: personaNames, nowEpochMs: tickNow, hideToolStream: props.hideToolStream === true, toolStreamCollapsed: toolStreamCollapsed })) }), state.pendingAsk ? (_jsx(Box, { marginTop: 1, children: _jsx(AskModal, { tag: state.pendingAsk, onResolve: handleAskResolve }) })) : null, state.pendingPlanReview ? (_jsx(Box, { marginTop: 1, children: _jsx(PlanReviewModal, { tag: state.pendingPlanReview, onResolve: handlePlanReviewResolve }) })) : null, _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [overlay === 'farewell' || modalActive ? null : (_jsx(InputBox, { onSubmit: handleSubmit, onExit: handleExit, now: props.now,
159
+ // α6.9: Ctrl+C abort handler. Forwards to ReplSession.cancel() which
160
+ // aborts the in-flight dispatch, closes the SSE stream, and surfaces
161
+ // "Aborted." in the transcript.
162
+ //
163
+ // Return contract (consumed by InputBox):
164
+ // - true - dispatch was cancelled (keep the buffer + DO arm
165
+ // the press-again-to-exit timer; second Ctrl+C in
166
+ // the window exits).
167
+ // - false - idle / nothing to cancel (legacy: clear buffer +
168
+ // arm the exit timer so the operator sees the hint
169
+ // and can confirm exit on the next press).
170
+ // - undefined - bypassed entirely (e.g. a modal owns the input).
171
+ // InputBox MUST NOT arm the exit timer and MUST
172
+ // NOT clear the buffer. P2 fix: previously this
173
+ // returned `false` and the buffer-clear path wiped
174
+ // the operator's mid-typed modal text on the first
175
+ // Ctrl+C, costing a press of work.
176
+ const handleCancel = useCallback(() => {
177
+ if (modalActive)
178
+ return undefined;
179
+ return props.session.cancel();
180
+ }, [props.session, modalActive]);
181
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [props.updateBanner ? _jsx(UpdateBanner, { result: props.updateBanner }) : null, splashVisible ? (_jsx(ReplSplash, { cliVersion: state.cliVersion, workspaceLabel: state.workspaceLabel, plan: props.splashPlan, model: props.splashModel, tenant: props.splashTenant, onDismiss: dismissSplash, mascotPrePrinted: props.mascotPrePrinted === true })) : null, _jsx(Header, { state: state }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: overlay === 'help' ? (_jsx(HelpOverlay, {})) : overlay === 'roster' ? (_jsx(RosterOverlay, {})) : overlay === 'farewell' ? (_jsx(FarewellOverlay, {})) : (_jsx(MainArea, { state: state, personaNames: personaNames, nowEpochMs: tickNow, hideToolStream: props.hideToolStream === true, toolStreamCollapsed: toolStreamCollapsed })) }), state.pendingAsk ? (_jsx(Box, { marginTop: 1, children: _jsx(AskModal, { tag: state.pendingAsk, onResolve: handleAskResolve }) })) : null, state.pendingPlanReview ? (_jsx(Box, { marginTop: 1, children: _jsx(PlanReviewModal, { tag: state.pendingPlanReview, onResolve: handlePlanReviewResolve }) })) : null, _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [overlay === 'farewell' || modalActive ? null : (_jsx(InputBox, { onSubmit: handleSubmit, onExit: handleExit, onCancel: handleCancel, now: props.now,
160
182
  // Slug from process.cwd() (full path) so two workspaces with
161
183
  // the same basename do not share history. state.workspaceLabel
162
184
  // is the basename only. Codex review P2.
163
- workspaceSlug: slugForCwd(process.cwd()) })), _jsx(StatusBar, { connection: state.connection, activeAgentCount: countActive(state), tokensDownstreamTotal: state.tokensDownstreamTotal, briefStartedAtEpochMs: state.briefStartedAtEpochMs, nowEpochMs: tickNow, pulsePhase: pulsePhase, pugiMdCount: workspaceContext.pugiMdCount, mcpServerCount: workspaceContext.mcpServerCount, skillCount: workspaceContext.skillCount, quotaPct: props.quotaPct })] })] }));
185
+ workspaceSlug: slugForCwd(process.cwd()) })), _jsx(StatusBar, { connection: state.connection, activeAgentCount: countActive(state), tokensDownstreamTotal: state.tokensDownstreamTotal, briefStartedAtEpochMs: state.briefStartedAtEpochMs, nowEpochMs: tickNow, pulsePhase: pulsePhase, pugiMdCount: workspaceContext.pugiMdCount, mcpServerCount: workspaceContext.mcpServerCount, skillCount: workspaceContext.skillCount, quotaPct: props.quotaPct, dispatchState: state.dispatchState, dispatchToolLabel: state.dispatchToolLabel })] })] }));
164
186
  }
165
187
  function Header({ state }) {
166
188
  return (_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Pugi" }), _jsx(Text, { bold: true, color: "cyan", children: ".io" }), _jsx(Text, { dimColor: true, children: ` · workspace: ${state.workspaceLabel} · v${state.cliVersion} · ` }), _jsx(Text, { color: "cyan", children: state.connection === 'on_watch' ? 'on watch' : state.connection.replace('_', ' ') })] }));
@@ -12,7 +12,12 @@ export function StatusBar(props) {
12
12
  const tokenLabel = formatTokens(props.tokensDownstreamTotal);
13
13
  const phase = clampPhase(props.pulsePhase);
14
14
  const glyph = PULSE_DOTS[Math.min(phase, PULSE_DOTS.length - 1)] ?? PULSE_DOTS[0];
15
- const status = connectionLabel(props.connection);
15
+ // α6.9: composite status label connection problems trump dispatch
16
+ // state because the operator needs to know about a dropped admin-api
17
+ // first. When the connection is healthy (`on_watch` / `connecting`),
18
+ // the FSM dispatch state takes over to show the dispatch lifecycle
19
+ // (`dispatching` / `tool: read` / `aborting` / etc.).
20
+ const status = composeStatusLabel(props.connection, props.dispatchState, props.dispatchToolLabel);
16
21
  return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: status.color, children: `${glyph ?? '●'} ${status.label}` }), _jsx(Text, { dimColor: true, children: ` · ${props.activeAgentCount} agents · ` }), _jsx(Text, { children: `↓ ${tokenLabel} tokens` }), _jsx(Text, { dimColor: true, children: ` · ${elapsedLabel}` })] }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: `${formatCount(props.pugiMdCount)} PUGI.md · ${formatCount(props.mcpServerCount)} MCP · ${formatCount(props.skillCount)} skills · ${formatQuota(props.quotaPct)} quota` }) })] }));
17
22
  }
18
23
  /**
@@ -28,10 +33,17 @@ function formatQuota(pct) {
28
33
  return '—';
29
34
  return `${Math.round(pct)}%`;
30
35
  }
31
- function connectionLabel(connection) {
36
+ // Exported for test introspection (status-bar-fsm.spec.tsx asserts
37
+ // connecting vs on_watch render in distinct colors; ink-testing-library
38
+ // strips ANSI from lastFrame() so we read the resolved color directly).
39
+ export function connectionLabel(connection) {
32
40
  switch (connection) {
33
41
  case 'connecting':
34
- return { label: 'connecting', color: 'cyan' };
42
+ // P2 fix: was 'cyan', same as 'on_watch' - operator could not
43
+ // tell boot from stable. Magenta is distinct from every other
44
+ // state in this palette (cyan steady, yellow reconnect, gray
45
+ // offline) so a brief flicker through this state stands out.
46
+ return { label: 'connecting', color: 'magenta' };
35
47
  case 'on_watch':
36
48
  return { label: 'on watch', color: 'cyan' };
37
49
  case 'reconnecting':
@@ -40,6 +52,54 @@ function connectionLabel(connection) {
40
52
  return { label: 'offline', color: 'gray' };
41
53
  }
42
54
  }
55
+ /**
56
+ * α6.9: compose the visible status label from connection + FSM state.
57
+ *
58
+ * Priority order:
59
+ *
60
+ * 1. `offline` / `reconnecting` — transport health wins; the
61
+ * operator needs to know about a dropped stream before anything
62
+ * about the dispatch.
63
+ * 2. `aborting` / `aborted` / `failed` — operator-visible terminal
64
+ * states the FSM reached; the colour shifts to amber/red so the
65
+ * anomaly stands out vs the calm cyan baseline.
66
+ * 3. `tool_running` — surfaces the tool label when available
67
+ * (`tool: read`), falls back to `tool` when not.
68
+ * 4. `awaiting_response` — `dispatching` (matches Codex CLI's verb
69
+ * for the same state).
70
+ * 5. `completed` — `shipped` (matches the agent tree status glyph
71
+ * so the operator's eye links the two surfaces).
72
+ * 6. `idle` / unknown — connection label (`on watch` / `connecting`).
73
+ *
74
+ * The dispatch label `dispatchToolLabel` is already shaped as
75
+ * `tool: <kind>` upstream so we just concatenate; null falls through
76
+ * to the bare `tool` placeholder.
77
+ */
78
+ function composeStatusLabel(connection, dispatchState, toolLabel) {
79
+ // Transport health wins.
80
+ if (connection === 'offline' || connection === 'reconnecting') {
81
+ return connectionLabel(connection);
82
+ }
83
+ // FSM dispatch state overlay (only when the FSM was wired).
84
+ switch (dispatchState) {
85
+ case 'aborting':
86
+ return { label: 'aborting', color: 'yellow' };
87
+ case 'aborted':
88
+ return { label: 'aborted', color: 'gray' };
89
+ case 'failed':
90
+ return { label: 'failed', color: 'red' };
91
+ case 'tool_running':
92
+ return { label: toolLabel ?? 'tool', color: 'cyan' };
93
+ case 'awaiting_response':
94
+ return { label: 'dispatching', color: 'cyan' };
95
+ case 'completed':
96
+ return { label: 'shipped', color: 'green' };
97
+ case 'idle':
98
+ case undefined:
99
+ default:
100
+ return connectionLabel(connection);
101
+ }
102
+ }
43
103
  function formatElapsed(startedAt, now) {
44
104
  if (typeof startedAt !== 'number')
45
105
  return 'idle';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pugi/cli",
3
- "version": "0.1.0-alpha.19",
3
+ "version": "0.1.0-alpha.20",
4
4
  "description": "Pugi CLI - terminal-native software execution system",
5
5
  "homepage": "https://pugi.io",
6
6
  "repository": {
@@ -52,7 +52,7 @@
52
52
  "undici": "^8.3.0",
53
53
  "zod": "^3.23.0",
54
54
  "@pugi/personas": "0.1.1",
55
- "@pugi/sdk": "0.1.0-alpha.19"
55
+ "@pugi/sdk": "0.1.0-alpha.20"
56
56
  },
57
57
  "devDependencies": {
58
58
  "@types/node": "^22.0.0",