@pugi/cli 0.1.0-alpha.9 → 0.1.0-beta.10

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 (74) hide show
  1. package/README.md +33 -0
  2. package/THIRD_PARTY_NOTICES.md +40 -0
  3. package/assets/pugi-mascot.ansi +16 -0
  4. package/dist/commands/deploy.js +439 -0
  5. package/dist/core/agents/loader.js +104 -0
  6. package/dist/core/agents/registry.js +1 -1
  7. package/dist/core/consensus/anvil-fanout.js +276 -0
  8. package/dist/core/consensus/diff-capture.js +382 -0
  9. package/dist/core/consensus/rubric.js +233 -0
  10. package/dist/core/context/index.js +21 -0
  11. package/dist/core/context/pugiignore.js +316 -0
  12. package/dist/core/context/repo-skeleton.js +533 -0
  13. package/dist/core/context/watcher.js +342 -0
  14. package/dist/core/context/working-set.js +165 -0
  15. package/dist/core/edits/dispatch.js +185 -0
  16. package/dist/core/edits/index.js +15 -0
  17. package/dist/core/edits/layer-a-apply.js +217 -0
  18. package/dist/core/edits/layer-b-apply.js +211 -0
  19. package/dist/core/edits/layer-c-apply.js +160 -0
  20. package/dist/core/edits/layer-d-ast.js +29 -0
  21. package/dist/core/edits/marker-parser.js +401 -0
  22. package/dist/core/edits/security-gate.js +223 -0
  23. package/dist/core/edits/worktree.js +322 -0
  24. package/dist/core/engine/native-pugi.js +6 -1
  25. package/dist/core/engine/prompts.js +8 -0
  26. package/dist/core/engine/tool-bridge.js +33 -1
  27. package/dist/core/lsp/client.js +719 -0
  28. package/dist/core/repl/ask.js +512 -0
  29. package/dist/core/repl/cancellation.js +98 -0
  30. package/dist/core/repl/dispatch-fsm.js +220 -0
  31. package/dist/core/repl/privacy-banner.js +71 -0
  32. package/dist/core/repl/session.js +1908 -13
  33. package/dist/core/repl/slash-commands.js +92 -32
  34. package/dist/core/repl/store/index.js +12 -0
  35. package/dist/core/repl/store/jsonl-log.js +321 -0
  36. package/dist/core/repl/store/lockfile.js +155 -0
  37. package/dist/core/repl/store/session-store.js +792 -0
  38. package/dist/core/repl/store/types.js +44 -0
  39. package/dist/core/repl/store/uuid-v7.js +68 -0
  40. package/dist/core/repl/workspace-context.js +72 -1
  41. package/dist/core/skills/defaults.js +457 -0
  42. package/dist/core/skills/loader.js +454 -0
  43. package/dist/core/skills/sources.js +480 -0
  44. package/dist/core/skills/trust.js +172 -0
  45. package/dist/runtime/cli.js +998 -12
  46. package/dist/runtime/commands/agents.js +385 -0
  47. package/dist/runtime/commands/config.js +338 -8
  48. package/dist/runtime/commands/delegate.js +289 -0
  49. package/dist/runtime/commands/lsp.js +206 -0
  50. package/dist/runtime/commands/patch.js +128 -0
  51. package/dist/runtime/commands/review-consensus.js +399 -0
  52. package/dist/runtime/commands/roster.js +117 -0
  53. package/dist/runtime/commands/skills.js +401 -0
  54. package/dist/runtime/commands/worktree.js +177 -0
  55. package/dist/runtime/plan-decompose.js +531 -0
  56. package/dist/tools/apply-patch.js +495 -0
  57. package/dist/tools/file-tools.js +90 -0
  58. package/dist/tools/lsp-tools.js +189 -0
  59. package/dist/tools/registry.js +26 -0
  60. package/dist/tools/web-fetch.js +1 -1
  61. package/dist/tui/agent-tree-pane.js +9 -0
  62. package/dist/tui/ask-cli.js +52 -0
  63. package/dist/tui/ask-modal.js +211 -0
  64. package/dist/tui/conversation-pane.js +48 -3
  65. package/dist/tui/input-box.js +48 -5
  66. package/dist/tui/markdown-render.js +266 -0
  67. package/dist/tui/repl-render.js +319 -3
  68. package/dist/tui/repl-splash-mascot.js +130 -0
  69. package/dist/tui/repl-splash.js +7 -1
  70. package/dist/tui/repl.js +96 -12
  71. package/dist/tui/status-bar.js +63 -3
  72. package/dist/tui/tool-stream-pane.js +91 -0
  73. package/docs/examples/codegraph.mcp.json +10 -0
  74. package/package.json +14 -6
@@ -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) {
@@ -100,18 +362,72 @@ export class ReplSession {
100
362
  });
101
363
  this.patch({ sessionId, connection: 'connecting' });
102
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);
103
374
  }
104
375
  catch (error) {
105
376
  this.appendSystemLine(`Could not open Pugi session: ${this.errorMessage(error)}`);
106
377
  this.patch({ connection: 'offline' });
107
378
  }
108
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
+ }
109
417
  /**
110
418
  * Tear down the SSE stream and stop the reconnect timer. The session
111
419
  * id stays valid server-side; `pugi resume <id>` reopens later.
112
420
  */
113
421
  close() {
114
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
+ }
115
431
  if (this.streamHandle) {
116
432
  this.streamHandle.close();
117
433
  this.streamHandle = undefined;
@@ -120,6 +436,116 @@ export class ReplSession {
120
436
  clearTimeout(this.reconnectTimer);
121
437
  this.reconnectTimer = undefined;
122
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;
123
549
  }
124
550
  /* ------------- input handling -------------- */
125
551
  /**
@@ -151,6 +577,18 @@ export class ReplSession {
151
577
  await this.dispatchStop(verdict.persona);
152
578
  return verdict;
153
579
  }
580
+ case 'delegate': {
581
+ // α7.5 Phase 1: surface the dispatch intent inline. The actual
582
+ // wire shape (POST /api/pugi/sessions/:id/delegate) requires the
583
+ // SDK transport extension that ships alongside this PR; the
584
+ // REPL session module wires the call when the matching transport
585
+ // method lands (paired CLI follow-up). Today we surface the
586
+ // delegation intent in the transcript so the operator sees the
587
+ // verdict echo for muscle-memory before the round-trip lights up.
588
+ this.appendSystemLine(`delegate ${verdict.persona}: ${verdict.brief.length > 80 ? `${verdict.brief.slice(0, 77)}...` : verdict.brief}`);
589
+ this.appendSystemLine('Run `pugi delegate <slug> "<brief>"` from a fresh shell while the REPL transport wiring lands.');
590
+ return verdict;
591
+ }
154
592
  case 'dispatch': {
155
593
  await this.dispatchBrief(verdict.brief);
156
594
  return verdict;
@@ -183,12 +621,109 @@ export class ReplSession {
183
621
  this.dispatchStatus();
184
622
  return verdict;
185
623
  }
624
+ case 'consensus': {
625
+ // alpha 6.7: surface a deterministic deep-link so the operator
626
+ // knows the command exists in the REPL palette even though the
627
+ // full SSE renderer ships outside the Ink frame. Running the
628
+ // gate live inside the REPL needs the non-TTY emit path; for
629
+ // M1 we point the operator at the shell command.
630
+ const tail = verdict.ref ? ` ${verdict.ref}` : '';
631
+ this.appendSystemLine(`Run \`pugi review --consensus${tail}\` from a fresh shell to dispatch the 3-model gate.`);
632
+ return verdict;
633
+ }
634
+ case 'resume': {
635
+ await this.dispatchResume();
636
+ return verdict;
637
+ }
638
+ case 'context': {
639
+ this.dispatchContext();
640
+ return verdict;
641
+ }
642
+ case 'ask': {
643
+ // α6.3: synthesise a local yes/no `<pugi-ask>` modal so the
644
+ // operator can exercise the question UI without a persona-side
645
+ // round trip. The REPL UI mounts the modal from the resulting
646
+ // `pendingAsk` state; on resolution the encoded verdict lands
647
+ // in the transcript as a system line (no admin-api dispatch
648
+ // because the question is local).
649
+ const askTag = synthesiseLocalAskTag(verdict.question);
650
+ if (!askTag) {
651
+ this.appendSystemLine('Could not synthesise local ask (question too long?). Cap is 80 chars.');
652
+ return verdict;
653
+ }
654
+ this.patch({ pendingAsk: askTag, pendingAskSource: 'local' });
655
+ return verdict;
656
+ }
657
+ case 'privacy': {
658
+ // alpha 6.13: print the full mode contract + current banner
659
+ // inline. The current mode is resolved lazily by the helper -
660
+ // when unauthenticated or offline the banner falls back to
661
+ // "(unknown - mode lookup pending)" and the contract doc still
662
+ // renders so the operator can read the alternatives.
663
+ await this.dispatchPrivacy();
664
+ return verdict;
665
+ }
186
666
  case 'stub': {
187
667
  this.appendSystemLine(verdict.message);
188
668
  return verdict;
189
669
  }
190
670
  }
191
671
  }
672
+ /**
673
+ * In-REPL `/privacy` - alpha 6.13. Prints the full 3-mode contract
674
+ * doc + the current mode banner inline. The current mode is fetched
675
+ * via the admin-api /api/admin/privacy/mode endpoint when the
676
+ * operator is authenticated; otherwise the banner falls back to
677
+ * "(unknown)" and the contract doc still renders so the operator
678
+ * can compare modes without leaving the REPL.
679
+ */
680
+ async dispatchPrivacy() {
681
+ const { renderPrivacyContractDoc } = await import('./privacy-banner.js');
682
+ // Triple-review P1 fix (2026-05-25): use the bootstrap-cached mode
683
+ // so the operator sees the LIVE current mode in the banner header
684
+ // instead of "(unknown)". The fetch happens once on session start;
685
+ // if it failed (offline / unauth) the cache stays null and the
686
+ // banner falls back to "(unknown)" - same UX as before, just with
687
+ // the happy path actually delivering the mode.
688
+ const doc = renderPrivacyContractDoc(this.privacyMode);
689
+ this.appendSystemLine(doc);
690
+ }
691
+ /**
692
+ * In-REPL `/resume` - α6.4. Lists the 10 most recent sessions from
693
+ * the local SessionStore and prints them as a numbered system menu.
694
+ * The Ink-side picker UI is deferred to the next sprint; today the
695
+ * operator gets a deterministic list + the exact command to relaunch
696
+ * with: `pugi resume <id>`. Keeping the picker out of the REPL
697
+ * frame avoids re-architecting the conversation pane mid-sprint and
698
+ * keeps `/resume` testable without an Ink runtime.
699
+ */
700
+ async dispatchResume() {
701
+ if (!this.store) {
702
+ this.appendSystemLine('Local session store is disabled - /resume is unavailable.');
703
+ return;
704
+ }
705
+ let rows;
706
+ try {
707
+ rows = await this.store.listSessions({ limit: 10 });
708
+ }
709
+ catch (error) {
710
+ this.appendSystemLine(`Could not list sessions: ${this.errorMessage(error)}`);
711
+ return;
712
+ }
713
+ if (rows.length === 0) {
714
+ this.appendSystemLine('No stored sessions yet - keep dispatching to build history.');
715
+ return;
716
+ }
717
+ this.appendSystemLine(`Recent sessions (${rows.length}):`);
718
+ for (let i = 0; i < rows.length; i += 1) {
719
+ const row = rows[i];
720
+ const title = (row.title ?? '(untitled)').slice(0, 64);
721
+ const idShort = row.id.slice(0, 13);
722
+ const branch = row.branch ?? 'no-branch';
723
+ this.appendSystemLine(` ${(i + 1).toString().padStart(2)}. ${idShort} ${branch.padEnd(16)} ${title}`);
724
+ }
725
+ this.appendSystemLine('Pick one with: pugi resume <id> (paste the 13-char id from above).');
726
+ }
192
727
  /**
193
728
  * Reset the conversation transcript. The agent registry stays intact
194
729
  * so the operator can `/clear` to declutter the chat pane without
@@ -197,6 +732,173 @@ export class ReplSession {
197
732
  clearTranscript() {
198
733
  this.patch({ transcript: [] });
199
734
  }
735
+ /* ------------- α6.3 office-hours surface -------------- */
736
+ /**
737
+ * Surface an `<pugi-ask>` modal manually. Returned promise resolves
738
+ * with the operator's verdict - used by the `pugi ask "<q>"` shell
739
+ * command and by the `/ask` slash. The resolver is wired into the
740
+ * session state via `pendingAsk` so the REPL UI can render the modal
741
+ * and forward `onResolve` back through `resolveAsk()`.
742
+ *
743
+ * NOTE: idempotent on a duplicate signature - a second presentAsk
744
+ * with the same question + option values returns the first
745
+ * outstanding promise rather than stacking two modals.
746
+ */
747
+ presentAsk(tag) {
748
+ if (this.outstandingAskPromise
749
+ && this.state.pendingAsk?.signature === tag.signature) {
750
+ // The operator is already looking at this exact ask; reuse the
751
+ // outstanding promise so the second caller sees the same answer
752
+ // when it eventually arrives.
753
+ return this.outstandingAskPromise;
754
+ }
755
+ // If a DIFFERENT ask is open, reject the new one with a clear
756
+ // error rather than silently queueing - the persona should never
757
+ // emit two concurrent asks, and surfacing the bug fails loud.
758
+ if (this.outstandingAskPromise) {
759
+ return Promise.reject(new Error('presentAsk: another ask is already pending. Resolve it first.'));
760
+ }
761
+ this.patch({ pendingAsk: tag, pendingAskSource: 'persona' });
762
+ const promise = new Promise((resolve) => {
763
+ this.outstandingAskResolver = resolve;
764
+ });
765
+ this.outstandingAskPromise = promise;
766
+ return promise;
767
+ }
768
+ /**
769
+ * Resolve the currently pending `<pugi-ask>` modal. Called by the
770
+ * REPL UI when the operator submits the modal. Appends an operator
771
+ * line to the transcript carrying the verdict, dispatches the
772
+ * verdict-encoded brief to admin-api as the next user turn (when the
773
+ * modal originated from a persona stream), and clears `pendingAsk`.
774
+ * Idempotent: a second call without a fresh `pendingAsk` is a no-op.
775
+ *
776
+ * The verdict is also forwarded to the resolver returned by
777
+ * `presentAsk()` if there is one outstanding, so the CLI's `pugi ask`
778
+ * command can await the answer.
779
+ *
780
+ * Cancellation contract (Claude triple-review P1): when the modal
781
+ * came from a persona stream, cancel ALSO dispatches a literal
782
+ * `[ASK-RESPONSE:cancelled]` to admin-api so the persona observes the
783
+ * cancellation rather than hanging indefinitely on the missing
784
+ * follow-up. The matching documentation in the Mira system prompt
785
+ * teaches the persona to acknowledge cancellation and offer a
786
+ * different path. Local-origin modals (synthesised via `/ask`) skip
787
+ * the dispatch entirely - the persona never saw the question.
788
+ *
789
+ * Free-text sanitisation (Claude triple-review P1): the operator's
790
+ * customInput is stripped of any leading `[ASK-RESPONSE:...]` /
791
+ * `[PLAN-VERDICT:...]` prefix before being encoded, so a malicious
792
+ * (or accidental) operator string cannot forge a verdict header that
793
+ * a prefix-greedy persona would misinterpret as a different choice.
794
+ */
795
+ async resolveAsk(verdict) {
796
+ if (!this.state.pendingAsk)
797
+ return;
798
+ const tag = this.state.pendingAsk;
799
+ const source = this.state.pendingAskSource;
800
+ const sanitisedVerdict = sanitiseAskVerdict(verdict);
801
+ this.patch({ pendingAsk: null, pendingAskSource: null });
802
+ const encoded = encodeAskVerdictLocal(sanitisedVerdict);
803
+ // Tell the outstanding presentAsk caller, if any. The sanitised
804
+ // verdict is forwarded so downstream consumers cannot be tricked
805
+ // by a forged verdict header either.
806
+ if (this.outstandingAskResolver) {
807
+ const resolver = this.outstandingAskResolver;
808
+ this.outstandingAskResolver = undefined;
809
+ this.outstandingAskPromise = undefined;
810
+ resolver(sanitisedVerdict);
811
+ }
812
+ // Surface the operator's choice as a transcript row so the
813
+ // conversation reads linearly. The label of the chosen option
814
+ // (or the literal custom input) is more readable than the bare
815
+ // value - Codex CLI's "you chose: Vercel" pattern.
816
+ const humanLabel = humanLabelForVerdict(tag, sanitisedVerdict);
817
+ this.appendOperatorLine(humanLabel);
818
+ // Local-origin modals (operator typed `/ask`) never need an
819
+ // admin-api round trip - the persona never observed the question.
820
+ // Codex triple-review P2.
821
+ if (source !== 'persona')
822
+ return;
823
+ // Persona-origin modals always dispatch, including on cancel:
824
+ // without the cancellation echo the persona's last turn stays open
825
+ // and the agent hangs. Claude triple-review P1.
826
+ await this.dispatchAskFollowup(encoded);
827
+ }
828
+ /**
829
+ * Same shape as `presentAsk` for the plan-review modal.
830
+ */
831
+ presentPlanReview(tag) {
832
+ if (this.outstandingPlanReviewPromise
833
+ && this.state.pendingPlanReview?.signature === tag.signature) {
834
+ return this.outstandingPlanReviewPromise;
835
+ }
836
+ if (this.outstandingPlanReviewPromise) {
837
+ return Promise.reject(new Error('presentPlanReview: another plan review is already pending.'));
838
+ }
839
+ this.patch({ pendingPlanReview: tag, pendingPlanReviewSource: 'persona' });
840
+ const promise = new Promise((resolve) => {
841
+ this.outstandingPlanReviewResolver = resolve;
842
+ });
843
+ this.outstandingPlanReviewPromise = promise;
844
+ return promise;
845
+ }
846
+ /**
847
+ * Resolve the currently pending `<pugi-plan-review>` modal. Same
848
+ * mechanics as `resolveAsk` - including the cancel-dispatch contract
849
+ * and modifyText sanitisation. The persona always sees a
850
+ * `[PLAN-VERDICT:...]` echo (even on cancel) so it never hangs
851
+ * waiting for the verdict that the operator declined to send.
852
+ */
853
+ async resolvePlanReview(result) {
854
+ if (!this.state.pendingPlanReview)
855
+ return;
856
+ const source = this.state.pendingPlanReviewSource;
857
+ const sanitisedResult = sanitisePlanReviewResult(result);
858
+ this.patch({ pendingPlanReview: null, pendingPlanReviewSource: null });
859
+ const encoded = encodePlanReviewVerdictLocal(sanitisedResult);
860
+ if (this.outstandingPlanReviewResolver) {
861
+ const resolver = this.outstandingPlanReviewResolver;
862
+ this.outstandingPlanReviewResolver = undefined;
863
+ this.outstandingPlanReviewPromise = undefined;
864
+ resolver(sanitisedResult);
865
+ }
866
+ this.appendOperatorLine(humanLabelForPlanReviewVerdict(sanitisedResult));
867
+ // Local-origin plan reviews skip the dispatch (Codex P2). Persona
868
+ // origin always dispatches, including on cancel (Claude P1).
869
+ if (source !== 'persona')
870
+ return;
871
+ await this.dispatchAskFollowup(encoded);
872
+ }
873
+ /**
874
+ * Internal: post the verdict-encoded brief WITHOUT going through the
875
+ * cap-warning gate. The follow-up is the natural continuation of the
876
+ * same conversation the persona started, so blocking it on capacity
877
+ * would strand the operator with no way to answer.
878
+ */
879
+ async dispatchAskFollowup(encodedBrief) {
880
+ const sessionId = this.state.sessionId;
881
+ if (!sessionId) {
882
+ this.appendSystemLine('No server session - response queued locally.');
883
+ return;
884
+ }
885
+ try {
886
+ await this.options.transport.postBrief({
887
+ apiUrl: this.options.apiUrl,
888
+ apiKey: this.options.apiKey,
889
+ sessionId,
890
+ brief: encodedBrief,
891
+ });
892
+ }
893
+ catch (error) {
894
+ this.appendSystemLine(`Could not forward response: ${this.errorMessage(error)}`);
895
+ }
896
+ }
897
+ // ----- Outstanding resolver bookkeeping for presentAsk / presentPlanReview -----
898
+ outstandingAskResolver;
899
+ outstandingAskPromise;
900
+ outstandingPlanReviewResolver;
901
+ outstandingPlanReviewPromise;
200
902
  /* ------------- Tier 1 / Tier 2 wired handlers -------------- */
201
903
  async dispatchJobs() {
202
904
  try {
@@ -265,6 +967,129 @@ export class ReplSession {
265
967
  this.appendSystemLine(`Workspace: ${this.state.workspaceLabel}.`);
266
968
  this.appendSystemLine(`CLI: pugi ${this.state.cliVersion}.`);
267
969
  }
970
+ /**
971
+ * α6.5 `/context` slash handler. Surfaces the three-tier context
972
+ * summary as a stack of system lines. Sections (in order):
973
+ *
974
+ * 1. Tier 0 (repo skeleton) - size in bytes, branch, package
975
+ * manager, languages. Skipped when no skeleton was injected
976
+ * (REPL launched outside a workspace or with --no-context).
977
+ *
978
+ * 2. Tier 1 (working set) - `count / capacity` plus the total
979
+ * size in bytes plus the oldest entry's age in seconds.
980
+ * Always emits even when empty so the operator can confirm
981
+ * the tier is wired.
982
+ *
983
+ * 3. Tier 2 (RAG) - one-line heads-up that the Anvil-side
984
+ * workspace lands in α6.5b.
985
+ *
986
+ * The renderer never mutates state.
987
+ */
988
+ dispatchContext() {
989
+ if (this.repoSkeleton) {
990
+ const parts = [`Tier 0 skeleton: ${this.repoSkeleton.totalSize} bytes`];
991
+ if (this.repoSkeleton.branch)
992
+ parts.push(`branch ${this.repoSkeleton.branch}`);
993
+ if (this.repoSkeleton.packageManager)
994
+ parts.push(this.repoSkeleton.packageManager);
995
+ if (this.repoSkeleton.primaryLanguages.length > 0) {
996
+ parts.push(`langs ${this.repoSkeleton.primaryLanguages.slice(0, 3).join('/')}`);
997
+ }
998
+ this.appendSystemLine(parts.join(' - '));
999
+ }
1000
+ else {
1001
+ this.appendSystemLine('Tier 0 skeleton: not loaded (run pugi init or launch in a workspace).');
1002
+ }
1003
+ if (this.workingSet) {
1004
+ const summary = this.workingSet.summary();
1005
+ const ageLine = summary.oldestTouchedAtEpochMs !== null
1006
+ ? ` - oldest touch ${formatAgeSeconds(this.now() - summary.oldestTouchedAtEpochMs)} ago`
1007
+ : '';
1008
+ this.appendSystemLine(`Tier 1 working set: ${summary.count}/${summary.capacity} files, ${summary.totalSizeBytes} bytes${ageLine}.`);
1009
+ }
1010
+ else {
1011
+ this.appendSystemLine('Tier 1 working set: not wired.');
1012
+ }
1013
+ this.appendSystemLine('Tier 2 RAG: deferred to α6.5b (Anvil-side per-tenant workspace).');
1014
+ }
1015
+ /**
1016
+ * α6.5 chokidar batch handler. Forwards each event to the working
1017
+ * set tracker (so `unlink` evicts and `add`/`change` bump the
1018
+ * recency) and emits at most one throttled system line per
1019
+ * `FILEWATCH_SYSTEM_LINE_GAP_MS` window.
1020
+ *
1021
+ * The transcript surface intentionally shows ONE filename + the
1022
+ * count of additional changes (`file changed: src/foo.ts (+3 more)`).
1023
+ * The full event list is preserved in the buffer for future
1024
+ * `/context --files` deep-dive (not in α6.5 Phase 1).
1025
+ */
1026
+ recordFilewatchBatch(batch) {
1027
+ // Hard-guard against post-close invocation. close() detaches the
1028
+ // watcher listeners, but the EventEmitter contract allows the
1029
+ // currently-dispatching emit() call to finish delivering to every
1030
+ // listener captured at the start of emit(). If the session closes
1031
+ // mid-emit, the handler can still fire on a dead session. Returning
1032
+ // early keeps the working set + transcript untouched.
1033
+ // triple-review P1 (PR #380).
1034
+ if (this.closed)
1035
+ return;
1036
+ if (this.workingSet) {
1037
+ for (const event of batch.events) {
1038
+ if (event.kind === 'unlink') {
1039
+ this.workingSet.forget(event.absPath);
1040
+ }
1041
+ // Note: we do NOT auto-track add/change here. The working set
1042
+ // reflects files the AGENT touched - filewatch is informational.
1043
+ // Future wiring: bash/read/edit tools will call track() directly.
1044
+ }
1045
+ }
1046
+ const nowMs = this.now();
1047
+ const sinceLast = nowMs - this.lastFilewatchLineAtEpochMs;
1048
+ if (sinceLast < FILEWATCH_SYSTEM_LINE_GAP_MS && this.lastFilewatchLineAtEpochMs !== 0) {
1049
+ // Inside the throttle window - buffer for future deep-dive but
1050
+ // do not emit a system line. Cap the buffer at
1051
+ // PENDING_FILEWATCH_BATCH_CAP and drop the oldest on overflow so
1052
+ // a noisy filewatch source cannot drive unbounded memory growth
1053
+ // across a long REPL session. triple-review P1 (PR #380).
1054
+ if (this.pendingFilewatchBatches.length >= PENDING_FILEWATCH_BATCH_CAP) {
1055
+ this.pendingFilewatchBatches.shift();
1056
+ if (!this.pendingFilewatchOverflowWarned) {
1057
+ this.pendingFilewatchOverflowWarned = true;
1058
+ this.appendSystemLine(`Filewatch buffer at cap (${PENDING_FILEWATCH_BATCH_CAP} batches) - shedding oldest. Source may be a build watcher in a tight loop.`);
1059
+ }
1060
+ }
1061
+ this.pendingFilewatchBatches.push(batch);
1062
+ return;
1063
+ }
1064
+ const totalEvents = this.pendingFilewatchBatches.reduce((acc, b) => acc + b.events.length, 0) + batch.events.length;
1065
+ const head = batch.events[0];
1066
+ if (!head) {
1067
+ // Empty batch - should not happen given the watcher guards,
1068
+ // but defensive.
1069
+ this.pendingFilewatchBatches = [];
1070
+ return;
1071
+ }
1072
+ const wsLine = this.workingSet
1073
+ ? ` (working set: ${this.workingSet.size()}/${this.workingSet.capacityLimit()})`
1074
+ : '';
1075
+ const tail = totalEvents > 1 ? ` (+${totalEvents - 1} more)` : '';
1076
+ this.appendSystemLine(`file ${head.kind}: ${head.path}${tail}${wsLine}`);
1077
+ this.lastFilewatchLineAtEpochMs = nowMs;
1078
+ this.pendingFilewatchBatches = [];
1079
+ }
1080
+ /**
1081
+ * α6.5 chokidar cap-exceeded handler. The watcher closes itself
1082
+ * when it crosses the watched-paths cap; the session surfaces a
1083
+ * single system line so the operator knows live updates are off.
1084
+ * The conversation stays usable - we just lose the file-changed
1085
+ * badge for the rest of the session.
1086
+ */
1087
+ recordFilewatchCapExceeded(info) {
1088
+ // Same post-close guard as recordFilewatchBatch. triple-review P1 (PR #380).
1089
+ if (this.closed)
1090
+ return;
1091
+ this.appendSystemLine(`Filewatch off: ${info.watchedCount} watched paths exceeded cap (${info.cap}). Falling back to manual stat-on-read.`);
1092
+ }
268
1093
  /**
269
1094
  * Fetch one URL via the web_fetch tool and inject the resulting
270
1095
  * Markdown into the transcript as an operator-attributed brief. The
@@ -324,6 +1149,92 @@ export class ReplSession {
324
1149
  }
325
1150
  this.appendOperatorLine(brief);
326
1151
  this.patch({ briefStartedAtEpochMs: this.now() });
1152
+ // α6.9 + R3 P1 (Codex triple-review 2026-05-25): supersede the
1153
+ // prior dispatch when one is in flight. Steps in order:
1154
+ //
1155
+ // 1. Abort the old CancellationToken so any in-flight tool
1156
+ // holding `ctx.cancellation` sees `isAborted = true` and bails
1157
+ // (the R2 fix; preserves the file-tools cancellation gate).
1158
+ // 2. Drive the OLD FSM through `aborting -> aborted` terminal.
1159
+ // This is load-bearing for the R3 race: a LATE event arriving
1160
+ // on the old FSM (`agent.spawned`, `agent.step`, terminal,
1161
+ // etc.) before the timestamp gate trips would otherwise still
1162
+ // attempt to transition the new FSM. Driving the old FSM to a
1163
+ // terminal state means the FSM check in
1164
+ // `advanceFsmOnDispatchEnd` (`isTerminal`) short-circuits as a
1165
+ // defense-in-depth layer.
1166
+ // 3. `resetFsmToIdle()` mints a fresh FSM so the new dispatch
1167
+ // starts clean. The FSM legal-transition matrix forbids
1168
+ // `aborted -> awaiting_response`, so the reset is required.
1169
+ // 4. Record `currentDispatchStartTime` BEFORE bumping
1170
+ // `dispatchSeq` + clearing `taskDispatchSeq`. The timestamp
1171
+ // gate in `handleServerEvent` checks
1172
+ // `event.timestamp < currentDispatchStartTime` to drop late
1173
+ // events from any superseded dispatch (including the late
1174
+ // `agent.spawned` that the R2 seq gate could not catch).
1175
+ // 5. Clear `taskDispatchSeq` so any stamp left over from the old
1176
+ // dispatch cannot influence seq comparisons for the new turn.
1177
+ // 6. Bump `dispatchSeq` and mint a fresh `CancellationToken`.
1178
+ //
1179
+ // If no prior dispatch is in flight (clean idle / terminal entry),
1180
+ // the supersede block is skipped; we only reset the FSM if it sits
1181
+ // in a terminal state from the prior turn.
1182
+ if (this.currentDispatchToken) {
1183
+ // Step 1: abort the old token. Listeners (including the
1184
+ // file-tools cancellation gate) fan out before we replace the
1185
+ // field below.
1186
+ this.currentDispatchToken.abort();
1187
+ // Step 2: walk the old FSM to terminal. Guard against an FSM
1188
+ // that already sits in `aborting` or a terminal state - the
1189
+ // legal-transition matrix forbids `aborting -> aborting` and
1190
+ // forbids any outgoing transition from terminal states.
1191
+ if (!this.fsm.isTerminal) {
1192
+ if (this.fsm.current !== 'aborting') {
1193
+ this.fsm.transition('aborting', 'superseded_by_new_brief');
1194
+ }
1195
+ this.fsm.transition('aborted', 'superseded_by_new_brief');
1196
+ }
1197
+ // Step 3: fresh FSM so the new dispatch can walk
1198
+ // `idle -> awaiting_response` cleanly.
1199
+ this.resetFsmToIdle();
1200
+ }
1201
+ else if (this.fsm.isTerminal) {
1202
+ // Prior turn ended naturally (completed / failed / aborted) with
1203
+ // no live token left around. Reset only the FSM.
1204
+ this.resetFsmToIdle();
1205
+ }
1206
+ // Step 4: record the dispatch start time BEFORE bumping the seq.
1207
+ // The timestamp gate in `handleServerEvent` reads this value to
1208
+ // decide whether an inbound event predates the live dispatch and
1209
+ // should be dropped. Recording before the seq bump is critical:
1210
+ // any concurrent SSE event landing between this line and the seq
1211
+ // bump must still see a strictly-monotonic timestamp boundary.
1212
+ this.currentDispatchStartTime = this.now();
1213
+ // Step 5: clear the per-task seq stamps. Any leftover stamp from a
1214
+ // superseded dispatch would otherwise look like it matched the new
1215
+ // dispatchSeq (because the late `agent.spawned` for that taskId
1216
+ // could stamp the OLD taskId with the NEW seq), nulling the new
1217
+ // token via a stale terminal event. The timestamp gate is the
1218
+ // primary defense; the clear is belt-and-braces.
1219
+ this.taskDispatchSeq.clear();
1220
+ // Step 6: bump seq + mint fresh token.
1221
+ this.dispatchSeq += 1;
1222
+ this.currentDispatchToken = new CancellationToken();
1223
+ // The FSM is now `idle` (either fresh-start or post-reset). Walk
1224
+ // to `awaiting_response` so the bottom-bar surface picks up the
1225
+ // new dispatch state immediately.
1226
+ if (this.fsm.current === 'idle') {
1227
+ this.fsm.transition('awaiting_response', 'brief_dispatched');
1228
+ }
1229
+ // α6.9: re-open the SSE stream if a prior `cancel()` tore it
1230
+ // down. Without this, the new brief would dispatch on admin-api
1231
+ // but the client would never observe `agent.spawned` / `step` /
1232
+ // `completed` — the operator would see a stalled status bar
1233
+ // forever. Idempotent: openStream() short-circuits when a handle
1234
+ // already exists or the session is closed.
1235
+ if (!this.streamHandle && !this.closed) {
1236
+ this.openStream();
1237
+ }
327
1238
  try {
328
1239
  await this.options.transport.postBrief({
329
1240
  apiUrl: this.options.apiUrl,
@@ -334,7 +1245,78 @@ export class ReplSession {
334
1245
  }
335
1246
  catch (error) {
336
1247
  this.appendSystemLine(`Brief dispatch refused: ${this.errorMessage(error)}`);
1248
+ // α6.9: a failed brief POST never produced a turn, so we move
1249
+ // the FSM straight to `failed` so the bottom-bar surfaces the
1250
+ // outcome and the next brief can mint a fresh token.
1251
+ this.markDispatchFailed('post_brief_failed');
1252
+ }
1253
+ }
1254
+ /**
1255
+ * α6.9: reset the FSM to `idle` after a terminal transition so the
1256
+ * next brief can start. The FSM does not allow direct
1257
+ * `completed -> awaiting_response`, so we mint a fresh FSM by
1258
+ * overwriting the field. Listeners on the old FSM are dropped (they
1259
+ * cannot fire again — terminal states have no outgoing transitions).
1260
+ * The state.dispatchState patch happens via the new FSM's listeners
1261
+ * which we re-attach immediately.
1262
+ */
1263
+ resetFsmToIdle() {
1264
+ // Re-attach the same listeners on a fresh FSM instance. We cannot
1265
+ // mutate `this.fsm` because it is `readonly`; but we mark it as
1266
+ // mutable for this single reset path.
1267
+ const next = new DispatchFSM();
1268
+ next.onEnter('idle', () => this.patch({ dispatchState: 'idle', dispatchToolLabel: null }));
1269
+ next.onEnter('awaiting_response', () => this.patch({ dispatchState: 'awaiting_response', dispatchToolLabel: null }));
1270
+ next.onEnter('tool_running', () => this.patch({ dispatchState: 'tool_running' }));
1271
+ next.onEnter('aborting', () => this.patch({ dispatchState: 'aborting', dispatchToolLabel: null }));
1272
+ next.onEnter('aborted', () => this.patch({ dispatchState: 'aborted', dispatchToolLabel: null }));
1273
+ next.onEnter('completed', () => this.patch({ dispatchState: 'completed', dispatchToolLabel: null }));
1274
+ next.onEnter('failed', () => this.patch({ dispatchState: 'failed', dispatchToolLabel: null }));
1275
+ // Swap the instance - the FSM does not allow direct
1276
+ // `<terminal> -> awaiting_response`, so the next brief needs a
1277
+ // fresh machine to walk from `idle`. Clean assignment (no cast)
1278
+ // because `fsm` is no longer declared readonly.
1279
+ this.fsm = next;
1280
+ // State patch so subscribers see the idle transition immediately.
1281
+ this.patch({ dispatchState: 'idle', dispatchToolLabel: null });
1282
+ }
1283
+ /**
1284
+ * α6.9: short-circuit the FSM to `failed` on a non-recoverable
1285
+ * dispatch error (network refusal, malformed event, etc). Idempotent
1286
+ * — a second call from a terminal state is a no-op.
1287
+ */
1288
+ markDispatchFailed(reason) {
1289
+ if (this.fsm.isTerminal)
1290
+ return;
1291
+ if (this.fsm.current === 'idle')
1292
+ return;
1293
+ // From `awaiting_response` or `tool_running` we can transition to
1294
+ // `failed` directly per the legal-transition matrix. From `aborting`
1295
+ // the only legal move is `aborted`, so skip — the abort path is
1296
+ // already in motion.
1297
+ if (this.fsm.current === 'aborting')
1298
+ return;
1299
+ this.fsm.transition('failed', reason);
1300
+ // α6.9 P1 fix (Claude triple-review): postBrief threw between
1301
+ // openStream() and dispatch registration server-side. The local
1302
+ // SSE handle is open but listening for events under a dispatchId
1303
+ // the admin-api never created. If we leave it open, any inbound
1304
+ // event for a future dispatch on the same session would drive
1305
+ // the FSM from terminal `failed` -> illegal target and throw
1306
+ // IllegalDispatchTransitionError. Tear down so the next brief
1307
+ // re-opens cleanly via dispatchBrief's openStream() gate.
1308
+ //
1309
+ // R2 P2 fix (Claude triple-review 2026-05-25): tear down the
1310
+ // stream BEFORE nulling the token. Same ordering contract as
1311
+ // `cancel()`: any onAbort listener fired during teardown should
1312
+ // observe the (now-aborted) token via getCurrentDispatchToken()
1313
+ // rather than null. Nulling the token first would race the
1314
+ // teardown's listener fan-out against a stale null read.
1315
+ if (this.streamHandle) {
1316
+ this.streamHandle.close();
1317
+ this.streamHandle = undefined;
337
1318
  }
1319
+ this.currentDispatchToken = null;
338
1320
  }
339
1321
  async dispatchStop(persona) {
340
1322
  const sessionId = this.state.sessionId;
@@ -382,12 +1364,131 @@ export class ReplSession {
382
1364
  onError: (error) => {
383
1365
  if (this.closed)
384
1366
  return;
1367
+ // α6.14.2 wave 5: when admin-api restarts it drops the in-memory
1368
+ // session store, so subscribe returns HTTP 404 forever on the
1369
+ // saved sessionId. Detect that case and mint a fresh server
1370
+ // session silently rather than spamming the operator with
1371
+ // "Stream interrupted (HTTP 404)" reconnect lines. Capped to
1372
+ // MAX_SESSION_RECREATE_ATTEMPTS inside SESSION_RECREATE_WINDOW_MS
1373
+ // so a permanently down admin-api fails loud.
1374
+ //
1375
+ // Race guard (triple-review P2 follow-up): the SSE transport can
1376
+ // fire onError synchronously a second time while we are tearing
1377
+ // down the dead stream inside recreateSessionSilently (the
1378
+ // streamHandle.close() call there can flush a pending error
1379
+ // synchronously in some transports). If that second 404 arrives
1380
+ // with recreatingSession === true, we must SHORT-CIRCUIT it too
1381
+ // rather than fall through to the legacy "Stream interrupted"
1382
+ // path - otherwise the operator sees the exact 404 line the
1383
+ // recreate is trying to suppress.
1384
+ if (this.isSessionNotFoundError(error)) {
1385
+ if (this.recreatingSession) {
1386
+ // Recreate already in flight - drop the duplicate 404 on the
1387
+ // floor. The first recreate will either succeed (new stream
1388
+ // opens, this dead handle is gone) or fall through to the
1389
+ // loud "keeps dropping" / "session recreate refused" paths
1390
+ // already defined in recreateSessionSilently.
1391
+ return;
1392
+ }
1393
+ this.patch({ connection: 'reconnecting' });
1394
+ void this.recreateSessionSilently();
1395
+ return;
1396
+ }
1397
+ // α6.14.4 CEO dogfood 2026-05-25 (parity with Claude Code):
1398
+ // collapse the repeated "Stream interrupted (fetch failed).
1399
+ // Reconnecting." spam. The status bar already shows
1400
+ // connection='reconnecting' AND the attempt counter; pushing
1401
+ // a fresh transcript row per attempt fills the screen with
1402
+ // noise. Only emit the system line for the FIRST drop of a
1403
+ // run; subsequent reconnects update the status bar silently
1404
+ // until either success (clears the connection state) or the
1405
+ // give-up path in scheduleReconnect prints the final hint.
1406
+ const wasOnline = this.state.connection === 'on_watch'
1407
+ || this.state.connection === 'connecting';
385
1408
  this.patch({ connection: 'reconnecting' });
386
- this.appendSystemLine(`Stream interrupted (${this.errorMessage(error)}). Reconnecting.`);
1409
+ if (wasOnline) {
1410
+ this.appendSystemLine(`Stream interrupted (${this.errorMessage(error)}). Reconnecting...`);
1411
+ }
387
1412
  this.scheduleReconnect();
388
1413
  },
389
1414
  });
390
1415
  }
1416
+ /**
1417
+ * Detect "session not found" from the SSE transport. The production
1418
+ * transport in `repl-render.tsx` wraps non-2xx responses as
1419
+ * `Error("HTTP 404 on SSE stream")`. We pattern-match on the status
1420
+ * 404 so a different transport (e.g. a test fake or a future polling
1421
+ * fallback) can surface the same intent with the same shape.
1422
+ * (α6.14.2 wave 5.)
1423
+ */
1424
+ isSessionNotFoundError(error) {
1425
+ const msg = this.errorMessage(error);
1426
+ return /\b404\b/.test(msg);
1427
+ }
1428
+ /**
1429
+ * Mint a fresh server-side session, swap the consumer to the new
1430
+ * stream URL, keep the conversation flowing. Caps at
1431
+ * MAX_SESSION_RECREATE_ATTEMPTS inside SESSION_RECREATE_WINDOW_MS so
1432
+ * a permanently down admin-api fails loud after a few seconds of
1433
+ * trying. Logged once per attempt at debug level (we surface a
1434
+ * single visible line on first auto-recreate so the operator knows
1435
+ * what happened, then stay quiet). (α6.14.2 wave 5.)
1436
+ */
1437
+ async recreateSessionSilently() {
1438
+ if (this.closed)
1439
+ return;
1440
+ if (this.recreatingSession)
1441
+ return;
1442
+ const nowMs = this.now();
1443
+ // Drop stale window entries so the cap is rolling, not cumulative.
1444
+ while (this.recentRecreateAtMs.length > 0
1445
+ && nowMs - (this.recentRecreateAtMs[0] ?? 0) > SESSION_RECREATE_WINDOW_MS) {
1446
+ this.recentRecreateAtMs.shift();
1447
+ }
1448
+ if (this.recentRecreateAtMs.length >= MAX_SESSION_RECREATE_ATTEMPTS) {
1449
+ // Cap exceeded - fall back to the loud "give up" path so the
1450
+ // operator sees something is actually wrong.
1451
+ this.appendSystemLine('Admin API session keeps dropping (HTTP 404 x3). Type /quit and `pugi resume` to retry.');
1452
+ this.patch({ connection: 'offline' });
1453
+ return;
1454
+ }
1455
+ this.recreatingSession = true;
1456
+ this.recentRecreateAtMs.push(nowMs);
1457
+ // Tear down the dead SSE handle so the next openStream() does not
1458
+ // close-over the stale sessionId.
1459
+ if (this.streamHandle) {
1460
+ this.streamHandle.close();
1461
+ this.streamHandle = undefined;
1462
+ }
1463
+ // Reset reconnect attempt + lastEventId - the new session is a
1464
+ // fresh stream, not a continuation of the dead one.
1465
+ this.reconnectAttempt = 0;
1466
+ this.lastEventId = undefined;
1467
+ // Single visible line on the FIRST auto-recreate of the window so
1468
+ // the operator knows the CLI is recovering; later recreates in
1469
+ // the same window stay silent.
1470
+ if (this.recentRecreateAtMs.length === 1) {
1471
+ this.appendSystemLine('Admin API restarted - minting a fresh session.');
1472
+ }
1473
+ try {
1474
+ const { sessionId } = await this.options.transport.createSession({
1475
+ apiUrl: this.options.apiUrl,
1476
+ apiKey: this.options.apiKey,
1477
+ workspace: this.options.workspace,
1478
+ });
1479
+ this.patch({ sessionId, connection: 'connecting' });
1480
+ this.openStream();
1481
+ }
1482
+ catch (error) {
1483
+ // The recreate POST itself failed - fall back to the existing
1484
+ // backoff reconnect so the operator still sees retry progress.
1485
+ this.appendSystemLine(`Session recreate refused (${this.errorMessage(error)}). Reconnecting.`);
1486
+ this.scheduleReconnect();
1487
+ }
1488
+ finally {
1489
+ this.recreatingSession = false;
1490
+ }
1491
+ }
391
1492
  scheduleReconnect() {
392
1493
  if (this.closed)
393
1494
  return;
@@ -405,13 +1506,38 @@ export class ReplSession {
405
1506
  }
406
1507
  /* ------------- event reducer -------------- */
407
1508
  handleServerEvent(event) {
1509
+ // R3 P1 fix (Codex triple-review 2026-05-25): wall-clock gate that
1510
+ // drops events from a SUPERSEDED dispatch. The R2 seq gate alone
1511
+ // could not catch a LATE `agent.spawned` for an old taskId arriving
1512
+ // AFTER `dispatchBrief` already bumped `dispatchSeq`. The late
1513
+ // spawn would stamp the OLD taskId with the NEW seq, so the
1514
+ // subsequent terminal event for that task looked current and
1515
+ // nulled the freshly minted token. Comparing `event.timestamp`
1516
+ // against `currentDispatchStartTime` (recorded BEFORE the seq
1517
+ // bump) catches the late event before it can corrupt the seq map
1518
+ // or drive the live FSM.
1519
+ //
1520
+ // `Date.parse` returns NaN on malformed input; we treat NaN as
1521
+ // "unknown timestamp, do not drop" so a transport bug never
1522
+ // silently swallows events. Zero `currentDispatchStartTime` means
1523
+ // no dispatch has started yet (start() path) — same fail-open.
1524
+ const eventTs = event.timestamp ? Date.parse(event.timestamp) : 0;
1525
+ if (Number.isFinite(eventTs)
1526
+ && eventTs > 0
1527
+ && this.currentDispatchStartTime > 0
1528
+ && eventTs < this.currentDispatchStartTime) {
1529
+ // Late event from a superseded dispatch. Drop silently — the
1530
+ // operator already saw the new brief land, and the new dispatch
1531
+ // owns the surface now.
1532
+ return;
1533
+ }
408
1534
  switch (event.type) {
409
1535
  case 'agent.spawned': {
410
1536
  const persona = safePersonaName(event.role);
411
1537
  // Wave 4 fix 2026-05-25: the roster collapses to one row per
412
1538
  // persona slug. The α5.7 reducer pushed a fresh row on every
413
1539
  // spawn, so after three turns the bottom panel stacked
414
- // "Mira orchestrator shipped" three times. The new contract:
1540
+ // "Pugi orchestrator shipped" three times. The new contract:
415
1541
  // - If a row already exists for this personaSlug, REUSE it.
416
1542
  // Replace its taskId, reset status to 'queued', clear the
417
1543
  // detail line, restart the duration clock, zero the token
@@ -441,26 +1567,64 @@ export class ReplSession {
441
1567
  else {
442
1568
  this.patch({ agents: [node, ...this.state.agents] });
443
1569
  }
1570
+ // R2 P1 fix (Codex triple-review 2026-05-25): stamp the live
1571
+ // dispatch sequence onto this taskId so terminal handlers can
1572
+ // tell apart a "current dispatch" event from a "superseded
1573
+ // dispatch" event. See `dispatchSeq` + `taskDispatchSeq`
1574
+ // field comments.
1575
+ this.taskDispatchSeq.set(event.taskId, this.dispatchSeq);
444
1576
  // The conversation pane already prefixes persona rows with the
445
1577
  // persona name in the persona's hue colour. Skip embedding the
446
1578
  // name in the body text to avoid the `Marcus Marcus dispatched`
447
1579
  // double-print. `void persona` keeps the resolved name in scope
448
1580
  // for the agent tree node above without leaking it into the
449
1581
  // transcript body.
1582
+ // α6.14.3 CEO dogfood 2026-05-25: drop the "dispatched (X)"
1583
+ // transcript echo. The agent tree pane already shows the
1584
+ // spawned state; printing it as a persona row is pure noise
1585
+ // between the operator's brief and the persona's real reply.
450
1586
  void persona;
451
- this.appendPersonaLine(event.personaSlug, `dispatched (${event.role}).`);
452
1587
  return;
453
1588
  }
454
1589
  case 'agent.step': {
1590
+ // α6.3 office-hours: scan the running buffer for `<pugi-ask>` /
1591
+ // `<pugi-plan-review>` envelopes BEFORE we cache the detail.
1592
+ // The parser returns the cleaned remainder with the raw XML
1593
+ // stripped, so the operator never sees the envelope as prose.
1594
+ // Streaming partial tags (open seen, close not yet streamed)
1595
+ // are kept in the buffer; the next step event extends it.
1596
+ const sanitised = this.consumeAskAndPlanReviewTags(event.taskId, event.detail);
455
1597
  // Cache the running detail per task so we can surface the
456
1598
  // model's actual reply when agent.completed lands (otherwise
457
1599
  // the reply disappears under the literal 'shipped' patch).
458
- if (event.detail && event.detail.trim().length > 0) {
459
- this.lastStepDetail.set(event.taskId, event.detail);
1600
+ if (sanitised && sanitised.trim().length > 0) {
1601
+ this.lastStepDetail.set(event.taskId, sanitised);
1602
+ }
1603
+ // α6.12: synthesise a tool call entry when the step detail
1604
+ // matches a tool-invocation grammar. The pattern is generous
1605
+ // (Read(path) / Edit(path:lines) / Bash(cmd) / Grep(pat) /
1606
+ // Glob(pat) / WebFetch(url)) so the pane has rows to render
1607
+ // before the admin-api side ships the proper tool.* SSE events.
1608
+ // Use the sanitised detail (post-tag-strip) so a `<pugi-ask>`
1609
+ // envelope never produces a phantom tool row.
1610
+ const synthesised = synthesiseToolCall({
1611
+ taskId: event.taskId,
1612
+ detail: sanitised,
1613
+ agent: this.personaSlugForTask(event.taskId),
1614
+ now: this.now(),
1615
+ });
1616
+ if (synthesised) {
1617
+ this.appendToolCall(synthesised);
1618
+ // α6.9: a fresh tool call moves the FSM to `tool_running`
1619
+ // when the dispatch is still active. The status-bar surface
1620
+ // also gets a short label (`tool: read`, `tool: bash`, etc).
1621
+ // Aborting / terminal states are not allowed to transition
1622
+ // here — we silently skip rather than throw.
1623
+ this.advanceFsmOnToolStart(synthesised.tool);
460
1624
  }
461
1625
  this.patch({
462
1626
  agents: this.state.agents.map((a) => a.taskId === event.taskId
463
- ? { ...a, status: 'thinking', detail: event.detail }
1627
+ ? { ...a, status: 'thinking', detail: sanitised || event.detail }
464
1628
  : a),
465
1629
  });
466
1630
  return;
@@ -483,11 +1647,22 @@ export class ReplSession {
483
1647
  const target = this.state.agents.find((a) => a.taskId === event.taskId);
484
1648
  const finalDetail = this.lastStepDetail.get(event.taskId);
485
1649
  this.lastStepDetail.delete(event.taskId);
1650
+ if (this.askBufferPending.has(event.taskId)) {
1651
+ this.appendSystemLine('Persona emitted incomplete <pugi-ask> or <pugi-plan-review> tag; dropped.');
1652
+ }
1653
+ this.askBuffer.delete(event.taskId);
1654
+ this.askBufferPending.delete(event.taskId);
486
1655
  this.patch({
487
1656
  agents: this.state.agents.map((a) => a.taskId === event.taskId
488
1657
  ? { ...a, status: 'shipped', detail: 'shipped' }
489
1658
  : a),
490
1659
  });
1660
+ // α6.9: transition the FSM to `completed` when no other
1661
+ // dispatch is still in flight. The check uses the agents list
1662
+ // POST-patch so any sibling task in `queued` / `thinking` keeps
1663
+ // the dispatch alive; the FSM only goes terminal when the last
1664
+ // agent ships.
1665
+ this.advanceFsmOnDispatchEnd('completed', 'agent_completed', event.taskId);
491
1666
  if (target) {
492
1667
  // If the persona actually produced a reply via incremental
493
1668
  // agent.step events, render that reply in the transcript so
@@ -500,15 +1675,34 @@ export class ReplSession {
500
1675
  if (finalDetail
501
1676
  && finalDetail !== 'queued for dispatch'
502
1677
  && finalDetail.trim().length > 4) {
503
- for (const line of finalDetail.split('\n')) {
504
- const trimmed = line.trim();
505
- if (trimmed.length > 0) {
506
- this.appendPersonaLine(target.personaSlug, trimmed);
1678
+ // α6.12: ship the WHOLE body as one transcript row when the
1679
+ // reply contains ANY Markdown structure (code fence, bullet
1680
+ // list, numbered list, headings). The conversation pane
1681
+ // routes it through Markdown renderer в one pass, preserving
1682
+ // grouped bullets + heading hierarchy. Plain prose still
1683
+ // splits per line so word-wrap stays correct.
1684
+ //
1685
+ // Claude triple-review P1 (PR #369): the prior `includes('```')`
1686
+ // gate only caught fences - multi-line bullets fragmented
1687
+ // per row showed as `▸ Mira • read PUGI.md / ▸ Mira • patched
1688
+ // bug / ...` instead of a single grouped bullet block.
1689
+ if (looksLikeMarkdown(finalDetail)) {
1690
+ this.appendPersonaLine(target.personaSlug, finalDetail);
1691
+ }
1692
+ else {
1693
+ for (const line of finalDetail.split('\n')) {
1694
+ const trimmed = line.trim();
1695
+ if (trimmed.length > 0) {
1696
+ this.appendPersonaLine(target.personaSlug, trimmed);
1697
+ }
507
1698
  }
508
1699
  }
509
1700
  }
510
1701
  else {
511
- this.appendPersonaLine(target.personaSlug, 'shipped.');
1702
+ // α6.14.3 CEO dogfood 2026-05-25: drop the literal
1703
+ // "shipped." fallback row. If we have no cached detail to
1704
+ // surface, stay silent. The agent tree pane already shows
1705
+ // the green check + duration.
512
1706
  }
513
1707
  }
514
1708
  return;
@@ -516,6 +1710,11 @@ export class ReplSession {
516
1710
  case 'agent.blocked': {
517
1711
  const target = this.state.agents.find((a) => a.taskId === event.taskId);
518
1712
  this.lastStepDetail.delete(event.taskId);
1713
+ if (this.askBufferPending.has(event.taskId)) {
1714
+ this.appendSystemLine('Persona emitted incomplete <pugi-ask> or <pugi-plan-review> tag; dropped.');
1715
+ }
1716
+ this.askBuffer.delete(event.taskId);
1717
+ this.askBufferPending.delete(event.taskId);
519
1718
  this.patch({
520
1719
  agents: this.state.agents.map((a) => a.taskId === event.taskId
521
1720
  ? { ...a, status: 'blocked', detail: event.detail }
@@ -524,11 +1723,21 @@ export class ReplSession {
524
1723
  if (target) {
525
1724
  this.appendPersonaLine(target.personaSlug, `blocked: ${event.detail}`);
526
1725
  }
1726
+ // α6.9: `blocked` is a graceful refusal, not a crash — treat it
1727
+ // as a `completed` outcome from the FSM's perspective so the
1728
+ // operator sees the bottom-bar settle back to `idle` after the
1729
+ // last block clears.
1730
+ this.advanceFsmOnDispatchEnd('completed', 'agent_blocked', event.taskId);
527
1731
  return;
528
1732
  }
529
1733
  case 'agent.failed': {
530
1734
  const target = this.state.agents.find((a) => a.taskId === event.taskId);
531
1735
  this.lastStepDetail.delete(event.taskId);
1736
+ if (this.askBufferPending.has(event.taskId)) {
1737
+ this.appendSystemLine('Persona emitted incomplete <pugi-ask> or <pugi-plan-review> tag; dropped.');
1738
+ }
1739
+ this.askBuffer.delete(event.taskId);
1740
+ this.askBufferPending.delete(event.taskId);
532
1741
  this.patch({
533
1742
  agents: this.state.agents.map((a) => a.taskId === event.taskId
534
1743
  ? { ...a, status: 'failed', detail: event.error }
@@ -537,11 +1746,121 @@ export class ReplSession {
537
1746
  if (target) {
538
1747
  this.appendPersonaLine(target.personaSlug, `failed: ${event.error}`);
539
1748
  }
1749
+ // α6.9: terminal `failed` transition when no sibling task
1750
+ // remains. Same defer-until-last-agent semantics as
1751
+ // `completed` so the bottom-bar surface tracks the dispatch
1752
+ // collectively.
1753
+ this.advanceFsmOnDispatchEnd('failed', 'agent_failed', event.taskId);
1754
+ return;
1755
+ }
1756
+ }
1757
+ }
1758
+ /**
1759
+ * α6.9 helper: advance the FSM to `tool_running` when a tool call
1760
+ * lands mid-dispatch. Guarded against terminal / aborting states so
1761
+ * a late tool event after `cancel()` does not throw on an illegal
1762
+ * transition. The `tool` label drives the bottom-bar's
1763
+ * `tool: <kind>` granularity.
1764
+ */
1765
+ advanceFsmOnToolStart(tool) {
1766
+ const current = this.fsm.current;
1767
+ if (this.fsm.isTerminal)
1768
+ return;
1769
+ if (current === 'aborting')
1770
+ return;
1771
+ if (current === 'idle')
1772
+ return;
1773
+ // Only `awaiting_response -> tool_running` is a hard move. From
1774
+ // `tool_running` we patch the label without a state transition so
1775
+ // a multi-tool turn shows the latest tool's label without churning
1776
+ // the FSM through transient idle states.
1777
+ if (current === 'awaiting_response') {
1778
+ this.fsm.transition('tool_running', `tool_${tool}`);
1779
+ }
1780
+ this.patch({ dispatchToolLabel: `tool: ${tool}` });
1781
+ }
1782
+ /**
1783
+ * α6.9 helper: advance the FSM toward a terminal outcome when the
1784
+ * LAST in-flight agent's lifecycle ends. The dispatch is "still
1785
+ * running" when any other agent in the tree is in `queued` /
1786
+ * `thinking`; the FSM only goes terminal when the last one settles.
1787
+ *
1788
+ * Idempotent + guarded against illegal transitions: a late event
1789
+ * after a manual `cancel()` finds the FSM already in `aborted` and
1790
+ * is silently dropped.
1791
+ */
1792
+ advanceFsmOnDispatchEnd(outcome, reason, taskId) {
1793
+ // R2 P1 fix (Codex triple-review 2026-05-25): a terminal event
1794
+ // for a SUPERSEDED dispatch must NOT advance the live FSM or null
1795
+ // the live token. If the event carries a taskId and the stamped
1796
+ // dispatchSeq for that task is older than the current dispatchSeq,
1797
+ // the event belongs to a prior dispatch that was replaced by a
1798
+ // newer `dispatchBrief()`. Silently drop the FSM advance.
1799
+ if (taskId !== undefined) {
1800
+ const taskSeq = this.taskDispatchSeq.get(taskId);
1801
+ if (taskSeq !== undefined && taskSeq < this.dispatchSeq) {
1802
+ // Stale dispatch event - the live dispatch is a newer turn.
1803
+ // Skip the FSM advance + token null so brief #N+1 stays
1804
+ // cancellable and the new turn's lifecycle is not corrupted.
540
1805
  return;
541
1806
  }
542
1807
  }
1808
+ if (this.fsm.isTerminal)
1809
+ return;
1810
+ if (this.fsm.current === 'aborting')
1811
+ return;
1812
+ if (this.fsm.current === 'idle')
1813
+ return;
1814
+ // Defer until every agent has settled so a multi-agent dispatch
1815
+ // collectively transitions once on the LAST settle.
1816
+ const stillActive = this.state.agents.some((a) => a.status === 'queued' || a.status === 'thinking');
1817
+ if (stillActive)
1818
+ return;
1819
+ // From `tool_running` we must walk through `awaiting_response`
1820
+ // first (the legal-transition matrix forbids
1821
+ // tool_running -> completed). The intermediate step is a one-tick
1822
+ // pass through and immediately settles to terminal.
1823
+ if (this.fsm.current === 'tool_running') {
1824
+ this.fsm.transition('awaiting_response', `${reason}_drained`);
1825
+ }
1826
+ this.fsm.transition(outcome, reason);
1827
+ this.currentDispatchToken = null;
1828
+ this.patch({ briefStartedAtEpochMs: undefined });
543
1829
  }
544
1830
  /* ------------- transcript helpers -------------- */
1831
+ /**
1832
+ * Look up the persona slug for a running task. Used by the tool call
1833
+ * synthesiser to colour the tool stream rows correctly. Falls back to
1834
+ * the literal `unknown` slug when the task is not in the agent tree
1835
+ * (the SSE wire can emit a step before the matching spawn under heavy
1836
+ * load - rare in practice but the pane stays robust).
1837
+ */
1838
+ personaSlugForTask(taskId) {
1839
+ const agent = this.state.agents.find((a) => a.taskId === taskId);
1840
+ return agent?.personaSlug ?? 'unknown';
1841
+ }
1842
+ /**
1843
+ * Fold a tool call entry into the rolling list. If the entry id
1844
+ * already exists, replace it in-place (so a synthesised `running` →
1845
+ * `ok` transition reuses the same row). Otherwise append. The list
1846
+ * is capped at `MAX_TOOL_CALLS` so a long-running session does not
1847
+ * leak memory.
1848
+ */
1849
+ appendToolCall(entry) {
1850
+ const existingIndex = this.state.toolCalls.findIndex((c) => c.id === entry.id);
1851
+ let next;
1852
+ if (existingIndex >= 0) {
1853
+ next = this.state.toolCalls.slice();
1854
+ next[existingIndex] = entry;
1855
+ }
1856
+ else {
1857
+ next = this.state.toolCalls.concat(entry);
1858
+ }
1859
+ if (next.length > MAX_TOOL_CALLS) {
1860
+ next = next.slice(-MAX_TOOL_CALLS);
1861
+ }
1862
+ this.patch({ toolCalls: next });
1863
+ }
545
1864
  appendOperatorLine(text) {
546
1865
  this.appendRow({ source: 'operator', text });
547
1866
  }
@@ -549,7 +1868,19 @@ export class ReplSession {
549
1868
  this.appendRow({ source: 'system', text });
550
1869
  }
551
1870
  appendPersonaLine(personaSlug, text) {
552
- this.appendRow({ source: 'persona', text, personaSlug });
1871
+ // α6.14.2 wave 5: dedup the persona display-name prefix. The
1872
+ // conversation pane already renders `▸ <DisplayName> <text>` from
1873
+ // the slug → name map; when the model's own reply begins with
1874
+ // the same display name (CEO 2026-05-25 screenshot: "Pugi Pugi,
1875
+ // координатор Pugi"), the operator sees the name twice. Strip
1876
+ // the leading display-name token (with optional trailing comma /
1877
+ // colon / whitespace) so the prefix the pane adds is the only one
1878
+ // visible. We also drop any leaked `<workspace-context-NONCE>`
1879
+ // wrapper the model sometimes echoes back at the head of its
1880
+ // first turn - that envelope is for prompt scaffolding, not for
1881
+ // the operator's eyes.
1882
+ const stripped = stripPersonaPrefixEcho(personaSlug, text);
1883
+ this.appendRow({ source: 'persona', text: stripped, personaSlug });
553
1884
  }
554
1885
  appendRow(input) {
555
1886
  if (input.text.length === 0)
@@ -563,6 +1894,161 @@ export class ReplSession {
563
1894
  };
564
1895
  const next = this.state.transcript.concat(row).slice(-MAX_TRANSCRIPT_ROWS);
565
1896
  this.patch({ transcript: next });
1897
+ // Mirror into the local SessionStore so `/resume` can replay.
1898
+ // Persistence is fail-safe: a single error becomes one system
1899
+ // line, subsequent errors are silent so a stuck disk does not
1900
+ // flood the operator. The mapping from row.source -> store kind:
1901
+ // operator -> 'user' (drives turn_count + title)
1902
+ // persona -> 'persona'
1903
+ // system -> 'system'
1904
+ this.persistRow(row);
1905
+ }
1906
+ /**
1907
+ * Best-effort write of one transcript row into the local
1908
+ * SessionStore. Swallows errors after emitting one system line so a
1909
+ * broken store never blocks the conversation. Public callers go
1910
+ * through `appendRow` - this method is private on purpose.
1911
+ */
1912
+ persistRow(row) {
1913
+ if (!this.store)
1914
+ return;
1915
+ const kind = row.source === 'operator' ? 'user'
1916
+ : row.source === 'persona' ? 'persona'
1917
+ : 'system';
1918
+ const payload = row.source === 'persona'
1919
+ ? { text: row.text, personaSlug: row.personaSlug }
1920
+ : row.source === 'operator'
1921
+ ? { brief: row.text }
1922
+ : { text: row.text };
1923
+ const event = { t: row.timestampEpochMs, kind, payload };
1924
+ void this.store.appendEvent(event).catch((error) => {
1925
+ if (this.storeErrorEmitted)
1926
+ return;
1927
+ this.storeErrorEmitted = true;
1928
+ const msg = error instanceof Error ? error.message : String(error);
1929
+ // Use appendRow directly via state patch so we don't recurse
1930
+ // into persistRow (which would loop on a stuck store).
1931
+ const errRow = {
1932
+ id: randomUUID(),
1933
+ source: 'system',
1934
+ text: `Local session persistence failed: ${msg}. Conversation continues in-memory only.`,
1935
+ timestampEpochMs: this.now(),
1936
+ };
1937
+ const next = this.state.transcript.concat(errRow).slice(-MAX_TRANSCRIPT_ROWS);
1938
+ this.patch({ transcript: next });
1939
+ });
1940
+ }
1941
+ /**
1942
+ * Restore a transcript from a stored event log - α6.4. Called by
1943
+ * the CLI bootstrap when the operator runs `pugi resume <id>` or
1944
+ * picks an entry from the `/resume` picker. Replays each event into
1945
+ * the local transcript WITHOUT writing back to the store so the
1946
+ * restore is idempotent.
1947
+ *
1948
+ * Implementation note: we briefly disable persistence by setting
1949
+ * `storeErrorEmitted` BEFORE the replay and clearing it after - but
1950
+ * the cleaner path is to bypass `appendRow` entirely and patch
1951
+ * state directly. We do the latter so persistRow does not double-
1952
+ * write the restored events.
1953
+ */
1954
+ restoreTranscript(events) {
1955
+ const rows = [];
1956
+ for (const event of events) {
1957
+ const row = eventToTranscriptRow(event);
1958
+ if (row)
1959
+ rows.push(row);
1960
+ }
1961
+ // Cap at MAX_TRANSCRIPT_ROWS - the same cap appendRow uses so the
1962
+ // window math stays consistent post-restore.
1963
+ const capped = rows.slice(-MAX_TRANSCRIPT_ROWS);
1964
+ this.patch({ transcript: capped });
1965
+ }
1966
+ /**
1967
+ * Local session id used as the persistence key. Surfaced to the
1968
+ * CLI bootstrap so `pugi resume` listings can pin the id without
1969
+ * pulling it out of internal state.
1970
+ */
1971
+ getLocalSessionId() {
1972
+ return this.localSessionId;
1973
+ }
1974
+ /* ------------- α6.3 buffered tag detection -------------- */
1975
+ /**
1976
+ * Scan the running `agent.step.detail` buffer for `<pugi-ask>` /
1977
+ * `<pugi-plan-review>` envelopes. If a complete envelope is found,
1978
+ * the parser strips it from the visible body and sets the matching
1979
+ * `pendingAsk` / `pendingPlanReview` state so the REPL can render
1980
+ * the modal. Streaming partial tags (open observed, close not yet
1981
+ * arrived) are kept in the buffer so the next step event can extend
1982
+ * them.
1983
+ *
1984
+ * Returns the sanitised body the caller should treat as the
1985
+ * persona's prose. May be empty when the entire body was tag XML;
1986
+ * the caller then leaves `lastStepDetail` untouched and the
1987
+ * `agent.completed` fallback ("shipped.") fires.
1988
+ */
1989
+ consumeAskAndPlanReviewTags(taskId, detail) {
1990
+ if (!detail || detail.length === 0) {
1991
+ return this.askBuffer.get(taskId) ?? '';
1992
+ }
1993
+ // The persona emits the running detail as a cumulative string, so
1994
+ // a fresh `agent.step` carries the full body up to the current
1995
+ // token (matches the wave-2 caching contract above). We pass the
1996
+ // raw detail through the extractors directly rather than keeping a
1997
+ // separate buffer - but we still record the pre-extraction body so
1998
+ // a partial open tag is preserved when the next chunk arrives.
1999
+ this.askBuffer.set(taskId, detail);
2000
+ const askResult = extractAskTags(detail);
2001
+ let working = askResult.cleaned;
2002
+ for (const tag of askResult.tags) {
2003
+ if (this.seenTagSignatures.includes(tag.signature))
2004
+ continue;
2005
+ this.recordSeenTag(tag.signature);
2006
+ // Only one pending ask at a time - drop additional tags in the
2007
+ // same step into the cleaned body as a system warning. The
2008
+ // persona's prompt forbids concurrent asks, so this branch is a
2009
+ // defensive guard against a misbehaving model.
2010
+ if (this.state.pendingAsk) {
2011
+ this.appendSystemLine('Persona emitted a second <pugi-ask> while one was already open. Dropped.');
2012
+ continue;
2013
+ }
2014
+ this.patch({ pendingAsk: tag, pendingAskSource: 'persona' });
2015
+ }
2016
+ if (askResult.hadMalformedTag) {
2017
+ this.appendSystemLine('Malformed <pugi-ask> dropped (parser refusal).');
2018
+ }
2019
+ const planResult = extractPlanReviewTags(working);
2020
+ working = planResult.cleaned;
2021
+ for (const tag of planResult.tags) {
2022
+ if (this.seenTagSignatures.includes(tag.signature))
2023
+ continue;
2024
+ this.recordSeenTag(tag.signature);
2025
+ if (this.state.pendingPlanReview) {
2026
+ this.appendSystemLine('Persona emitted a second <pugi-plan-review> while one was already open. Dropped.');
2027
+ continue;
2028
+ }
2029
+ this.patch({ pendingPlanReview: tag, pendingPlanReviewSource: 'persona' });
2030
+ }
2031
+ if (planResult.hadMalformedTag) {
2032
+ this.appendSystemLine('Malformed <pugi-plan-review> dropped (parser refusal).');
2033
+ }
2034
+ // Record / clear the "pending open tag" flag so agent.completed can
2035
+ // emit a warning if the persona ends the turn with an unfinished
2036
+ // envelope. The flag flips OFF when both parsers report no
2037
+ // outstanding open tag - if either is still pending, we keep it on
2038
+ // so the warning fires once at turn end.
2039
+ if (askResult.pendingOpenTag || planResult.pendingOpenTag) {
2040
+ this.askBufferPending.add(taskId);
2041
+ }
2042
+ else {
2043
+ this.askBufferPending.delete(taskId);
2044
+ }
2045
+ return working;
2046
+ }
2047
+ recordSeenTag(signature) {
2048
+ this.seenTagSignatures.push(signature);
2049
+ while (this.seenTagSignatures.length > 32) {
2050
+ this.seenTagSignatures.shift();
2051
+ }
566
2052
  }
567
2053
  /* ------------- agent count + clock -------------- */
568
2054
  activeAgentCount() {
@@ -599,6 +2085,89 @@ export class ReplSession {
599
2085
  * does not recognise, the REPL still renders something usable rather
600
2086
  * than crashing mid-frame.
601
2087
  */
2088
+ /**
2089
+ * Map a stored SessionEvent back into a TranscriptRow for `/resume`
2090
+ * replay. Returns null when the event has no operator-visible body
2091
+ * (e.g. tool.start without a text payload - those land back as
2092
+ * tool stream rows, not transcript rows). The shape mirrors the
2093
+ * `persistRow` mapping in reverse:
2094
+ *
2095
+ * 'user' -> operator (brief)
2096
+ * 'persona' -> persona (text + personaSlug)
2097
+ * 'system' -> system (text)
2098
+ *
2099
+ * Exported indirectly via `restoreTranscript`.
2100
+ */
2101
+ function eventToTranscriptRow(event) {
2102
+ const payload = (event.payload ?? null);
2103
+ if (event.kind === 'user') {
2104
+ const text = typeof payload?.brief === 'string'
2105
+ ? payload.brief
2106
+ : typeof payload?.text === 'string'
2107
+ ? payload.text
2108
+ : '';
2109
+ if (text.length === 0)
2110
+ return null;
2111
+ return {
2112
+ id: randomUUID(),
2113
+ source: 'operator',
2114
+ text,
2115
+ timestampEpochMs: event.t,
2116
+ };
2117
+ }
2118
+ if (event.kind === 'persona') {
2119
+ const text = typeof payload?.text === 'string' ? payload.text : '';
2120
+ if (text.length === 0)
2121
+ return null;
2122
+ const personaSlug = typeof payload?.personaSlug === 'string'
2123
+ ? payload.personaSlug
2124
+ : undefined;
2125
+ return {
2126
+ id: randomUUID(),
2127
+ source: 'persona',
2128
+ text,
2129
+ personaSlug,
2130
+ timestampEpochMs: event.t,
2131
+ };
2132
+ }
2133
+ if (event.kind === 'system') {
2134
+ const text = typeof payload?.text === 'string' ? payload.text : '';
2135
+ if (text.length === 0)
2136
+ return null;
2137
+ return {
2138
+ id: randomUUID(),
2139
+ source: 'system',
2140
+ text,
2141
+ timestampEpochMs: event.t,
2142
+ };
2143
+ }
2144
+ return null;
2145
+ }
2146
+ /**
2147
+ * Heuristic: does this text contain Markdown structures that benefit
2148
+ * from atomic grouping? Code fences, bullet lists, numbered lists,
2149
+ * headings - anything where per-line splitting would fragment visual
2150
+ * grouping (Claude triple-review P1 PR #369).
2151
+ */
2152
+ function looksLikeMarkdown(text) {
2153
+ if (text.includes('```'))
2154
+ return true;
2155
+ const lines = text.split('\n');
2156
+ let bulletCount = 0;
2157
+ let numberedCount = 0;
2158
+ let headingCount = 0;
2159
+ for (const raw of lines) {
2160
+ const line = raw.trim();
2161
+ if (/^[-*+]\s+\S/.test(line))
2162
+ bulletCount += 1;
2163
+ if (/^\d+\.\s+\S/.test(line))
2164
+ numberedCount += 1;
2165
+ if (/^#{1,6}\s+\S/.test(line))
2166
+ headingCount += 1;
2167
+ }
2168
+ // 2+ bullets OR 2+ numbered OR any heading = group atomically.
2169
+ return bulletCount >= 2 || numberedCount >= 2 || headingCount >= 1;
2170
+ }
602
2171
  function safePersonaName(role) {
603
2172
  try {
604
2173
  return getPersonaForRole(role).name;
@@ -607,6 +2176,31 @@ function safePersonaName(role) {
607
2176
  return role;
608
2177
  }
609
2178
  }
2179
+ /**
2180
+ * Render a millisecond delta as a compact human-readable age. Used by
2181
+ * `/context` to surface the oldest working-set entry's age:
2182
+ *
2183
+ * < 60s -> `45s`
2184
+ * < 1h -> `4m`
2185
+ * < 24h -> `2h`
2186
+ * >= 24h -> `3d`
2187
+ *
2188
+ * Negative deltas (clock skew) clamp to `0s`.
2189
+ */
2190
+ function formatAgeSeconds(deltaMs) {
2191
+ const ms = Math.max(0, deltaMs);
2192
+ const seconds = Math.floor(ms / 1000);
2193
+ if (seconds < 60)
2194
+ return `${seconds}s`;
2195
+ const minutes = Math.floor(seconds / 60);
2196
+ if (minutes < 60)
2197
+ return `${minutes}m`;
2198
+ const hours = Math.floor(minutes / 60);
2199
+ if (hours < 24)
2200
+ return `${hours}h`;
2201
+ const days = Math.floor(hours / 24);
2202
+ return `${days}d`;
2203
+ }
610
2204
  /**
611
2205
  * Convenience: list the legal role slugs the operator can target with
612
2206
  * `/stop`. Surfaced in the slash command help overlay and in the
@@ -615,4 +2209,305 @@ function safePersonaName(role) {
615
2209
  export function knownRoles() {
616
2210
  return listRoles();
617
2211
  }
2212
+ /* ------------------------------------------------------------------ */
2213
+ /* Tool call synthesiser - α6.12 */
2214
+ /* ------------------------------------------------------------------ */
2215
+ /**
2216
+ * Match canonical tool invocation grammar in an `agent.step.detail`
2217
+ * string and emit a synthesised `ToolCallEntry`. Returns null when no
2218
+ * known tool pattern matches.
2219
+ *
2220
+ * The grammar mirrors the way Claude Code, Codex CLI, and Gemini CLI
2221
+ * display tool calls in their tool stream panes:
2222
+ *
2223
+ * Read(path)
2224
+ * Edit(path[:lines])
2225
+ * Bash(command)
2226
+ * Grep("pattern" [in path])
2227
+ * Glob(pattern)
2228
+ * WebFetch(url)
2229
+ *
2230
+ * The matcher is case-insensitive on the tool name so a persona that
2231
+ * spells the tool as `READ(...)` or `web_fetch(...)` still lands in
2232
+ * the pane. Args are capped at 80 characters; the pane will further
2233
+ * truncate to 60 on render so the row stays single-line on a narrow
2234
+ * terminal.
2235
+ *
2236
+ * Exported for unit testing - production code path is internal.
2237
+ */
2238
+ export function synthesiseToolCall(input) {
2239
+ const detail = input.detail.trim();
2240
+ if (detail.length === 0)
2241
+ return null;
2242
+ // Pattern: ToolName(args) optionally suffixed with a result hint.
2243
+ // We allow the canonical Claude Code casing AND the snake_case
2244
+ // alias `web_fetch` so the synthesiser matches what personas write.
2245
+ const match = /^(Read|Edit|Bash|Grep|Glob|WebFetch|web_fetch)\s*\(\s*([^)]*)\s*\)\s*(.*)$/i
2246
+ .exec(detail);
2247
+ if (!match)
2248
+ return null;
2249
+ const toolName = normaliseToolName(match[1]);
2250
+ const args = (match[2] ?? '').trim().slice(0, 80);
2251
+ const tail = (match[3] ?? '').trim();
2252
+ const status = parseStatusFromTail(tail);
2253
+ return {
2254
+ id: `${input.taskId}:${toolName}:${args}`,
2255
+ agent: input.agent,
2256
+ tool: toolName,
2257
+ args,
2258
+ status: status.status,
2259
+ detail: status.detail,
2260
+ startedAtEpochMs: input.now,
2261
+ };
2262
+ }
2263
+ function normaliseToolName(raw) {
2264
+ const lower = raw.toLowerCase();
2265
+ if (lower === 'webfetch' || lower === 'web_fetch')
2266
+ return 'web_fetch';
2267
+ if (lower === 'read')
2268
+ return 'read';
2269
+ if (lower === 'edit')
2270
+ return 'edit';
2271
+ if (lower === 'bash')
2272
+ return 'bash';
2273
+ if (lower === 'grep')
2274
+ return 'grep';
2275
+ if (lower === 'glob')
2276
+ return 'glob';
2277
+ // Unreachable - regex constrains the input. Fallback keeps types happy.
2278
+ return 'read';
2279
+ }
2280
+ /**
2281
+ * Cheap status inference from the tail string. We honour explicit
2282
+ * `OK` / `error` / `running` prefixes the dispatcher may write, plus
2283
+ * a `+/-` diff hint (treated as `ok`) and a `no match` (treated as
2284
+ * `ok` because grep with no result is not an error condition).
2285
+ */
2286
+ function parseStatusFromTail(tail) {
2287
+ if (tail.length === 0)
2288
+ return { status: 'running' };
2289
+ const lower = tail.toLowerCase();
2290
+ if (lower.startsWith('error') || lower.startsWith('failed')) {
2291
+ return { status: 'error', detail: tail };
2292
+ }
2293
+ if (lower.startsWith('running')) {
2294
+ return { status: 'running', detail: tail };
2295
+ }
2296
+ return { status: 'ok', detail: tail };
2297
+ }
2298
+ /* ------------------------------------------------------------------ */
2299
+ /* α6.3 office-hours encoders */
2300
+ /* */
2301
+ /* Mirrors `tui/ask-modal.tsx#encodeAskVerdict` so the session can */
2302
+ /* synthesise the operator-side echo without dragging an Ink module */
2303
+ /* into the test surface. The two encoders MUST agree byte-for-byte - */
2304
+ /* a divergence would silently mis-prefix the persona's follow-up. */
2305
+ /* ------------------------------------------------------------------ */
2306
+ function encodeAskVerdictLocal(verdict) {
2307
+ if (verdict.cancelled)
2308
+ return '[ASK-RESPONSE:cancelled]';
2309
+ if (verdict.value.length > 0)
2310
+ return `[ASK-RESPONSE:${verdict.value}]`;
2311
+ if (verdict.customInput && verdict.customInput.length > 0) {
2312
+ return `[ASK-RESPONSE:other] ${verdict.customInput}`;
2313
+ }
2314
+ return '[ASK-RESPONSE:cancelled]';
2315
+ }
2316
+ /**
2317
+ * Strip any leading `[ASK-RESPONSE:...]` or `[PLAN-VERDICT:...]`
2318
+ * pattern from free-text operator input so a malicious or accidental
2319
+ * operator string cannot forge a verdict header. Stripping iterates
2320
+ * because the operator could prepend several forged headers in a row;
2321
+ * we keep peeling until the head is clean.
2322
+ *
2323
+ * Example: operator types `[ASK-RESPONSE:vercel] my real answer` -
2324
+ * the leading `[ASK-RESPONSE:vercel] ` is stripped, leaving
2325
+ * `my real answer`, so the encoded wire string becomes
2326
+ * `[ASK-RESPONSE:other] my real answer` rather than
2327
+ * `[ASK-RESPONSE:other] [ASK-RESPONSE:vercel] my real answer` which
2328
+ * a prefix-greedy persona could read as "operator chose vercel".
2329
+ *
2330
+ * Claude triple-review P1 (PR #375).
2331
+ */
2332
+ function sanitiseVerdictText(raw) {
2333
+ let cleaned = raw;
2334
+ // Bounded loop: each iteration must strip a non-empty pattern, so it
2335
+ // terminates in O(input length). Hard cap as defence-in-depth in case
2336
+ // the regex ever matches an empty span.
2337
+ for (let i = 0; i < raw.length + 4; i += 1) {
2338
+ const stripped = cleaned.replace(/^\s*\[(?:ASK-RESPONSE|PLAN-VERDICT):[^\]]*\]\s*/u, '');
2339
+ if (stripped === cleaned)
2340
+ break;
2341
+ cleaned = stripped;
2342
+ }
2343
+ return cleaned.trim();
2344
+ }
2345
+ function sanitiseAskVerdict(verdict) {
2346
+ if (verdict.customInput === undefined)
2347
+ return verdict;
2348
+ const sanitisedCustom = sanitiseVerdictText(verdict.customInput);
2349
+ if (sanitisedCustom === verdict.customInput)
2350
+ return verdict;
2351
+ // If sanitisation emptied the buffer, treat the verdict as a
2352
+ // cancellation rather than dispatching a meaningless "other" with no
2353
+ // body. Preserves the dispatch invariant (no empty bodies) and
2354
+ // matches the encoder's fallback.
2355
+ if (sanitisedCustom.length === 0) {
2356
+ return { value: '', cancelled: true };
2357
+ }
2358
+ return { ...verdict, customInput: sanitisedCustom };
2359
+ }
2360
+ function sanitisePlanReviewResult(result) {
2361
+ if (result.verdict !== 'modify')
2362
+ return result;
2363
+ if (result.modifyText === undefined)
2364
+ return result;
2365
+ const sanitisedText = sanitiseVerdictText(result.modifyText);
2366
+ if (sanitisedText === result.modifyText)
2367
+ return result;
2368
+ if (sanitisedText.length === 0) {
2369
+ return { verdict: 'cancel' };
2370
+ }
2371
+ return { verdict: 'modify', modifyText: sanitisedText };
2372
+ }
2373
+ function encodePlanReviewVerdictLocal(result) {
2374
+ switch (result.verdict) {
2375
+ case 'approve':
2376
+ return '[PLAN-VERDICT:approve]';
2377
+ case 'cancel':
2378
+ return '[PLAN-VERDICT:cancel]';
2379
+ case 'modify':
2380
+ if (result.modifyText && result.modifyText.length > 0) {
2381
+ return `[PLAN-VERDICT:modify] ${result.modifyText}`;
2382
+ }
2383
+ return '[PLAN-VERDICT:cancel]';
2384
+ }
2385
+ }
2386
+ /**
2387
+ * Compose the human-readable transcript line that records the
2388
+ * operator's ask verdict. Mirrors Codex CLI's "you chose: <label>"
2389
+ * pattern so the conversation reads linearly.
2390
+ */
2391
+ function humanLabelForVerdict(tag, verdict) {
2392
+ if (verdict.cancelled)
2393
+ return '(cancelled the question)';
2394
+ if (verdict.value.length > 0) {
2395
+ const opt = tag.options.find((o) => o.value === verdict.value);
2396
+ return opt ? `chose: ${opt.label}` : `chose: ${verdict.value}`;
2397
+ }
2398
+ if (verdict.customInput && verdict.customInput.length > 0) {
2399
+ return `chose: other - ${verdict.customInput}`;
2400
+ }
2401
+ return '(cancelled the question)';
2402
+ }
2403
+ function humanLabelForPlanReviewVerdict(result) {
2404
+ switch (result.verdict) {
2405
+ case 'approve':
2406
+ return 'approved the plan';
2407
+ case 'cancel':
2408
+ return 'cancelled the plan';
2409
+ case 'modify':
2410
+ if (result.modifyText && result.modifyText.length > 0) {
2411
+ return `modified the plan: ${result.modifyText}`;
2412
+ }
2413
+ return 'cancelled the plan';
2414
+ }
2415
+ }
2416
+ /**
2417
+ * Synthesise a 2-option yes/no `<pugi-ask>` tag from a raw question
2418
+ * string. Used by the `/ask` slash command and by `pugi ask <question>`
2419
+ * to give the operator a manual entrypoint into the office-hours UI
2420
+ * without needing a persona-side emission.
2421
+ *
2422
+ * Returns null when the question fails the parser's length cap so the
2423
+ * caller can surface a clear error rather than crashing the modal.
2424
+ */
2425
+ export function synthesiseLocalAskTag(question) {
2426
+ const trimmed = question.trim();
2427
+ if (trimmed.length === 0 || trimmed.length > 80)
2428
+ return null;
2429
+ const options = [
2430
+ { value: 'yes', label: 'Yes' },
2431
+ { value: 'no', label: 'No' },
2432
+ ];
2433
+ // Use the single-source signature helper so a persona-emitted ask
2434
+ // with the same question + same option values does not collide with
2435
+ // this synthesised one under a divergent algorithm. Claude
2436
+ // triple-review P1 (PR #375).
2437
+ const signature = signatureForAsk(trimmed, options);
2438
+ return {
2439
+ question: trimmed,
2440
+ options,
2441
+ signature,
2442
+ start: 0,
2443
+ end: 0,
2444
+ };
2445
+ }
2446
+ /**
2447
+ * Strip the persona's own display name from the head of a streamed
2448
+ * reply, plus any leaked `<workspace-context-...>` envelope the model
2449
+ * may echo on its first turn. Exported for direct unit testing —
2450
+ * production callers go through `appendPersonaLine`.
2451
+ *
2452
+ * Examples (display name = "Pugi"):
2453
+ * "Pugi, координатор Pugi. Брифую..." -> "координатор Pugi. Брифую..."
2454
+ * "Pugi: вот результат" -> "вот результат"
2455
+ * "<workspace-context-abc>Pugi, привет" -> "привет"
2456
+ * "обычный ответ без префикса" -> "обычный ответ без префикса"
2457
+ *
2458
+ * The strip is conservative - we only remove the display name when it
2459
+ * is followed by a separator (comma, colon, dash, space) so a sentence
2460
+ * that legitimately contains the name mid-text ("спроси у Pugi") is
2461
+ * not mangled. (α6.14.2 wave 5 - CEO dogfood fix.)
2462
+ */
2463
+ export function stripPersonaPrefixEcho(personaSlug, text) {
2464
+ let working = text.trimStart();
2465
+ // Drop any leaked `<workspace-context-...>` / `</workspace-context-...>`
2466
+ // wrapper at the head. The Mira prompt v1.1 sometimes echoes the
2467
+ // scaffolding envelope back when the model is warm-starting the
2468
+ // first turn; cosmetic noise the operator never needs to see.
2469
+ // We strip both opening tag and any text up to (and including) the
2470
+ // matching closing tag if present, else just the opening tag.
2471
+ const openMatch = /^<workspace-context[^>]*>/i.exec(working);
2472
+ if (openMatch) {
2473
+ working = working.slice(openMatch[0].length).trimStart();
2474
+ const closeMatch = /^([\s\S]*?)<\/workspace-context[^>]*>/i.exec(working);
2475
+ if (closeMatch) {
2476
+ working = working.slice(closeMatch[0].length).trimStart();
2477
+ }
2478
+ }
2479
+ // Resolve the display name from the canonical roster. Unknown slugs
2480
+ // (forward-compat with future personas streamed by a newer server)
2481
+ // skip the strip - better to leave the text alone than to mis-strip.
2482
+ const persona = getPersona(personaSlug);
2483
+ if (!persona)
2484
+ return working;
2485
+ const display = persona.name;
2486
+ if (!display || display.length === 0)
2487
+ return working;
2488
+ // Match `<DisplayName>` followed by an end-of-string, or by a
2489
+ // separator (comma, colon, dash, period followed by space, single
2490
+ // space). The match is case-insensitive so "pugi" also strips.
2491
+ // Escape regex specials in the display name even though THE_TEN
2492
+ // names are alpha-only today (forward-defense).
2493
+ const escaped = display.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
2494
+ const re = new RegExp(`^${escaped}(?:[\\s,:;\\-—–]+|$)`, 'i');
2495
+ // Loop the strip so cascading echoes ("Pugi Pugi Pugi, координатор ...")
2496
+ // collapse to a single name. The model occasionally emits the display
2497
+ // name two or three times back-to-back when the pane prefix also
2498
+ // injects "▸ Pugi"; without the loop, only the first token would be
2499
+ // peeled and the operator would still see "▸ Pugi Pugi, координатор".
2500
+ // Cap at 3 iterations - beyond that the text is either pathological
2501
+ // or unrelated and we should not keep chewing it. Bail when an
2502
+ // iteration makes no progress to avoid infinite loops on a regex that
2503
+ // matches an empty string (defence-in-depth even though the current
2504
+ // pattern guarantees at least one consumed char).
2505
+ for (let i = 0; i < 3; i += 1) {
2506
+ const m = re.exec(working);
2507
+ if (!m || m[0].length === 0)
2508
+ break;
2509
+ working = working.slice(m[0].length).trimStart();
2510
+ }
2511
+ return working;
2512
+ }
618
2513
  //# sourceMappingURL=session.js.map