@pugi/cli 0.1.0-alpha.8 → 0.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +33 -0
  2. package/assets/pugi-mascot.ansi +41 -0
  3. package/dist/commands/deploy.js +439 -0
  4. package/dist/core/agents/loader.js +104 -0
  5. package/dist/core/agents/registry.js +1 -1
  6. package/dist/core/consensus/anvil-fanout.js +276 -0
  7. package/dist/core/consensus/diff-capture.js +382 -0
  8. package/dist/core/consensus/rubric.js +233 -0
  9. package/dist/core/context/index.js +21 -0
  10. package/dist/core/context/pugiignore.js +316 -0
  11. package/dist/core/context/repo-skeleton.js +533 -0
  12. package/dist/core/context/watcher.js +342 -0
  13. package/dist/core/context/working-set.js +165 -0
  14. package/dist/core/edits/dispatch.js +185 -0
  15. package/dist/core/edits/index.js +15 -0
  16. package/dist/core/edits/layer-a-apply.js +217 -0
  17. package/dist/core/edits/layer-b-apply.js +211 -0
  18. package/dist/core/edits/layer-c-apply.js +160 -0
  19. package/dist/core/edits/layer-d-ast.js +29 -0
  20. package/dist/core/edits/marker-parser.js +401 -0
  21. package/dist/core/edits/security-gate.js +223 -0
  22. package/dist/core/engine/native-pugi.js +6 -1
  23. package/dist/core/engine/tool-bridge.js +33 -1
  24. package/dist/core/repl/ask.js +512 -0
  25. package/dist/core/repl/cancellation.js +98 -0
  26. package/dist/core/repl/dispatch-fsm.js +220 -0
  27. package/dist/core/repl/privacy-banner.js +71 -0
  28. package/dist/core/repl/session.js +1909 -13
  29. package/dist/core/repl/slash-commands.js +59 -32
  30. package/dist/core/repl/store/index.js +12 -0
  31. package/dist/core/repl/store/jsonl-log.js +321 -0
  32. package/dist/core/repl/store/lockfile.js +155 -0
  33. package/dist/core/repl/store/session-store.js +792 -0
  34. package/dist/core/repl/store/types.js +44 -0
  35. package/dist/core/repl/store/uuid-v7.js +68 -0
  36. package/dist/core/repl/workspace-context.js +184 -0
  37. package/dist/core/skills/loader.js +454 -0
  38. package/dist/core/skills/sources.js +480 -0
  39. package/dist/core/skills/trust.js +172 -0
  40. package/dist/runtime/cli.js +728 -10
  41. package/dist/runtime/commands/agents.js +385 -0
  42. package/dist/runtime/commands/config.js +338 -8
  43. package/dist/runtime/commands/review-consensus.js +399 -0
  44. package/dist/runtime/commands/skills.js +401 -0
  45. package/dist/tools/file-tools.js +90 -0
  46. package/dist/tools/web-fetch.js +1 -1
  47. package/dist/tui/agent-tree-pane.js +9 -0
  48. package/dist/tui/ask-cli.js +52 -0
  49. package/dist/tui/ask-modal.js +211 -0
  50. package/dist/tui/conversation-pane.js +48 -3
  51. package/dist/tui/input-box.js +48 -5
  52. package/dist/tui/markdown-render.js +266 -0
  53. package/dist/tui/repl-render.js +183 -4
  54. package/dist/tui/repl-splash-art.js +64 -0
  55. package/dist/tui/repl-splash-mascot.js +130 -0
  56. package/dist/tui/repl-splash.js +117 -0
  57. package/dist/tui/repl.js +108 -11
  58. package/dist/tui/slash-palette.js +47 -10
  59. package/dist/tui/status-bar.js +77 -4
  60. package/dist/tui/tool-stream-pane.js +91 -0
  61. package/dist/tui/workspace-context.js +105 -0
  62. package/package.json +11 -5
@@ -27,18 +27,53 @@
27
27
  * verbatim - the brand gate on those happens at the controller.
28
28
  */
29
29
  import { randomUUID } from 'node:crypto';
30
+ import { getPersona } from '@pugi/personas';
30
31
  import { listRoles, getPersonaForRole } from '../agents/registry.js';
31
32
  import { evaluateCap, describeVerdict } from './cap-warning.js';
32
33
  import { parseSlashCommand } from './slash-commands.js';
33
34
  import { webFetchTool } from '../../tools/web-fetch.js';
34
35
  import { loadSettings } from '../settings.js';
35
36
  import { getJobRegistry } from '../jobs/registry.js';
37
+ import { extractAskTags, extractPlanReviewTags, signatureForAsk, } from './ask.js';
36
38
  import { existsSync, readdirSync, statSync } from 'node:fs';
37
39
  import { resolve as resolvePath } from 'node:path';
40
+ import { CancellationToken } from './cancellation.js';
41
+ import { DispatchFSM } from './dispatch-fsm.js';
38
42
  const MAX_TRANSCRIPT_ROWS = 500;
43
+ const MAX_TOOL_CALLS = 200;
39
44
  const MAX_RECONNECT_ATTEMPTS = 10;
40
45
  const RECONNECT_BASE_MS = 250;
41
46
  const RECONNECT_MAX_MS = 5_000;
47
+ /**
48
+ * α6.5 filewatch throttle: minimum gap between two file-change
49
+ * system lines surfaced in the conversation pane. Per the sprint
50
+ * spec, a noisy save burst should not flood the transcript - we
51
+ * coalesce all chokidar batches that arrive inside the window into
52
+ * a single "file changed: ..." line.
53
+ */
54
+ const FILEWATCH_SYSTEM_LINE_GAP_MS = 5_000;
55
+ /**
56
+ * Hard cap on the size of `pendingFilewatchBatches`. The throttle window
57
+ * is 5s, but a `tsc --watch` style tool can fire dozens of `change`
58
+ * events per second for hours on end if the operator leaves the REPL
59
+ * up. Without a cap, every batch arriving inside the throttle window
60
+ * would accumulate forever, holding refs to thousands of FilewatchBatch
61
+ * objects (each carrying its own events array). On overflow we drop
62
+ * the OLDEST batch and surface a one-shot system warning so the
63
+ * operator knows the buffer is shedding. triple-review P1 (PR #380).
64
+ */
65
+ const PENDING_FILEWATCH_BATCH_CAP = 100;
66
+ /**
67
+ * Cap on silent session-recreate attempts on HTTP 404 from the SSE
68
+ * stream. When admin-api restarts it drops its in-memory session
69
+ * store, so the saved sessionId returns 404 on every subscribe. The
70
+ * CLI mints a fresh server session, swaps the consumer over, and
71
+ * keeps running - but we cap the recovery to 3 attempts inside 60s
72
+ * so a truly down admin-api fails loud instead of spinning forever.
73
+ * (α6.14.2 wave 5 - CEO dogfood fix.)
74
+ */
75
+ const MAX_SESSION_RECREATE_ATTEMPTS = 3;
76
+ const SESSION_RECREATE_WINDOW_MS = 60_000;
42
77
  export class ReplSession {
43
78
  options;
44
79
  subscribers = new Set();
@@ -48,30 +83,257 @@ export class ReplSession {
48
83
  reconnectAttempt = 0;
49
84
  reconnectTimer;
50
85
  closed = false;
86
+ /**
87
+ * Rolling window of recent silent-recreate timestamps (epoch ms).
88
+ * The SSE stream returns HTTP 404 when admin-api has restarted and
89
+ * lost its in-memory session store; rather than spam the operator
90
+ * with "Stream interrupted (HTTP 404)" loops, we mint a fresh
91
+ * session and swap the consumer. Capped at MAX_SESSION_RECREATE_*
92
+ * inside SESSION_RECREATE_WINDOW_MS so a permanently down admin-api
93
+ * fails loud instead of looping silently. (α6.14.2 wave 5.)
94
+ */
95
+ recentRecreateAtMs = [];
96
+ /**
97
+ * True while a session-recreate POST is in flight. Guards against
98
+ * the SSE stream firing multiple `onError(404)` callbacks racing
99
+ * the in-flight createSession promise. (α6.14.2 wave 5.)
100
+ */
101
+ recreatingSession = false;
51
102
  /**
52
103
  * Last non-trivial step.detail recorded per taskId. The server streams
53
104
  * the persona reply incrementally via `agent.step` events whose
54
105
  * `detail` field carries the cumulative model output. `agent.completed`
55
106
  * arrives last and previously overwrote the visible detail to the
56
107
  * literal string `'shipped'` while the transcript line said only
57
- * `shipped.` the actual reply text was lost. By caching the last
108
+ * `shipped.` - the actual reply text was lost. By caching the last
58
109
  * non-trivial detail here, we can flush it into the transcript when
59
110
  * the agent completes so the operator sees what the persona actually
60
111
  * said. CEO wave-2 fix 2026-05-25.
61
112
  */
62
113
  lastStepDetail = new Map();
114
+ /**
115
+ * Optional local SessionStore - α6.4. When non-null, every
116
+ * appendRow() call mirrors the row into the JSONL log so the
117
+ * conversation can be restored via `/resume`. Errors from the store
118
+ * are swallowed to a single system line (degradation, not crash).
119
+ * The store is opened by the CLI bootstrap and closed via
120
+ * `ReplSession.close()`. The store ownership is shared - the
121
+ * SqliteSessionStore is process-wide singleton-ish under the
122
+ * lockfile, so close-on-quit is safe.
123
+ */
124
+ store;
125
+ /**
126
+ * Local session id used as the persistence key. Distinct from the
127
+ * server-side sessionId issued by admin-api in state.sessionId.
128
+ * When the operator runs `pugi resume <id>`, the CLI passes the id
129
+ * via `localSessionId` so the JSONL log keeps growing under the
130
+ * original id rather than fragmenting into a new one.
131
+ */
132
+ localSessionId;
133
+ /**
134
+ * One-shot guard so a store error only emits ONE system line per
135
+ * session - without this, a stuck filesystem would spam the operator
136
+ * with `[store]` errors on every keystroke.
137
+ */
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;
155
+ /**
156
+ * α6.5 Tier 0 / Tier 1 / chokidar wiring. The bootstrap builds the
157
+ * skeleton + working set + watcher once and hands them to the
158
+ * session. The session uses them to:
159
+ *
160
+ * - render `/context` (count + cap + total bytes + skeleton size).
161
+ * - emit throttled "file changed" system lines on watcher batches.
162
+ * - forget removed files from the working set on `unlink`.
163
+ *
164
+ * All three are optional - tests and minimal callers pass null /
165
+ * undefined and the session degrades to "no three-tier integration"
166
+ * silently. The watcher's own lifecycle is owned by the bootstrap
167
+ * (we do NOT close it in `close()`).
168
+ */
169
+ repoSkeleton;
170
+ workingSet;
171
+ watcher;
172
+ /**
173
+ * Epoch ms of the last filewatch system line. Initialised to 0 so
174
+ * the FIRST batch always emits; subsequent batches inside the gap
175
+ * are coalesced into the next emit window.
176
+ */
177
+ lastFilewatchLineAtEpochMs = 0;
178
+ /**
179
+ * Buffer of batches whose emission was throttled. Drained on the
180
+ * next within-window batch by overwriting the throttled line with
181
+ * a summary that mentions how many additional files were touched.
182
+ * Capped at PENDING_FILEWATCH_BATCH_CAP to bound memory growth
183
+ * under long-running noisy filewatch sources (tsc --watch on a
184
+ * 200-file project hammering for hours). triple-review P1 (PR #380).
185
+ */
186
+ pendingFilewatchBatches = [];
187
+ /**
188
+ * One-shot guard so the overflow-warning system line emits only once
189
+ * per session rather than spamming the operator with `[filewatch]
190
+ * shedding` on every dropped batch.
191
+ */
192
+ pendingFilewatchOverflowWarned = false;
193
+ /**
194
+ * Bound subscriber refs so close() can detach the listeners from the
195
+ * shared watcher. The bootstrap owns the watcher lifecycle (it calls
196
+ * watcher.close() on REPL teardown), but the session MUST detach its
197
+ * own listeners on close() so any chokidar event landing between
198
+ * session.close() and watcher.close() does not run handlers on a
199
+ * dead session. Without detachment, recordFilewatchBatch would
200
+ * touch this.workingSet / this.transcript on a closed session.
201
+ * triple-review P1 (PR #380).
202
+ */
203
+ filewatchBatchHandler = (batch) => {
204
+ this.recordFilewatchBatch(batch);
205
+ };
206
+ filewatchCapHandler = (info) => {
207
+ this.recordFilewatchCapExceeded(info);
208
+ };
209
+ /**
210
+ * Rolling dedupe set for `<pugi-ask>` and `<pugi-plan-review>`
211
+ * signatures. The persona may emit the same envelope twice on network
212
+ * retry; we suppress the duplicate so the operator does not see two
213
+ * stacked modals. Capped at 32 entries - generous for a real session,
214
+ * defensive against a hostile flood. (α6.3.)
215
+ */
216
+ seenTagSignatures = [];
217
+ /**
218
+ * Per-task buffer for streaming tag detection. The persona's
219
+ * `<pugi-ask>` open and close tags may arrive in separate
220
+ * `agent.step` events when the upstream LLM token-streams output
221
+ * char-by-char. We accumulate the running detail per taskId until a
222
+ * complete envelope lands OR the turn ends. (α6.3.)
223
+ */
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;
286
+ /**
287
+ * Tracks taskIds that had an `<pugi-ask>` or `<pugi-plan-review>`
288
+ * envelope mid-stream the last time the parser ran on the buffer. If
289
+ * the turn ends with this flag still set, we emit a system-line
290
+ * warning that the persona produced an incomplete tag - the partial
291
+ * XML is silently dropped (the parser already withheld it from the
292
+ * cleaned body). Codex triple-review P2 (PR #375).
293
+ */
294
+ askBufferPending = new Set();
63
295
  constructor(options) {
64
296
  this.options = options;
297
+ this.store = options.store ?? null;
298
+ this.localSessionId = options.localSessionId;
299
+ this.repoSkeleton = options.repoSkeleton ?? null;
300
+ this.workingSet = options.workingSet ?? null;
301
+ this.watcher = options.watcher ?? null;
302
+ // Subscribe to the chokidar watcher when present. Late-binding
303
+ // happens here so the bootstrap can construct the session and
304
+ // attach the watcher in one pass without re-validating shape.
305
+ if (this.watcher) {
306
+ this.watcher.on('batch', this.filewatchBatchHandler);
307
+ this.watcher.on('capExceeded', this.filewatchCapHandler);
308
+ }
65
309
  this.state = {
66
310
  sessionId: undefined,
67
311
  workspaceLabel: options.workspaceLabel,
68
312
  cliVersion: options.cliVersion,
69
313
  connection: 'connecting',
70
314
  agents: [],
315
+ toolCalls: [],
71
316
  transcript: [],
72
317
  tokensDownstreamTotal: 0,
73
318
  briefStartedAtEpochMs: undefined,
319
+ pendingAsk: null,
320
+ pendingAskSource: null,
321
+ pendingPlanReview: null,
322
+ pendingPlanReviewSource: null,
323
+ dispatchState: 'idle',
324
+ dispatchToolLabel: null,
74
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 }));
75
337
  }
76
338
  /* ------------- subscribe / state -------------- */
77
339
  subscribe(callback) {
@@ -96,21 +358,76 @@ export class ReplSession {
96
358
  const { sessionId } = await this.options.transport.createSession({
97
359
  apiUrl: this.options.apiUrl,
98
360
  apiKey: this.options.apiKey,
361
+ workspace: this.options.workspace,
99
362
  });
100
363
  this.patch({ sessionId, connection: 'connecting' });
101
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);
102
374
  }
103
375
  catch (error) {
104
376
  this.appendSystemLine(`Could not open Pugi session: ${this.errorMessage(error)}`);
105
377
  this.patch({ connection: 'offline' });
106
378
  }
107
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
+ }
108
417
  /**
109
418
  * Tear down the SSE stream and stop the reconnect timer. The session
110
419
  * id stays valid server-side; `pugi resume <id>` reopens later.
111
420
  */
112
421
  close() {
113
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
+ }
114
431
  if (this.streamHandle) {
115
432
  this.streamHandle.close();
116
433
  this.streamHandle = undefined;
@@ -119,6 +436,116 @@ export class ReplSession {
119
436
  clearTimeout(this.reconnectTimer);
120
437
  this.reconnectTimer = undefined;
121
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();
444
+ // Detach watcher listeners so any chokidar event landing between
445
+ // session.close() and the bootstrap-owned watcher.close() does NOT
446
+ // run a handler on a dead session. The handlers themselves also
447
+ // hard-guard on `this.closed`, but detaching is the load-bearing
448
+ // fix - it severs the strong reference the watcher held on the
449
+ // session callback, which otherwise blocks GC. triple-review P1 (PR #380).
450
+ if (this.watcher) {
451
+ this.watcher.off('batch', this.filewatchBatchHandler);
452
+ this.watcher.off('capExceeded', this.filewatchCapHandler);
453
+ }
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;
122
549
  }
123
550
  /* ------------- input handling -------------- */
124
551
  /**
@@ -137,7 +564,11 @@ export class ReplSession {
137
564
  // UI overlays - no transport interaction.
138
565
  return verdict;
139
566
  case 'quit':
140
- this.appendSystemLine('Brief it. It ships.');
567
+ // UI Designer audit 2026-05-25: "Brief it. It ships." is reserved
568
+ // for identity intro + landing per wave-4 prompt rule. Drop the
569
+ // tagline drift here; tell the operator what happened and how to
570
+ // resume.
571
+ this.appendSystemLine('On watch ended. pugi resume to come back.');
141
572
  return verdict;
142
573
  case 'error':
143
574
  this.appendSystemLine(verdict.message);
@@ -178,12 +609,109 @@ export class ReplSession {
178
609
  this.dispatchStatus();
179
610
  return verdict;
180
611
  }
612
+ case 'consensus': {
613
+ // alpha 6.7: surface a deterministic deep-link so the operator
614
+ // knows the command exists in the REPL palette even though the
615
+ // full SSE renderer ships outside the Ink frame. Running the
616
+ // gate live inside the REPL needs the non-TTY emit path; for
617
+ // M1 we point the operator at the shell command.
618
+ const tail = verdict.ref ? ` ${verdict.ref}` : '';
619
+ this.appendSystemLine(`Run \`pugi review --consensus${tail}\` from a fresh shell to dispatch the 3-model gate.`);
620
+ return verdict;
621
+ }
622
+ case 'resume': {
623
+ await this.dispatchResume();
624
+ return verdict;
625
+ }
626
+ case 'context': {
627
+ this.dispatchContext();
628
+ return verdict;
629
+ }
630
+ case 'ask': {
631
+ // α6.3: synthesise a local yes/no `<pugi-ask>` modal so the
632
+ // operator can exercise the question UI without a persona-side
633
+ // round trip. The REPL UI mounts the modal from the resulting
634
+ // `pendingAsk` state; on resolution the encoded verdict lands
635
+ // in the transcript as a system line (no admin-api dispatch
636
+ // because the question is local).
637
+ const askTag = synthesiseLocalAskTag(verdict.question);
638
+ if (!askTag) {
639
+ this.appendSystemLine('Could not synthesise local ask (question too long?). Cap is 80 chars.');
640
+ return verdict;
641
+ }
642
+ this.patch({ pendingAsk: askTag, pendingAskSource: 'local' });
643
+ return verdict;
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
+ }
181
654
  case 'stub': {
182
655
  this.appendSystemLine(verdict.message);
183
656
  return verdict;
184
657
  }
185
658
  }
186
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
+ }
679
+ /**
680
+ * In-REPL `/resume` - α6.4. Lists the 10 most recent sessions from
681
+ * the local SessionStore and prints them as a numbered system menu.
682
+ * The Ink-side picker UI is deferred to the next sprint; today the
683
+ * operator gets a deterministic list + the exact command to relaunch
684
+ * with: `pugi resume <id>`. Keeping the picker out of the REPL
685
+ * frame avoids re-architecting the conversation pane mid-sprint and
686
+ * keeps `/resume` testable without an Ink runtime.
687
+ */
688
+ async dispatchResume() {
689
+ if (!this.store) {
690
+ this.appendSystemLine('Local session store is disabled - /resume is unavailable.');
691
+ return;
692
+ }
693
+ let rows;
694
+ try {
695
+ rows = await this.store.listSessions({ limit: 10 });
696
+ }
697
+ catch (error) {
698
+ this.appendSystemLine(`Could not list sessions: ${this.errorMessage(error)}`);
699
+ return;
700
+ }
701
+ if (rows.length === 0) {
702
+ this.appendSystemLine('No stored sessions yet - keep dispatching to build history.');
703
+ return;
704
+ }
705
+ this.appendSystemLine(`Recent sessions (${rows.length}):`);
706
+ for (let i = 0; i < rows.length; i += 1) {
707
+ const row = rows[i];
708
+ const title = (row.title ?? '(untitled)').slice(0, 64);
709
+ const idShort = row.id.slice(0, 13);
710
+ const branch = row.branch ?? 'no-branch';
711
+ this.appendSystemLine(` ${(i + 1).toString().padStart(2)}. ${idShort} ${branch.padEnd(16)} ${title}`);
712
+ }
713
+ this.appendSystemLine('Pick one with: pugi resume <id> (paste the 13-char id from above).');
714
+ }
187
715
  /**
188
716
  * Reset the conversation transcript. The agent registry stays intact
189
717
  * so the operator can `/clear` to declutter the chat pane without
@@ -192,6 +720,173 @@ export class ReplSession {
192
720
  clearTranscript() {
193
721
  this.patch({ transcript: [] });
194
722
  }
723
+ /* ------------- α6.3 office-hours surface -------------- */
724
+ /**
725
+ * Surface an `<pugi-ask>` modal manually. Returned promise resolves
726
+ * with the operator's verdict - used by the `pugi ask "<q>"` shell
727
+ * command and by the `/ask` slash. The resolver is wired into the
728
+ * session state via `pendingAsk` so the REPL UI can render the modal
729
+ * and forward `onResolve` back through `resolveAsk()`.
730
+ *
731
+ * NOTE: idempotent on a duplicate signature - a second presentAsk
732
+ * with the same question + option values returns the first
733
+ * outstanding promise rather than stacking two modals.
734
+ */
735
+ presentAsk(tag) {
736
+ if (this.outstandingAskPromise
737
+ && this.state.pendingAsk?.signature === tag.signature) {
738
+ // The operator is already looking at this exact ask; reuse the
739
+ // outstanding promise so the second caller sees the same answer
740
+ // when it eventually arrives.
741
+ return this.outstandingAskPromise;
742
+ }
743
+ // If a DIFFERENT ask is open, reject the new one with a clear
744
+ // error rather than silently queueing - the persona should never
745
+ // emit two concurrent asks, and surfacing the bug fails loud.
746
+ if (this.outstandingAskPromise) {
747
+ return Promise.reject(new Error('presentAsk: another ask is already pending. Resolve it first.'));
748
+ }
749
+ this.patch({ pendingAsk: tag, pendingAskSource: 'persona' });
750
+ const promise = new Promise((resolve) => {
751
+ this.outstandingAskResolver = resolve;
752
+ });
753
+ this.outstandingAskPromise = promise;
754
+ return promise;
755
+ }
756
+ /**
757
+ * Resolve the currently pending `<pugi-ask>` modal. Called by the
758
+ * REPL UI when the operator submits the modal. Appends an operator
759
+ * line to the transcript carrying the verdict, dispatches the
760
+ * verdict-encoded brief to admin-api as the next user turn (when the
761
+ * modal originated from a persona stream), and clears `pendingAsk`.
762
+ * Idempotent: a second call without a fresh `pendingAsk` is a no-op.
763
+ *
764
+ * The verdict is also forwarded to the resolver returned by
765
+ * `presentAsk()` if there is one outstanding, so the CLI's `pugi ask`
766
+ * command can await the answer.
767
+ *
768
+ * Cancellation contract (Claude triple-review P1): when the modal
769
+ * came from a persona stream, cancel ALSO dispatches a literal
770
+ * `[ASK-RESPONSE:cancelled]` to admin-api so the persona observes the
771
+ * cancellation rather than hanging indefinitely on the missing
772
+ * follow-up. The matching documentation in the Mira system prompt
773
+ * teaches the persona to acknowledge cancellation and offer a
774
+ * different path. Local-origin modals (synthesised via `/ask`) skip
775
+ * the dispatch entirely - the persona never saw the question.
776
+ *
777
+ * Free-text sanitisation (Claude triple-review P1): the operator's
778
+ * customInput is stripped of any leading `[ASK-RESPONSE:...]` /
779
+ * `[PLAN-VERDICT:...]` prefix before being encoded, so a malicious
780
+ * (or accidental) operator string cannot forge a verdict header that
781
+ * a prefix-greedy persona would misinterpret as a different choice.
782
+ */
783
+ async resolveAsk(verdict) {
784
+ if (!this.state.pendingAsk)
785
+ return;
786
+ const tag = this.state.pendingAsk;
787
+ const source = this.state.pendingAskSource;
788
+ const sanitisedVerdict = sanitiseAskVerdict(verdict);
789
+ this.patch({ pendingAsk: null, pendingAskSource: null });
790
+ const encoded = encodeAskVerdictLocal(sanitisedVerdict);
791
+ // Tell the outstanding presentAsk caller, if any. The sanitised
792
+ // verdict is forwarded so downstream consumers cannot be tricked
793
+ // by a forged verdict header either.
794
+ if (this.outstandingAskResolver) {
795
+ const resolver = this.outstandingAskResolver;
796
+ this.outstandingAskResolver = undefined;
797
+ this.outstandingAskPromise = undefined;
798
+ resolver(sanitisedVerdict);
799
+ }
800
+ // Surface the operator's choice as a transcript row so the
801
+ // conversation reads linearly. The label of the chosen option
802
+ // (or the literal custom input) is more readable than the bare
803
+ // value - Codex CLI's "you chose: Vercel" pattern.
804
+ const humanLabel = humanLabelForVerdict(tag, sanitisedVerdict);
805
+ this.appendOperatorLine(humanLabel);
806
+ // Local-origin modals (operator typed `/ask`) never need an
807
+ // admin-api round trip - the persona never observed the question.
808
+ // Codex triple-review P2.
809
+ if (source !== 'persona')
810
+ return;
811
+ // Persona-origin modals always dispatch, including on cancel:
812
+ // without the cancellation echo the persona's last turn stays open
813
+ // and the agent hangs. Claude triple-review P1.
814
+ await this.dispatchAskFollowup(encoded);
815
+ }
816
+ /**
817
+ * Same shape as `presentAsk` for the plan-review modal.
818
+ */
819
+ presentPlanReview(tag) {
820
+ if (this.outstandingPlanReviewPromise
821
+ && this.state.pendingPlanReview?.signature === tag.signature) {
822
+ return this.outstandingPlanReviewPromise;
823
+ }
824
+ if (this.outstandingPlanReviewPromise) {
825
+ return Promise.reject(new Error('presentPlanReview: another plan review is already pending.'));
826
+ }
827
+ this.patch({ pendingPlanReview: tag, pendingPlanReviewSource: 'persona' });
828
+ const promise = new Promise((resolve) => {
829
+ this.outstandingPlanReviewResolver = resolve;
830
+ });
831
+ this.outstandingPlanReviewPromise = promise;
832
+ return promise;
833
+ }
834
+ /**
835
+ * Resolve the currently pending `<pugi-plan-review>` modal. Same
836
+ * mechanics as `resolveAsk` - including the cancel-dispatch contract
837
+ * and modifyText sanitisation. The persona always sees a
838
+ * `[PLAN-VERDICT:...]` echo (even on cancel) so it never hangs
839
+ * waiting for the verdict that the operator declined to send.
840
+ */
841
+ async resolvePlanReview(result) {
842
+ if (!this.state.pendingPlanReview)
843
+ return;
844
+ const source = this.state.pendingPlanReviewSource;
845
+ const sanitisedResult = sanitisePlanReviewResult(result);
846
+ this.patch({ pendingPlanReview: null, pendingPlanReviewSource: null });
847
+ const encoded = encodePlanReviewVerdictLocal(sanitisedResult);
848
+ if (this.outstandingPlanReviewResolver) {
849
+ const resolver = this.outstandingPlanReviewResolver;
850
+ this.outstandingPlanReviewResolver = undefined;
851
+ this.outstandingPlanReviewPromise = undefined;
852
+ resolver(sanitisedResult);
853
+ }
854
+ this.appendOperatorLine(humanLabelForPlanReviewVerdict(sanitisedResult));
855
+ // Local-origin plan reviews skip the dispatch (Codex P2). Persona
856
+ // origin always dispatches, including on cancel (Claude P1).
857
+ if (source !== 'persona')
858
+ return;
859
+ await this.dispatchAskFollowup(encoded);
860
+ }
861
+ /**
862
+ * Internal: post the verdict-encoded brief WITHOUT going through the
863
+ * cap-warning gate. The follow-up is the natural continuation of the
864
+ * same conversation the persona started, so blocking it on capacity
865
+ * would strand the operator with no way to answer.
866
+ */
867
+ async dispatchAskFollowup(encodedBrief) {
868
+ const sessionId = this.state.sessionId;
869
+ if (!sessionId) {
870
+ this.appendSystemLine('No server session - response queued locally.');
871
+ return;
872
+ }
873
+ try {
874
+ await this.options.transport.postBrief({
875
+ apiUrl: this.options.apiUrl,
876
+ apiKey: this.options.apiKey,
877
+ sessionId,
878
+ brief: encodedBrief,
879
+ });
880
+ }
881
+ catch (error) {
882
+ this.appendSystemLine(`Could not forward response: ${this.errorMessage(error)}`);
883
+ }
884
+ }
885
+ // ----- Outstanding resolver bookkeeping for presentAsk / presentPlanReview -----
886
+ outstandingAskResolver;
887
+ outstandingAskPromise;
888
+ outstandingPlanReviewResolver;
889
+ outstandingPlanReviewPromise;
195
890
  /* ------------- Tier 1 / Tier 2 wired handlers -------------- */
196
891
  async dispatchJobs() {
197
892
  try {
@@ -260,6 +955,129 @@ export class ReplSession {
260
955
  this.appendSystemLine(`Workspace: ${this.state.workspaceLabel}.`);
261
956
  this.appendSystemLine(`CLI: pugi ${this.state.cliVersion}.`);
262
957
  }
958
+ /**
959
+ * α6.5 `/context` slash handler. Surfaces the three-tier context
960
+ * summary as a stack of system lines. Sections (in order):
961
+ *
962
+ * 1. Tier 0 (repo skeleton) - size in bytes, branch, package
963
+ * manager, languages. Skipped when no skeleton was injected
964
+ * (REPL launched outside a workspace or with --no-context).
965
+ *
966
+ * 2. Tier 1 (working set) - `count / capacity` plus the total
967
+ * size in bytes plus the oldest entry's age in seconds.
968
+ * Always emits even when empty so the operator can confirm
969
+ * the tier is wired.
970
+ *
971
+ * 3. Tier 2 (RAG) - one-line heads-up that the Anvil-side
972
+ * workspace lands in α6.5b.
973
+ *
974
+ * The renderer never mutates state.
975
+ */
976
+ dispatchContext() {
977
+ if (this.repoSkeleton) {
978
+ const parts = [`Tier 0 skeleton: ${this.repoSkeleton.totalSize} bytes`];
979
+ if (this.repoSkeleton.branch)
980
+ parts.push(`branch ${this.repoSkeleton.branch}`);
981
+ if (this.repoSkeleton.packageManager)
982
+ parts.push(this.repoSkeleton.packageManager);
983
+ if (this.repoSkeleton.primaryLanguages.length > 0) {
984
+ parts.push(`langs ${this.repoSkeleton.primaryLanguages.slice(0, 3).join('/')}`);
985
+ }
986
+ this.appendSystemLine(parts.join(' - '));
987
+ }
988
+ else {
989
+ this.appendSystemLine('Tier 0 skeleton: not loaded (run pugi init or launch in a workspace).');
990
+ }
991
+ if (this.workingSet) {
992
+ const summary = this.workingSet.summary();
993
+ const ageLine = summary.oldestTouchedAtEpochMs !== null
994
+ ? ` - oldest touch ${formatAgeSeconds(this.now() - summary.oldestTouchedAtEpochMs)} ago`
995
+ : '';
996
+ this.appendSystemLine(`Tier 1 working set: ${summary.count}/${summary.capacity} files, ${summary.totalSizeBytes} bytes${ageLine}.`);
997
+ }
998
+ else {
999
+ this.appendSystemLine('Tier 1 working set: not wired.');
1000
+ }
1001
+ this.appendSystemLine('Tier 2 RAG: deferred to α6.5b (Anvil-side per-tenant workspace).');
1002
+ }
1003
+ /**
1004
+ * α6.5 chokidar batch handler. Forwards each event to the working
1005
+ * set tracker (so `unlink` evicts and `add`/`change` bump the
1006
+ * recency) and emits at most one throttled system line per
1007
+ * `FILEWATCH_SYSTEM_LINE_GAP_MS` window.
1008
+ *
1009
+ * The transcript surface intentionally shows ONE filename + the
1010
+ * count of additional changes (`file changed: src/foo.ts (+3 more)`).
1011
+ * The full event list is preserved in the buffer for future
1012
+ * `/context --files` deep-dive (not in α6.5 Phase 1).
1013
+ */
1014
+ recordFilewatchBatch(batch) {
1015
+ // Hard-guard against post-close invocation. close() detaches the
1016
+ // watcher listeners, but the EventEmitter contract allows the
1017
+ // currently-dispatching emit() call to finish delivering to every
1018
+ // listener captured at the start of emit(). If the session closes
1019
+ // mid-emit, the handler can still fire on a dead session. Returning
1020
+ // early keeps the working set + transcript untouched.
1021
+ // triple-review P1 (PR #380).
1022
+ if (this.closed)
1023
+ return;
1024
+ if (this.workingSet) {
1025
+ for (const event of batch.events) {
1026
+ if (event.kind === 'unlink') {
1027
+ this.workingSet.forget(event.absPath);
1028
+ }
1029
+ // Note: we do NOT auto-track add/change here. The working set
1030
+ // reflects files the AGENT touched - filewatch is informational.
1031
+ // Future wiring: bash/read/edit tools will call track() directly.
1032
+ }
1033
+ }
1034
+ const nowMs = this.now();
1035
+ const sinceLast = nowMs - this.lastFilewatchLineAtEpochMs;
1036
+ if (sinceLast < FILEWATCH_SYSTEM_LINE_GAP_MS && this.lastFilewatchLineAtEpochMs !== 0) {
1037
+ // Inside the throttle window - buffer for future deep-dive but
1038
+ // do not emit a system line. Cap the buffer at
1039
+ // PENDING_FILEWATCH_BATCH_CAP and drop the oldest on overflow so
1040
+ // a noisy filewatch source cannot drive unbounded memory growth
1041
+ // across a long REPL session. triple-review P1 (PR #380).
1042
+ if (this.pendingFilewatchBatches.length >= PENDING_FILEWATCH_BATCH_CAP) {
1043
+ this.pendingFilewatchBatches.shift();
1044
+ if (!this.pendingFilewatchOverflowWarned) {
1045
+ this.pendingFilewatchOverflowWarned = true;
1046
+ this.appendSystemLine(`Filewatch buffer at cap (${PENDING_FILEWATCH_BATCH_CAP} batches) - shedding oldest. Source may be a build watcher in a tight loop.`);
1047
+ }
1048
+ }
1049
+ this.pendingFilewatchBatches.push(batch);
1050
+ return;
1051
+ }
1052
+ const totalEvents = this.pendingFilewatchBatches.reduce((acc, b) => acc + b.events.length, 0) + batch.events.length;
1053
+ const head = batch.events[0];
1054
+ if (!head) {
1055
+ // Empty batch - should not happen given the watcher guards,
1056
+ // but defensive.
1057
+ this.pendingFilewatchBatches = [];
1058
+ return;
1059
+ }
1060
+ const wsLine = this.workingSet
1061
+ ? ` (working set: ${this.workingSet.size()}/${this.workingSet.capacityLimit()})`
1062
+ : '';
1063
+ const tail = totalEvents > 1 ? ` (+${totalEvents - 1} more)` : '';
1064
+ this.appendSystemLine(`file ${head.kind}: ${head.path}${tail}${wsLine}`);
1065
+ this.lastFilewatchLineAtEpochMs = nowMs;
1066
+ this.pendingFilewatchBatches = [];
1067
+ }
1068
+ /**
1069
+ * α6.5 chokidar cap-exceeded handler. The watcher closes itself
1070
+ * when it crosses the watched-paths cap; the session surfaces a
1071
+ * single system line so the operator knows live updates are off.
1072
+ * The conversation stays usable - we just lose the file-changed
1073
+ * badge for the rest of the session.
1074
+ */
1075
+ recordFilewatchCapExceeded(info) {
1076
+ // Same post-close guard as recordFilewatchBatch. triple-review P1 (PR #380).
1077
+ if (this.closed)
1078
+ return;
1079
+ this.appendSystemLine(`Filewatch off: ${info.watchedCount} watched paths exceeded cap (${info.cap}). Falling back to manual stat-on-read.`);
1080
+ }
263
1081
  /**
264
1082
  * Fetch one URL via the web_fetch tool and inject the resulting
265
1083
  * Markdown into the transcript as an operator-attributed brief. The
@@ -319,6 +1137,92 @@ export class ReplSession {
319
1137
  }
320
1138
  this.appendOperatorLine(brief);
321
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
+ }
322
1226
  try {
323
1227
  await this.options.transport.postBrief({
324
1228
  apiUrl: this.options.apiUrl,
@@ -329,7 +1233,78 @@ export class ReplSession {
329
1233
  }
330
1234
  catch (error) {
331
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');
1240
+ }
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;
332
1306
  }
1307
+ this.currentDispatchToken = null;
333
1308
  }
334
1309
  async dispatchStop(persona) {
335
1310
  const sessionId = this.state.sessionId;
@@ -377,12 +1352,118 @@ export class ReplSession {
377
1352
  onError: (error) => {
378
1353
  if (this.closed)
379
1354
  return;
1355
+ // α6.14.2 wave 5: when admin-api restarts it drops the in-memory
1356
+ // session store, so subscribe returns HTTP 404 forever on the
1357
+ // saved sessionId. Detect that case and mint a fresh server
1358
+ // session silently rather than spamming the operator with
1359
+ // "Stream interrupted (HTTP 404)" reconnect lines. Capped to
1360
+ // MAX_SESSION_RECREATE_ATTEMPTS inside SESSION_RECREATE_WINDOW_MS
1361
+ // so a permanently down admin-api fails loud.
1362
+ //
1363
+ // Race guard (triple-review P2 follow-up): the SSE transport can
1364
+ // fire onError synchronously a second time while we are tearing
1365
+ // down the dead stream inside recreateSessionSilently (the
1366
+ // streamHandle.close() call there can flush a pending error
1367
+ // synchronously in some transports). If that second 404 arrives
1368
+ // with recreatingSession === true, we must SHORT-CIRCUIT it too
1369
+ // rather than fall through to the legacy "Stream interrupted"
1370
+ // path - otherwise the operator sees the exact 404 line the
1371
+ // recreate is trying to suppress.
1372
+ if (this.isSessionNotFoundError(error)) {
1373
+ if (this.recreatingSession) {
1374
+ // Recreate already in flight - drop the duplicate 404 on the
1375
+ // floor. The first recreate will either succeed (new stream
1376
+ // opens, this dead handle is gone) or fall through to the
1377
+ // loud "keeps dropping" / "session recreate refused" paths
1378
+ // already defined in recreateSessionSilently.
1379
+ return;
1380
+ }
1381
+ this.patch({ connection: 'reconnecting' });
1382
+ void this.recreateSessionSilently();
1383
+ return;
1384
+ }
380
1385
  this.patch({ connection: 'reconnecting' });
381
1386
  this.appendSystemLine(`Stream interrupted (${this.errorMessage(error)}). Reconnecting.`);
382
1387
  this.scheduleReconnect();
383
1388
  },
384
1389
  });
385
1390
  }
1391
+ /**
1392
+ * Detect "session not found" from the SSE transport. The production
1393
+ * transport in `repl-render.tsx` wraps non-2xx responses as
1394
+ * `Error("HTTP 404 on SSE stream")`. We pattern-match on the status
1395
+ * 404 so a different transport (e.g. a test fake or a future polling
1396
+ * fallback) can surface the same intent with the same shape.
1397
+ * (α6.14.2 wave 5.)
1398
+ */
1399
+ isSessionNotFoundError(error) {
1400
+ const msg = this.errorMessage(error);
1401
+ return /\b404\b/.test(msg);
1402
+ }
1403
+ /**
1404
+ * Mint a fresh server-side session, swap the consumer to the new
1405
+ * stream URL, keep the conversation flowing. Caps at
1406
+ * MAX_SESSION_RECREATE_ATTEMPTS inside SESSION_RECREATE_WINDOW_MS so
1407
+ * a permanently down admin-api fails loud after a few seconds of
1408
+ * trying. Logged once per attempt at debug level (we surface a
1409
+ * single visible line on first auto-recreate so the operator knows
1410
+ * what happened, then stay quiet). (α6.14.2 wave 5.)
1411
+ */
1412
+ async recreateSessionSilently() {
1413
+ if (this.closed)
1414
+ return;
1415
+ if (this.recreatingSession)
1416
+ return;
1417
+ const nowMs = this.now();
1418
+ // Drop stale window entries so the cap is rolling, not cumulative.
1419
+ while (this.recentRecreateAtMs.length > 0
1420
+ && nowMs - (this.recentRecreateAtMs[0] ?? 0) > SESSION_RECREATE_WINDOW_MS) {
1421
+ this.recentRecreateAtMs.shift();
1422
+ }
1423
+ if (this.recentRecreateAtMs.length >= MAX_SESSION_RECREATE_ATTEMPTS) {
1424
+ // Cap exceeded - fall back to the loud "give up" path so the
1425
+ // operator sees something is actually wrong.
1426
+ this.appendSystemLine('Admin API session keeps dropping (HTTP 404 x3). Type /quit and `pugi resume` to retry.');
1427
+ this.patch({ connection: 'offline' });
1428
+ return;
1429
+ }
1430
+ this.recreatingSession = true;
1431
+ this.recentRecreateAtMs.push(nowMs);
1432
+ // Tear down the dead SSE handle so the next openStream() does not
1433
+ // close-over the stale sessionId.
1434
+ if (this.streamHandle) {
1435
+ this.streamHandle.close();
1436
+ this.streamHandle = undefined;
1437
+ }
1438
+ // Reset reconnect attempt + lastEventId - the new session is a
1439
+ // fresh stream, not a continuation of the dead one.
1440
+ this.reconnectAttempt = 0;
1441
+ this.lastEventId = undefined;
1442
+ // Single visible line on the FIRST auto-recreate of the window so
1443
+ // the operator knows the CLI is recovering; later recreates in
1444
+ // the same window stay silent.
1445
+ if (this.recentRecreateAtMs.length === 1) {
1446
+ this.appendSystemLine('Admin API restarted - minting a fresh session.');
1447
+ }
1448
+ try {
1449
+ const { sessionId } = await this.options.transport.createSession({
1450
+ apiUrl: this.options.apiUrl,
1451
+ apiKey: this.options.apiKey,
1452
+ workspace: this.options.workspace,
1453
+ });
1454
+ this.patch({ sessionId, connection: 'connecting' });
1455
+ this.openStream();
1456
+ }
1457
+ catch (error) {
1458
+ // The recreate POST itself failed - fall back to the existing
1459
+ // backoff reconnect so the operator still sees retry progress.
1460
+ this.appendSystemLine(`Session recreate refused (${this.errorMessage(error)}). Reconnecting.`);
1461
+ this.scheduleReconnect();
1462
+ }
1463
+ finally {
1464
+ this.recreatingSession = false;
1465
+ }
1466
+ }
386
1467
  scheduleReconnect() {
387
1468
  if (this.closed)
388
1469
  return;
@@ -400,9 +1481,48 @@ export class ReplSession {
400
1481
  }
401
1482
  /* ------------- event reducer -------------- */
402
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
+ }
403
1509
  switch (event.type) {
404
1510
  case 'agent.spawned': {
405
1511
  const persona = safePersonaName(event.role);
1512
+ // Wave 4 fix 2026-05-25: the roster collapses to one row per
1513
+ // persona slug. The α5.7 reducer pushed a fresh row on every
1514
+ // spawn, so after three turns the bottom panel stacked
1515
+ // "Pugi orchestrator shipped" three times. The new contract:
1516
+ // - If a row already exists for this personaSlug, REUSE it.
1517
+ // Replace its taskId, reset status to 'queued', clear the
1518
+ // detail line, restart the duration clock, zero the token
1519
+ // counters. The persona name + slug + role stay stable
1520
+ // (they are the row identity).
1521
+ // - If no row exists yet, push a new one.
1522
+ // Per-task lifecycle (step/tokens/completed/blocked/failed) is
1523
+ // keyed off `taskId` everywhere, so the reused row still folds
1524
+ // the latest task's events correctly.
1525
+ const existing = this.state.agents.find((a) => a.personaSlug === event.personaSlug);
406
1526
  const node = {
407
1527
  taskId: event.taskId,
408
1528
  role: event.role,
@@ -414,27 +1534,72 @@ export class ReplSession {
414
1534
  tokensIn: 0,
415
1535
  tokensOut: 0,
416
1536
  };
417
- this.patch({ agents: [node, ...this.state.agents] });
1537
+ if (existing) {
1538
+ this.patch({
1539
+ agents: this.state.agents.map((a) => a.personaSlug === event.personaSlug ? node : a),
1540
+ });
1541
+ }
1542
+ else {
1543
+ this.patch({ agents: [node, ...this.state.agents] });
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);
418
1551
  // The conversation pane already prefixes persona rows with the
419
1552
  // persona name in the persona's hue colour. Skip embedding the
420
1553
  // name in the body text to avoid the `Marcus Marcus dispatched`
421
1554
  // double-print. `void persona` keeps the resolved name in scope
422
1555
  // for the agent tree node above without leaking it into the
423
1556
  // transcript body.
1557
+ // α6.14.3 CEO dogfood 2026-05-25: drop the "dispatched (X)"
1558
+ // transcript echo. The agent tree pane already shows the
1559
+ // spawned state; printing it as a persona row is pure noise
1560
+ // between the operator's brief and the persona's real reply.
424
1561
  void persona;
425
- this.appendPersonaLine(event.personaSlug, `dispatched (${event.role}).`);
426
1562
  return;
427
1563
  }
428
1564
  case 'agent.step': {
1565
+ // α6.3 office-hours: scan the running buffer for `<pugi-ask>` /
1566
+ // `<pugi-plan-review>` envelopes BEFORE we cache the detail.
1567
+ // The parser returns the cleaned remainder with the raw XML
1568
+ // stripped, so the operator never sees the envelope as prose.
1569
+ // Streaming partial tags (open seen, close not yet streamed)
1570
+ // are kept in the buffer; the next step event extends it.
1571
+ const sanitised = this.consumeAskAndPlanReviewTags(event.taskId, event.detail);
429
1572
  // Cache the running detail per task so we can surface the
430
1573
  // model's actual reply when agent.completed lands (otherwise
431
1574
  // the reply disappears under the literal 'shipped' patch).
432
- if (event.detail && event.detail.trim().length > 0) {
433
- this.lastStepDetail.set(event.taskId, event.detail);
1575
+ if (sanitised && sanitised.trim().length > 0) {
1576
+ this.lastStepDetail.set(event.taskId, sanitised);
1577
+ }
1578
+ // α6.12: synthesise a tool call entry when the step detail
1579
+ // matches a tool-invocation grammar. The pattern is generous
1580
+ // (Read(path) / Edit(path:lines) / Bash(cmd) / Grep(pat) /
1581
+ // Glob(pat) / WebFetch(url)) so the pane has rows to render
1582
+ // before the admin-api side ships the proper tool.* SSE events.
1583
+ // Use the sanitised detail (post-tag-strip) so a `<pugi-ask>`
1584
+ // envelope never produces a phantom tool row.
1585
+ const synthesised = synthesiseToolCall({
1586
+ taskId: event.taskId,
1587
+ detail: sanitised,
1588
+ agent: this.personaSlugForTask(event.taskId),
1589
+ now: this.now(),
1590
+ });
1591
+ if (synthesised) {
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);
434
1599
  }
435
1600
  this.patch({
436
1601
  agents: this.state.agents.map((a) => a.taskId === event.taskId
437
- ? { ...a, status: 'thinking', detail: event.detail }
1602
+ ? { ...a, status: 'thinking', detail: sanitised || event.detail }
438
1603
  : a),
439
1604
  });
440
1605
  return;
@@ -457,11 +1622,22 @@ export class ReplSession {
457
1622
  const target = this.state.agents.find((a) => a.taskId === event.taskId);
458
1623
  const finalDetail = this.lastStepDetail.get(event.taskId);
459
1624
  this.lastStepDetail.delete(event.taskId);
1625
+ if (this.askBufferPending.has(event.taskId)) {
1626
+ this.appendSystemLine('Persona emitted incomplete <pugi-ask> or <pugi-plan-review> tag; dropped.');
1627
+ }
1628
+ this.askBuffer.delete(event.taskId);
1629
+ this.askBufferPending.delete(event.taskId);
460
1630
  this.patch({
461
1631
  agents: this.state.agents.map((a) => a.taskId === event.taskId
462
1632
  ? { ...a, status: 'shipped', detail: 'shipped' }
463
1633
  : a),
464
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);
465
1641
  if (target) {
466
1642
  // If the persona actually produced a reply via incremental
467
1643
  // agent.step events, render that reply in the transcript so
@@ -474,15 +1650,34 @@ export class ReplSession {
474
1650
  if (finalDetail
475
1651
  && finalDetail !== 'queued for dispatch'
476
1652
  && finalDetail.trim().length > 4) {
477
- for (const line of finalDetail.split('\n')) {
478
- const trimmed = line.trim();
479
- if (trimmed.length > 0) {
480
- this.appendPersonaLine(target.personaSlug, trimmed);
1653
+ // α6.12: ship the WHOLE body as one transcript row when the
1654
+ // reply contains ANY Markdown structure (code fence, bullet
1655
+ // list, numbered list, headings). The conversation pane
1656
+ // routes it through Markdown renderer в one pass, preserving
1657
+ // grouped bullets + heading hierarchy. Plain prose still
1658
+ // splits per line so word-wrap stays correct.
1659
+ //
1660
+ // Claude triple-review P1 (PR #369): the prior `includes('```')`
1661
+ // gate only caught fences - multi-line bullets fragmented
1662
+ // per row showed as `▸ Mira • read PUGI.md / ▸ Mira • patched
1663
+ // bug / ...` instead of a single grouped bullet block.
1664
+ if (looksLikeMarkdown(finalDetail)) {
1665
+ this.appendPersonaLine(target.personaSlug, finalDetail);
1666
+ }
1667
+ else {
1668
+ for (const line of finalDetail.split('\n')) {
1669
+ const trimmed = line.trim();
1670
+ if (trimmed.length > 0) {
1671
+ this.appendPersonaLine(target.personaSlug, trimmed);
1672
+ }
481
1673
  }
482
1674
  }
483
1675
  }
484
1676
  else {
485
- this.appendPersonaLine(target.personaSlug, 'shipped.');
1677
+ // α6.14.3 CEO dogfood 2026-05-25: drop the literal
1678
+ // "shipped." fallback row. If we have no cached detail to
1679
+ // surface, stay silent. The agent tree pane already shows
1680
+ // the green check + duration.
486
1681
  }
487
1682
  }
488
1683
  return;
@@ -490,6 +1685,11 @@ export class ReplSession {
490
1685
  case 'agent.blocked': {
491
1686
  const target = this.state.agents.find((a) => a.taskId === event.taskId);
492
1687
  this.lastStepDetail.delete(event.taskId);
1688
+ if (this.askBufferPending.has(event.taskId)) {
1689
+ this.appendSystemLine('Persona emitted incomplete <pugi-ask> or <pugi-plan-review> tag; dropped.');
1690
+ }
1691
+ this.askBuffer.delete(event.taskId);
1692
+ this.askBufferPending.delete(event.taskId);
493
1693
  this.patch({
494
1694
  agents: this.state.agents.map((a) => a.taskId === event.taskId
495
1695
  ? { ...a, status: 'blocked', detail: event.detail }
@@ -498,11 +1698,21 @@ export class ReplSession {
498
1698
  if (target) {
499
1699
  this.appendPersonaLine(target.personaSlug, `blocked: ${event.detail}`);
500
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);
501
1706
  return;
502
1707
  }
503
1708
  case 'agent.failed': {
504
1709
  const target = this.state.agents.find((a) => a.taskId === event.taskId);
505
1710
  this.lastStepDetail.delete(event.taskId);
1711
+ if (this.askBufferPending.has(event.taskId)) {
1712
+ this.appendSystemLine('Persona emitted incomplete <pugi-ask> or <pugi-plan-review> tag; dropped.');
1713
+ }
1714
+ this.askBuffer.delete(event.taskId);
1715
+ this.askBufferPending.delete(event.taskId);
506
1716
  this.patch({
507
1717
  agents: this.state.agents.map((a) => a.taskId === event.taskId
508
1718
  ? { ...a, status: 'failed', detail: event.error }
@@ -511,11 +1721,121 @@ export class ReplSession {
511
1721
  if (target) {
512
1722
  this.appendPersonaLine(target.personaSlug, `failed: ${event.error}`);
513
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);
514
1729
  return;
515
1730
  }
516
1731
  }
517
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
+ }
518
1805
  /* ------------- transcript helpers -------------- */
1806
+ /**
1807
+ * Look up the persona slug for a running task. Used by the tool call
1808
+ * synthesiser to colour the tool stream rows correctly. Falls back to
1809
+ * the literal `unknown` slug when the task is not in the agent tree
1810
+ * (the SSE wire can emit a step before the matching spawn under heavy
1811
+ * load - rare in practice but the pane stays robust).
1812
+ */
1813
+ personaSlugForTask(taskId) {
1814
+ const agent = this.state.agents.find((a) => a.taskId === taskId);
1815
+ return agent?.personaSlug ?? 'unknown';
1816
+ }
1817
+ /**
1818
+ * Fold a tool call entry into the rolling list. If the entry id
1819
+ * already exists, replace it in-place (so a synthesised `running` →
1820
+ * `ok` transition reuses the same row). Otherwise append. The list
1821
+ * is capped at `MAX_TOOL_CALLS` so a long-running session does not
1822
+ * leak memory.
1823
+ */
1824
+ appendToolCall(entry) {
1825
+ const existingIndex = this.state.toolCalls.findIndex((c) => c.id === entry.id);
1826
+ let next;
1827
+ if (existingIndex >= 0) {
1828
+ next = this.state.toolCalls.slice();
1829
+ next[existingIndex] = entry;
1830
+ }
1831
+ else {
1832
+ next = this.state.toolCalls.concat(entry);
1833
+ }
1834
+ if (next.length > MAX_TOOL_CALLS) {
1835
+ next = next.slice(-MAX_TOOL_CALLS);
1836
+ }
1837
+ this.patch({ toolCalls: next });
1838
+ }
519
1839
  appendOperatorLine(text) {
520
1840
  this.appendRow({ source: 'operator', text });
521
1841
  }
@@ -523,7 +1843,19 @@ export class ReplSession {
523
1843
  this.appendRow({ source: 'system', text });
524
1844
  }
525
1845
  appendPersonaLine(personaSlug, text) {
526
- this.appendRow({ source: 'persona', text, personaSlug });
1846
+ // α6.14.2 wave 5: dedup the persona display-name prefix. The
1847
+ // conversation pane already renders `▸ <DisplayName> <text>` from
1848
+ // the slug → name map; when the model's own reply begins with
1849
+ // the same display name (CEO 2026-05-25 screenshot: "Pugi Pugi,
1850
+ // координатор Pugi"), the operator sees the name twice. Strip
1851
+ // the leading display-name token (with optional trailing comma /
1852
+ // colon / whitespace) so the prefix the pane adds is the only one
1853
+ // visible. We also drop any leaked `<workspace-context-NONCE>`
1854
+ // wrapper the model sometimes echoes back at the head of its
1855
+ // first turn - that envelope is for prompt scaffolding, not for
1856
+ // the operator's eyes.
1857
+ const stripped = stripPersonaPrefixEcho(personaSlug, text);
1858
+ this.appendRow({ source: 'persona', text: stripped, personaSlug });
527
1859
  }
528
1860
  appendRow(input) {
529
1861
  if (input.text.length === 0)
@@ -537,6 +1869,161 @@ export class ReplSession {
537
1869
  };
538
1870
  const next = this.state.transcript.concat(row).slice(-MAX_TRANSCRIPT_ROWS);
539
1871
  this.patch({ transcript: next });
1872
+ // Mirror into the local SessionStore so `/resume` can replay.
1873
+ // Persistence is fail-safe: a single error becomes one system
1874
+ // line, subsequent errors are silent so a stuck disk does not
1875
+ // flood the operator. The mapping from row.source -> store kind:
1876
+ // operator -> 'user' (drives turn_count + title)
1877
+ // persona -> 'persona'
1878
+ // system -> 'system'
1879
+ this.persistRow(row);
1880
+ }
1881
+ /**
1882
+ * Best-effort write of one transcript row into the local
1883
+ * SessionStore. Swallows errors after emitting one system line so a
1884
+ * broken store never blocks the conversation. Public callers go
1885
+ * through `appendRow` - this method is private on purpose.
1886
+ */
1887
+ persistRow(row) {
1888
+ if (!this.store)
1889
+ return;
1890
+ const kind = row.source === 'operator' ? 'user'
1891
+ : row.source === 'persona' ? 'persona'
1892
+ : 'system';
1893
+ const payload = row.source === 'persona'
1894
+ ? { text: row.text, personaSlug: row.personaSlug }
1895
+ : row.source === 'operator'
1896
+ ? { brief: row.text }
1897
+ : { text: row.text };
1898
+ const event = { t: row.timestampEpochMs, kind, payload };
1899
+ void this.store.appendEvent(event).catch((error) => {
1900
+ if (this.storeErrorEmitted)
1901
+ return;
1902
+ this.storeErrorEmitted = true;
1903
+ const msg = error instanceof Error ? error.message : String(error);
1904
+ // Use appendRow directly via state patch so we don't recurse
1905
+ // into persistRow (which would loop on a stuck store).
1906
+ const errRow = {
1907
+ id: randomUUID(),
1908
+ source: 'system',
1909
+ text: `Local session persistence failed: ${msg}. Conversation continues in-memory only.`,
1910
+ timestampEpochMs: this.now(),
1911
+ };
1912
+ const next = this.state.transcript.concat(errRow).slice(-MAX_TRANSCRIPT_ROWS);
1913
+ this.patch({ transcript: next });
1914
+ });
1915
+ }
1916
+ /**
1917
+ * Restore a transcript from a stored event log - α6.4. Called by
1918
+ * the CLI bootstrap when the operator runs `pugi resume <id>` or
1919
+ * picks an entry from the `/resume` picker. Replays each event into
1920
+ * the local transcript WITHOUT writing back to the store so the
1921
+ * restore is idempotent.
1922
+ *
1923
+ * Implementation note: we briefly disable persistence by setting
1924
+ * `storeErrorEmitted` BEFORE the replay and clearing it after - but
1925
+ * the cleaner path is to bypass `appendRow` entirely and patch
1926
+ * state directly. We do the latter so persistRow does not double-
1927
+ * write the restored events.
1928
+ */
1929
+ restoreTranscript(events) {
1930
+ const rows = [];
1931
+ for (const event of events) {
1932
+ const row = eventToTranscriptRow(event);
1933
+ if (row)
1934
+ rows.push(row);
1935
+ }
1936
+ // Cap at MAX_TRANSCRIPT_ROWS - the same cap appendRow uses so the
1937
+ // window math stays consistent post-restore.
1938
+ const capped = rows.slice(-MAX_TRANSCRIPT_ROWS);
1939
+ this.patch({ transcript: capped });
1940
+ }
1941
+ /**
1942
+ * Local session id used as the persistence key. Surfaced to the
1943
+ * CLI bootstrap so `pugi resume` listings can pin the id without
1944
+ * pulling it out of internal state.
1945
+ */
1946
+ getLocalSessionId() {
1947
+ return this.localSessionId;
1948
+ }
1949
+ /* ------------- α6.3 buffered tag detection -------------- */
1950
+ /**
1951
+ * Scan the running `agent.step.detail` buffer for `<pugi-ask>` /
1952
+ * `<pugi-plan-review>` envelopes. If a complete envelope is found,
1953
+ * the parser strips it from the visible body and sets the matching
1954
+ * `pendingAsk` / `pendingPlanReview` state so the REPL can render
1955
+ * the modal. Streaming partial tags (open observed, close not yet
1956
+ * arrived) are kept in the buffer so the next step event can extend
1957
+ * them.
1958
+ *
1959
+ * Returns the sanitised body the caller should treat as the
1960
+ * persona's prose. May be empty when the entire body was tag XML;
1961
+ * the caller then leaves `lastStepDetail` untouched and the
1962
+ * `agent.completed` fallback ("shipped.") fires.
1963
+ */
1964
+ consumeAskAndPlanReviewTags(taskId, detail) {
1965
+ if (!detail || detail.length === 0) {
1966
+ return this.askBuffer.get(taskId) ?? '';
1967
+ }
1968
+ // The persona emits the running detail as a cumulative string, so
1969
+ // a fresh `agent.step` carries the full body up to the current
1970
+ // token (matches the wave-2 caching contract above). We pass the
1971
+ // raw detail through the extractors directly rather than keeping a
1972
+ // separate buffer - but we still record the pre-extraction body so
1973
+ // a partial open tag is preserved when the next chunk arrives.
1974
+ this.askBuffer.set(taskId, detail);
1975
+ const askResult = extractAskTags(detail);
1976
+ let working = askResult.cleaned;
1977
+ for (const tag of askResult.tags) {
1978
+ if (this.seenTagSignatures.includes(tag.signature))
1979
+ continue;
1980
+ this.recordSeenTag(tag.signature);
1981
+ // Only one pending ask at a time - drop additional tags in the
1982
+ // same step into the cleaned body as a system warning. The
1983
+ // persona's prompt forbids concurrent asks, so this branch is a
1984
+ // defensive guard against a misbehaving model.
1985
+ if (this.state.pendingAsk) {
1986
+ this.appendSystemLine('Persona emitted a second <pugi-ask> while one was already open. Dropped.');
1987
+ continue;
1988
+ }
1989
+ this.patch({ pendingAsk: tag, pendingAskSource: 'persona' });
1990
+ }
1991
+ if (askResult.hadMalformedTag) {
1992
+ this.appendSystemLine('Malformed <pugi-ask> dropped (parser refusal).');
1993
+ }
1994
+ const planResult = extractPlanReviewTags(working);
1995
+ working = planResult.cleaned;
1996
+ for (const tag of planResult.tags) {
1997
+ if (this.seenTagSignatures.includes(tag.signature))
1998
+ continue;
1999
+ this.recordSeenTag(tag.signature);
2000
+ if (this.state.pendingPlanReview) {
2001
+ this.appendSystemLine('Persona emitted a second <pugi-plan-review> while one was already open. Dropped.');
2002
+ continue;
2003
+ }
2004
+ this.patch({ pendingPlanReview: tag, pendingPlanReviewSource: 'persona' });
2005
+ }
2006
+ if (planResult.hadMalformedTag) {
2007
+ this.appendSystemLine('Malformed <pugi-plan-review> dropped (parser refusal).');
2008
+ }
2009
+ // Record / clear the "pending open tag" flag so agent.completed can
2010
+ // emit a warning if the persona ends the turn with an unfinished
2011
+ // envelope. The flag flips OFF when both parsers report no
2012
+ // outstanding open tag - if either is still pending, we keep it on
2013
+ // so the warning fires once at turn end.
2014
+ if (askResult.pendingOpenTag || planResult.pendingOpenTag) {
2015
+ this.askBufferPending.add(taskId);
2016
+ }
2017
+ else {
2018
+ this.askBufferPending.delete(taskId);
2019
+ }
2020
+ return working;
2021
+ }
2022
+ recordSeenTag(signature) {
2023
+ this.seenTagSignatures.push(signature);
2024
+ while (this.seenTagSignatures.length > 32) {
2025
+ this.seenTagSignatures.shift();
2026
+ }
540
2027
  }
541
2028
  /* ------------- agent count + clock -------------- */
542
2029
  activeAgentCount() {
@@ -573,6 +2060,89 @@ export class ReplSession {
573
2060
  * does not recognise, the REPL still renders something usable rather
574
2061
  * than crashing mid-frame.
575
2062
  */
2063
+ /**
2064
+ * Map a stored SessionEvent back into a TranscriptRow for `/resume`
2065
+ * replay. Returns null when the event has no operator-visible body
2066
+ * (e.g. tool.start without a text payload - those land back as
2067
+ * tool stream rows, not transcript rows). The shape mirrors the
2068
+ * `persistRow` mapping in reverse:
2069
+ *
2070
+ * 'user' -> operator (brief)
2071
+ * 'persona' -> persona (text + personaSlug)
2072
+ * 'system' -> system (text)
2073
+ *
2074
+ * Exported indirectly via `restoreTranscript`.
2075
+ */
2076
+ function eventToTranscriptRow(event) {
2077
+ const payload = (event.payload ?? null);
2078
+ if (event.kind === 'user') {
2079
+ const text = typeof payload?.brief === 'string'
2080
+ ? payload.brief
2081
+ : typeof payload?.text === 'string'
2082
+ ? payload.text
2083
+ : '';
2084
+ if (text.length === 0)
2085
+ return null;
2086
+ return {
2087
+ id: randomUUID(),
2088
+ source: 'operator',
2089
+ text,
2090
+ timestampEpochMs: event.t,
2091
+ };
2092
+ }
2093
+ if (event.kind === 'persona') {
2094
+ const text = typeof payload?.text === 'string' ? payload.text : '';
2095
+ if (text.length === 0)
2096
+ return null;
2097
+ const personaSlug = typeof payload?.personaSlug === 'string'
2098
+ ? payload.personaSlug
2099
+ : undefined;
2100
+ return {
2101
+ id: randomUUID(),
2102
+ source: 'persona',
2103
+ text,
2104
+ personaSlug,
2105
+ timestampEpochMs: event.t,
2106
+ };
2107
+ }
2108
+ if (event.kind === 'system') {
2109
+ const text = typeof payload?.text === 'string' ? payload.text : '';
2110
+ if (text.length === 0)
2111
+ return null;
2112
+ return {
2113
+ id: randomUUID(),
2114
+ source: 'system',
2115
+ text,
2116
+ timestampEpochMs: event.t,
2117
+ };
2118
+ }
2119
+ return null;
2120
+ }
2121
+ /**
2122
+ * Heuristic: does this text contain Markdown structures that benefit
2123
+ * from atomic grouping? Code fences, bullet lists, numbered lists,
2124
+ * headings - anything where per-line splitting would fragment visual
2125
+ * grouping (Claude triple-review P1 PR #369).
2126
+ */
2127
+ function looksLikeMarkdown(text) {
2128
+ if (text.includes('```'))
2129
+ return true;
2130
+ const lines = text.split('\n');
2131
+ let bulletCount = 0;
2132
+ let numberedCount = 0;
2133
+ let headingCount = 0;
2134
+ for (const raw of lines) {
2135
+ const line = raw.trim();
2136
+ if (/^[-*+]\s+\S/.test(line))
2137
+ bulletCount += 1;
2138
+ if (/^\d+\.\s+\S/.test(line))
2139
+ numberedCount += 1;
2140
+ if (/^#{1,6}\s+\S/.test(line))
2141
+ headingCount += 1;
2142
+ }
2143
+ // 2+ bullets OR 2+ numbered OR any heading = group atomically.
2144
+ return bulletCount >= 2 || numberedCount >= 2 || headingCount >= 1;
2145
+ }
576
2146
  function safePersonaName(role) {
577
2147
  try {
578
2148
  return getPersonaForRole(role).name;
@@ -581,6 +2151,31 @@ function safePersonaName(role) {
581
2151
  return role;
582
2152
  }
583
2153
  }
2154
+ /**
2155
+ * Render a millisecond delta as a compact human-readable age. Used by
2156
+ * `/context` to surface the oldest working-set entry's age:
2157
+ *
2158
+ * < 60s -> `45s`
2159
+ * < 1h -> `4m`
2160
+ * < 24h -> `2h`
2161
+ * >= 24h -> `3d`
2162
+ *
2163
+ * Negative deltas (clock skew) clamp to `0s`.
2164
+ */
2165
+ function formatAgeSeconds(deltaMs) {
2166
+ const ms = Math.max(0, deltaMs);
2167
+ const seconds = Math.floor(ms / 1000);
2168
+ if (seconds < 60)
2169
+ return `${seconds}s`;
2170
+ const minutes = Math.floor(seconds / 60);
2171
+ if (minutes < 60)
2172
+ return `${minutes}m`;
2173
+ const hours = Math.floor(minutes / 60);
2174
+ if (hours < 24)
2175
+ return `${hours}h`;
2176
+ const days = Math.floor(hours / 24);
2177
+ return `${days}d`;
2178
+ }
584
2179
  /**
585
2180
  * Convenience: list the legal role slugs the operator can target with
586
2181
  * `/stop`. Surfaced in the slash command help overlay and in the
@@ -589,4 +2184,305 @@ function safePersonaName(role) {
589
2184
  export function knownRoles() {
590
2185
  return listRoles();
591
2186
  }
2187
+ /* ------------------------------------------------------------------ */
2188
+ /* Tool call synthesiser - α6.12 */
2189
+ /* ------------------------------------------------------------------ */
2190
+ /**
2191
+ * Match canonical tool invocation grammar in an `agent.step.detail`
2192
+ * string and emit a synthesised `ToolCallEntry`. Returns null when no
2193
+ * known tool pattern matches.
2194
+ *
2195
+ * The grammar mirrors the way Claude Code, Codex CLI, and Gemini CLI
2196
+ * display tool calls in their tool stream panes:
2197
+ *
2198
+ * Read(path)
2199
+ * Edit(path[:lines])
2200
+ * Bash(command)
2201
+ * Grep("pattern" [in path])
2202
+ * Glob(pattern)
2203
+ * WebFetch(url)
2204
+ *
2205
+ * The matcher is case-insensitive on the tool name so a persona that
2206
+ * spells the tool as `READ(...)` or `web_fetch(...)` still lands in
2207
+ * the pane. Args are capped at 80 characters; the pane will further
2208
+ * truncate to 60 on render so the row stays single-line on a narrow
2209
+ * terminal.
2210
+ *
2211
+ * Exported for unit testing - production code path is internal.
2212
+ */
2213
+ export function synthesiseToolCall(input) {
2214
+ const detail = input.detail.trim();
2215
+ if (detail.length === 0)
2216
+ return null;
2217
+ // Pattern: ToolName(args) optionally suffixed with a result hint.
2218
+ // We allow the canonical Claude Code casing AND the snake_case
2219
+ // alias `web_fetch` so the synthesiser matches what personas write.
2220
+ const match = /^(Read|Edit|Bash|Grep|Glob|WebFetch|web_fetch)\s*\(\s*([^)]*)\s*\)\s*(.*)$/i
2221
+ .exec(detail);
2222
+ if (!match)
2223
+ return null;
2224
+ const toolName = normaliseToolName(match[1]);
2225
+ const args = (match[2] ?? '').trim().slice(0, 80);
2226
+ const tail = (match[3] ?? '').trim();
2227
+ const status = parseStatusFromTail(tail);
2228
+ return {
2229
+ id: `${input.taskId}:${toolName}:${args}`,
2230
+ agent: input.agent,
2231
+ tool: toolName,
2232
+ args,
2233
+ status: status.status,
2234
+ detail: status.detail,
2235
+ startedAtEpochMs: input.now,
2236
+ };
2237
+ }
2238
+ function normaliseToolName(raw) {
2239
+ const lower = raw.toLowerCase();
2240
+ if (lower === 'webfetch' || lower === 'web_fetch')
2241
+ return 'web_fetch';
2242
+ if (lower === 'read')
2243
+ return 'read';
2244
+ if (lower === 'edit')
2245
+ return 'edit';
2246
+ if (lower === 'bash')
2247
+ return 'bash';
2248
+ if (lower === 'grep')
2249
+ return 'grep';
2250
+ if (lower === 'glob')
2251
+ return 'glob';
2252
+ // Unreachable - regex constrains the input. Fallback keeps types happy.
2253
+ return 'read';
2254
+ }
2255
+ /**
2256
+ * Cheap status inference from the tail string. We honour explicit
2257
+ * `OK` / `error` / `running` prefixes the dispatcher may write, plus
2258
+ * a `+/-` diff hint (treated as `ok`) and a `no match` (treated as
2259
+ * `ok` because grep with no result is not an error condition).
2260
+ */
2261
+ function parseStatusFromTail(tail) {
2262
+ if (tail.length === 0)
2263
+ return { status: 'running' };
2264
+ const lower = tail.toLowerCase();
2265
+ if (lower.startsWith('error') || lower.startsWith('failed')) {
2266
+ return { status: 'error', detail: tail };
2267
+ }
2268
+ if (lower.startsWith('running')) {
2269
+ return { status: 'running', detail: tail };
2270
+ }
2271
+ return { status: 'ok', detail: tail };
2272
+ }
2273
+ /* ------------------------------------------------------------------ */
2274
+ /* α6.3 office-hours encoders */
2275
+ /* */
2276
+ /* Mirrors `tui/ask-modal.tsx#encodeAskVerdict` so the session can */
2277
+ /* synthesise the operator-side echo without dragging an Ink module */
2278
+ /* into the test surface. The two encoders MUST agree byte-for-byte - */
2279
+ /* a divergence would silently mis-prefix the persona's follow-up. */
2280
+ /* ------------------------------------------------------------------ */
2281
+ function encodeAskVerdictLocal(verdict) {
2282
+ if (verdict.cancelled)
2283
+ return '[ASK-RESPONSE:cancelled]';
2284
+ if (verdict.value.length > 0)
2285
+ return `[ASK-RESPONSE:${verdict.value}]`;
2286
+ if (verdict.customInput && verdict.customInput.length > 0) {
2287
+ return `[ASK-RESPONSE:other] ${verdict.customInput}`;
2288
+ }
2289
+ return '[ASK-RESPONSE:cancelled]';
2290
+ }
2291
+ /**
2292
+ * Strip any leading `[ASK-RESPONSE:...]` or `[PLAN-VERDICT:...]`
2293
+ * pattern from free-text operator input so a malicious or accidental
2294
+ * operator string cannot forge a verdict header. Stripping iterates
2295
+ * because the operator could prepend several forged headers in a row;
2296
+ * we keep peeling until the head is clean.
2297
+ *
2298
+ * Example: operator types `[ASK-RESPONSE:vercel] my real answer` -
2299
+ * the leading `[ASK-RESPONSE:vercel] ` is stripped, leaving
2300
+ * `my real answer`, so the encoded wire string becomes
2301
+ * `[ASK-RESPONSE:other] my real answer` rather than
2302
+ * `[ASK-RESPONSE:other] [ASK-RESPONSE:vercel] my real answer` which
2303
+ * a prefix-greedy persona could read as "operator chose vercel".
2304
+ *
2305
+ * Claude triple-review P1 (PR #375).
2306
+ */
2307
+ function sanitiseVerdictText(raw) {
2308
+ let cleaned = raw;
2309
+ // Bounded loop: each iteration must strip a non-empty pattern, so it
2310
+ // terminates in O(input length). Hard cap as defence-in-depth in case
2311
+ // the regex ever matches an empty span.
2312
+ for (let i = 0; i < raw.length + 4; i += 1) {
2313
+ const stripped = cleaned.replace(/^\s*\[(?:ASK-RESPONSE|PLAN-VERDICT):[^\]]*\]\s*/u, '');
2314
+ if (stripped === cleaned)
2315
+ break;
2316
+ cleaned = stripped;
2317
+ }
2318
+ return cleaned.trim();
2319
+ }
2320
+ function sanitiseAskVerdict(verdict) {
2321
+ if (verdict.customInput === undefined)
2322
+ return verdict;
2323
+ const sanitisedCustom = sanitiseVerdictText(verdict.customInput);
2324
+ if (sanitisedCustom === verdict.customInput)
2325
+ return verdict;
2326
+ // If sanitisation emptied the buffer, treat the verdict as a
2327
+ // cancellation rather than dispatching a meaningless "other" with no
2328
+ // body. Preserves the dispatch invariant (no empty bodies) and
2329
+ // matches the encoder's fallback.
2330
+ if (sanitisedCustom.length === 0) {
2331
+ return { value: '', cancelled: true };
2332
+ }
2333
+ return { ...verdict, customInput: sanitisedCustom };
2334
+ }
2335
+ function sanitisePlanReviewResult(result) {
2336
+ if (result.verdict !== 'modify')
2337
+ return result;
2338
+ if (result.modifyText === undefined)
2339
+ return result;
2340
+ const sanitisedText = sanitiseVerdictText(result.modifyText);
2341
+ if (sanitisedText === result.modifyText)
2342
+ return result;
2343
+ if (sanitisedText.length === 0) {
2344
+ return { verdict: 'cancel' };
2345
+ }
2346
+ return { verdict: 'modify', modifyText: sanitisedText };
2347
+ }
2348
+ function encodePlanReviewVerdictLocal(result) {
2349
+ switch (result.verdict) {
2350
+ case 'approve':
2351
+ return '[PLAN-VERDICT:approve]';
2352
+ case 'cancel':
2353
+ return '[PLAN-VERDICT:cancel]';
2354
+ case 'modify':
2355
+ if (result.modifyText && result.modifyText.length > 0) {
2356
+ return `[PLAN-VERDICT:modify] ${result.modifyText}`;
2357
+ }
2358
+ return '[PLAN-VERDICT:cancel]';
2359
+ }
2360
+ }
2361
+ /**
2362
+ * Compose the human-readable transcript line that records the
2363
+ * operator's ask verdict. Mirrors Codex CLI's "you chose: <label>"
2364
+ * pattern so the conversation reads linearly.
2365
+ */
2366
+ function humanLabelForVerdict(tag, verdict) {
2367
+ if (verdict.cancelled)
2368
+ return '(cancelled the question)';
2369
+ if (verdict.value.length > 0) {
2370
+ const opt = tag.options.find((o) => o.value === verdict.value);
2371
+ return opt ? `chose: ${opt.label}` : `chose: ${verdict.value}`;
2372
+ }
2373
+ if (verdict.customInput && verdict.customInput.length > 0) {
2374
+ return `chose: other - ${verdict.customInput}`;
2375
+ }
2376
+ return '(cancelled the question)';
2377
+ }
2378
+ function humanLabelForPlanReviewVerdict(result) {
2379
+ switch (result.verdict) {
2380
+ case 'approve':
2381
+ return 'approved the plan';
2382
+ case 'cancel':
2383
+ return 'cancelled the plan';
2384
+ case 'modify':
2385
+ if (result.modifyText && result.modifyText.length > 0) {
2386
+ return `modified the plan: ${result.modifyText}`;
2387
+ }
2388
+ return 'cancelled the plan';
2389
+ }
2390
+ }
2391
+ /**
2392
+ * Synthesise a 2-option yes/no `<pugi-ask>` tag from a raw question
2393
+ * string. Used by the `/ask` slash command and by `pugi ask <question>`
2394
+ * to give the operator a manual entrypoint into the office-hours UI
2395
+ * without needing a persona-side emission.
2396
+ *
2397
+ * Returns null when the question fails the parser's length cap so the
2398
+ * caller can surface a clear error rather than crashing the modal.
2399
+ */
2400
+ export function synthesiseLocalAskTag(question) {
2401
+ const trimmed = question.trim();
2402
+ if (trimmed.length === 0 || trimmed.length > 80)
2403
+ return null;
2404
+ const options = [
2405
+ { value: 'yes', label: 'Yes' },
2406
+ { value: 'no', label: 'No' },
2407
+ ];
2408
+ // Use the single-source signature helper so a persona-emitted ask
2409
+ // with the same question + same option values does not collide with
2410
+ // this synthesised one under a divergent algorithm. Claude
2411
+ // triple-review P1 (PR #375).
2412
+ const signature = signatureForAsk(trimmed, options);
2413
+ return {
2414
+ question: trimmed,
2415
+ options,
2416
+ signature,
2417
+ start: 0,
2418
+ end: 0,
2419
+ };
2420
+ }
2421
+ /**
2422
+ * Strip the persona's own display name from the head of a streamed
2423
+ * reply, plus any leaked `<workspace-context-...>` envelope the model
2424
+ * may echo on its first turn. Exported for direct unit testing —
2425
+ * production callers go through `appendPersonaLine`.
2426
+ *
2427
+ * Examples (display name = "Pugi"):
2428
+ * "Pugi, координатор Pugi. Брифую..." -> "координатор Pugi. Брифую..."
2429
+ * "Pugi: вот результат" -> "вот результат"
2430
+ * "<workspace-context-abc>Pugi, привет" -> "привет"
2431
+ * "обычный ответ без префикса" -> "обычный ответ без префикса"
2432
+ *
2433
+ * The strip is conservative - we only remove the display name when it
2434
+ * is followed by a separator (comma, colon, dash, space) so a sentence
2435
+ * that legitimately contains the name mid-text ("спроси у Pugi") is
2436
+ * not mangled. (α6.14.2 wave 5 - CEO dogfood fix.)
2437
+ */
2438
+ export function stripPersonaPrefixEcho(personaSlug, text) {
2439
+ let working = text.trimStart();
2440
+ // Drop any leaked `<workspace-context-...>` / `</workspace-context-...>`
2441
+ // wrapper at the head. The Mira prompt v1.1 sometimes echoes the
2442
+ // scaffolding envelope back when the model is warm-starting the
2443
+ // first turn; cosmetic noise the operator never needs to see.
2444
+ // We strip both opening tag and any text up to (and including) the
2445
+ // matching closing tag if present, else just the opening tag.
2446
+ const openMatch = /^<workspace-context[^>]*>/i.exec(working);
2447
+ if (openMatch) {
2448
+ working = working.slice(openMatch[0].length).trimStart();
2449
+ const closeMatch = /^([\s\S]*?)<\/workspace-context[^>]*>/i.exec(working);
2450
+ if (closeMatch) {
2451
+ working = working.slice(closeMatch[0].length).trimStart();
2452
+ }
2453
+ }
2454
+ // Resolve the display name from the canonical roster. Unknown slugs
2455
+ // (forward-compat with future personas streamed by a newer server)
2456
+ // skip the strip - better to leave the text alone than to mis-strip.
2457
+ const persona = getPersona(personaSlug);
2458
+ if (!persona)
2459
+ return working;
2460
+ const display = persona.name;
2461
+ if (!display || display.length === 0)
2462
+ return working;
2463
+ // Match `<DisplayName>` followed by an end-of-string, or by a
2464
+ // separator (comma, colon, dash, period followed by space, single
2465
+ // space). The match is case-insensitive so "pugi" also strips.
2466
+ // Escape regex specials in the display name even though THE_TEN
2467
+ // names are alpha-only today (forward-defense).
2468
+ const escaped = display.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
2469
+ const re = new RegExp(`^${escaped}(?:[\\s,:;\\-—–]+|$)`, 'i');
2470
+ // Loop the strip so cascading echoes ("Pugi Pugi Pugi, координатор ...")
2471
+ // collapse to a single name. The model occasionally emits the display
2472
+ // name two or three times back-to-back when the pane prefix also
2473
+ // injects "▸ Pugi"; without the loop, only the first token would be
2474
+ // peeled and the operator would still see "▸ Pugi Pugi, координатор".
2475
+ // Cap at 3 iterations - beyond that the text is either pathological
2476
+ // or unrelated and we should not keep chewing it. Bail when an
2477
+ // iteration makes no progress to avoid infinite loops on a regex that
2478
+ // matches an empty string (defence-in-depth even though the current
2479
+ // pattern guarantees at least one consumed char).
2480
+ for (let i = 0; i < 3; i += 1) {
2481
+ const m = re.exec(working);
2482
+ if (!m || m[0].length === 0)
2483
+ break;
2484
+ working = working.slice(m[0].length).trimStart();
2485
+ }
2486
+ return working;
2487
+ }
592
2488
  //# sourceMappingURL=session.js.map