@pugi/cli 0.1.0-alpha.10 → 0.1.0-alpha.16

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.
@@ -27,18 +27,32 @@
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';
38
40
  const MAX_TRANSCRIPT_ROWS = 500;
41
+ const MAX_TOOL_CALLS = 200;
39
42
  const MAX_RECONNECT_ATTEMPTS = 10;
40
43
  const RECONNECT_BASE_MS = 250;
41
44
  const RECONNECT_MAX_MS = 5_000;
45
+ /**
46
+ * Cap on silent session-recreate attempts on HTTP 404 from the SSE
47
+ * stream. When admin-api restarts it drops its in-memory session
48
+ * store, so the saved sessionId returns 404 on every subscribe. The
49
+ * CLI mints a fresh server session, swaps the consumer over, and
50
+ * keeps running — but we cap the recovery to 3 attempts inside 60s
51
+ * so a truly down admin-api fails loud instead of spinning forever.
52
+ * (α6.14.2 wave 5 — CEO dogfood fix.)
53
+ */
54
+ const MAX_SESSION_RECREATE_ATTEMPTS = 3;
55
+ const SESSION_RECREATE_WINDOW_MS = 60_000;
42
56
  export class ReplSession {
43
57
  options;
44
58
  subscribers = new Set();
@@ -48,6 +62,22 @@ export class ReplSession {
48
62
  reconnectAttempt = 0;
49
63
  reconnectTimer;
50
64
  closed = false;
65
+ /**
66
+ * Rolling window of recent silent-recreate timestamps (epoch ms).
67
+ * The SSE stream returns HTTP 404 when admin-api has restarted and
68
+ * lost its in-memory session store; rather than spam the operator
69
+ * with "Stream interrupted (HTTP 404)" loops, we mint a fresh
70
+ * session and swap the consumer. Capped at MAX_SESSION_RECREATE_*
71
+ * inside SESSION_RECREATE_WINDOW_MS so a permanently down admin-api
72
+ * fails loud instead of looping silently. (α6.14.2 wave 5.)
73
+ */
74
+ recentRecreateAtMs = [];
75
+ /**
76
+ * True while a session-recreate POST is in flight. Guards against
77
+ * the SSE stream firing multiple `onError(404)` callbacks racing
78
+ * the in-flight createSession promise. (α6.14.2 wave 5.)
79
+ */
80
+ recreatingSession = false;
51
81
  /**
52
82
  * Last non-trivial step.detail recorded per taskId. The server streams
53
83
  * the persona reply incrementally via `agent.step` events whose
@@ -60,17 +90,74 @@ export class ReplSession {
60
90
  * said. CEO wave-2 fix 2026-05-25.
61
91
  */
62
92
  lastStepDetail = new Map();
93
+ /**
94
+ * Optional local SessionStore — α6.4. When non-null, every
95
+ * appendRow() call mirrors the row into the JSONL log so the
96
+ * conversation can be restored via `/resume`. Errors from the store
97
+ * are swallowed to a single system line (degradation, not crash).
98
+ * The store is opened by the CLI bootstrap and closed via
99
+ * `ReplSession.close()`. The store ownership is shared — the
100
+ * SqliteSessionStore is process-wide singleton-ish under the
101
+ * lockfile, so close-on-quit is safe.
102
+ */
103
+ store;
104
+ /**
105
+ * Local session id used as the persistence key. Distinct from the
106
+ * server-side sessionId issued by admin-api in state.sessionId.
107
+ * When the operator runs `pugi resume <id>`, the CLI passes the id
108
+ * via `localSessionId` so the JSONL log keeps growing under the
109
+ * original id rather than fragmenting into a new one.
110
+ */
111
+ localSessionId;
112
+ /**
113
+ * One-shot guard so a store error only emits ONE system line per
114
+ * session — without this, a stuck filesystem would spam the operator
115
+ * with `[store]` errors on every keystroke.
116
+ */
117
+ storeErrorEmitted = false;
118
+ /**
119
+ * Rolling dedupe set for `<pugi-ask>` and `<pugi-plan-review>`
120
+ * signatures. The persona may emit the same envelope twice on network
121
+ * retry; we suppress the duplicate so the operator does not see two
122
+ * stacked modals. Capped at 32 entries — generous for a real session,
123
+ * defensive against a hostile flood. (α6.3.)
124
+ */
125
+ seenTagSignatures = [];
126
+ /**
127
+ * Per-task buffer for streaming tag detection. The persona's
128
+ * `<pugi-ask>` open and close tags may arrive in separate
129
+ * `agent.step` events when the upstream LLM token-streams output
130
+ * char-by-char. We accumulate the running detail per taskId until a
131
+ * complete envelope lands OR the turn ends. (α6.3.)
132
+ */
133
+ askBuffer = new Map();
134
+ /**
135
+ * Tracks taskIds that had an `<pugi-ask>` or `<pugi-plan-review>`
136
+ * envelope mid-stream the last time the parser ran on the buffer. If
137
+ * the turn ends with this flag still set, we emit a system-line
138
+ * warning that the persona produced an incomplete tag - the partial
139
+ * XML is silently dropped (the parser already withheld it from the
140
+ * cleaned body). Codex triple-review P2 (PR #375).
141
+ */
142
+ askBufferPending = new Set();
63
143
  constructor(options) {
64
144
  this.options = options;
145
+ this.store = options.store ?? null;
146
+ this.localSessionId = options.localSessionId;
65
147
  this.state = {
66
148
  sessionId: undefined,
67
149
  workspaceLabel: options.workspaceLabel,
68
150
  cliVersion: options.cliVersion,
69
151
  connection: 'connecting',
70
152
  agents: [],
153
+ toolCalls: [],
71
154
  transcript: [],
72
155
  tokensDownstreamTotal: 0,
73
156
  briefStartedAtEpochMs: undefined,
157
+ pendingAsk: null,
158
+ pendingAskSource: null,
159
+ pendingPlanReview: null,
160
+ pendingPlanReviewSource: null,
74
161
  };
75
162
  }
76
163
  /* ------------- subscribe / state -------------- */
@@ -183,12 +270,77 @@ export class ReplSession {
183
270
  this.dispatchStatus();
184
271
  return verdict;
185
272
  }
273
+ case 'consensus': {
274
+ // alpha 6.7: surface a deterministic deep-link so the operator
275
+ // knows the command exists in the REPL palette even though the
276
+ // full SSE renderer ships outside the Ink frame. Running the
277
+ // gate live inside the REPL needs the non-TTY emit path; for
278
+ // M1 we point the operator at the shell command.
279
+ const tail = verdict.ref ? ` ${verdict.ref}` : '';
280
+ this.appendSystemLine(`Run \`pugi review --consensus${tail}\` from a fresh shell to dispatch the 3-model gate.`);
281
+ return verdict;
282
+ }
283
+ case 'resume': {
284
+ await this.dispatchResume();
285
+ return verdict;
286
+ }
287
+ case 'ask': {
288
+ // α6.3: synthesise a local yes/no `<pugi-ask>` modal so the
289
+ // operator can exercise the question UI without a persona-side
290
+ // round trip. The REPL UI mounts the modal from the resulting
291
+ // `pendingAsk` state; on resolution the encoded verdict lands
292
+ // in the transcript as a system line (no admin-api dispatch
293
+ // because the question is local).
294
+ const askTag = synthesiseLocalAskTag(verdict.question);
295
+ if (!askTag) {
296
+ this.appendSystemLine('Could not synthesise local ask (question too long?). Cap is 80 chars.');
297
+ return verdict;
298
+ }
299
+ this.patch({ pendingAsk: askTag, pendingAskSource: 'local' });
300
+ return verdict;
301
+ }
186
302
  case 'stub': {
187
303
  this.appendSystemLine(verdict.message);
188
304
  return verdict;
189
305
  }
190
306
  }
191
307
  }
308
+ /**
309
+ * In-REPL `/resume` — α6.4. Lists the 10 most recent sessions from
310
+ * the local SessionStore and prints them as a numbered system menu.
311
+ * The Ink-side picker UI is deferred to the next sprint; today the
312
+ * operator gets a deterministic list + the exact command to relaunch
313
+ * with: `pugi resume <id>`. Keeping the picker out of the REPL
314
+ * frame avoids re-architecting the conversation pane mid-sprint and
315
+ * keeps `/resume` testable without an Ink runtime.
316
+ */
317
+ async dispatchResume() {
318
+ if (!this.store) {
319
+ this.appendSystemLine('Local session store is disabled - /resume is unavailable.');
320
+ return;
321
+ }
322
+ let rows;
323
+ try {
324
+ rows = await this.store.listSessions({ limit: 10 });
325
+ }
326
+ catch (error) {
327
+ this.appendSystemLine(`Could not list sessions: ${this.errorMessage(error)}`);
328
+ return;
329
+ }
330
+ if (rows.length === 0) {
331
+ this.appendSystemLine('No stored sessions yet - keep dispatching to build history.');
332
+ return;
333
+ }
334
+ this.appendSystemLine(`Recent sessions (${rows.length}):`);
335
+ for (let i = 0; i < rows.length; i += 1) {
336
+ const row = rows[i];
337
+ const title = (row.title ?? '(untitled)').slice(0, 64);
338
+ const idShort = row.id.slice(0, 13);
339
+ const branch = row.branch ?? 'no-branch';
340
+ this.appendSystemLine(` ${(i + 1).toString().padStart(2)}. ${idShort} ${branch.padEnd(16)} ${title}`);
341
+ }
342
+ this.appendSystemLine('Pick one with: pugi resume <id> (paste the 13-char id from above).');
343
+ }
192
344
  /**
193
345
  * Reset the conversation transcript. The agent registry stays intact
194
346
  * so the operator can `/clear` to declutter the chat pane without
@@ -197,6 +349,173 @@ export class ReplSession {
197
349
  clearTranscript() {
198
350
  this.patch({ transcript: [] });
199
351
  }
352
+ /* ------------- α6.3 office-hours surface -------------- */
353
+ /**
354
+ * Surface an `<pugi-ask>` modal manually. Returned promise resolves
355
+ * with the operator's verdict — used by the `pugi ask "<q>"` shell
356
+ * command and by the `/ask` slash. The resolver is wired into the
357
+ * session state via `pendingAsk` so the REPL UI can render the modal
358
+ * and forward `onResolve` back through `resolveAsk()`.
359
+ *
360
+ * NOTE: idempotent on a duplicate signature — a second presentAsk
361
+ * with the same question + option values returns the first
362
+ * outstanding promise rather than stacking two modals.
363
+ */
364
+ presentAsk(tag) {
365
+ if (this.outstandingAskPromise
366
+ && this.state.pendingAsk?.signature === tag.signature) {
367
+ // The operator is already looking at this exact ask; reuse the
368
+ // outstanding promise so the second caller sees the same answer
369
+ // when it eventually arrives.
370
+ return this.outstandingAskPromise;
371
+ }
372
+ // If a DIFFERENT ask is open, reject the new one with a clear
373
+ // error rather than silently queueing — the persona should never
374
+ // emit two concurrent asks, and surfacing the bug fails loud.
375
+ if (this.outstandingAskPromise) {
376
+ return Promise.reject(new Error('presentAsk: another ask is already pending. Resolve it first.'));
377
+ }
378
+ this.patch({ pendingAsk: tag, pendingAskSource: 'persona' });
379
+ const promise = new Promise((resolve) => {
380
+ this.outstandingAskResolver = resolve;
381
+ });
382
+ this.outstandingAskPromise = promise;
383
+ return promise;
384
+ }
385
+ /**
386
+ * Resolve the currently pending `<pugi-ask>` modal. Called by the
387
+ * REPL UI when the operator submits the modal. Appends an operator
388
+ * line to the transcript carrying the verdict, dispatches the
389
+ * verdict-encoded brief to admin-api as the next user turn (when the
390
+ * modal originated from a persona stream), and clears `pendingAsk`.
391
+ * Idempotent: a second call without a fresh `pendingAsk` is a no-op.
392
+ *
393
+ * The verdict is also forwarded to the resolver returned by
394
+ * `presentAsk()` if there is one outstanding, so the CLI's `pugi ask`
395
+ * command can await the answer.
396
+ *
397
+ * Cancellation contract (Claude triple-review P1): when the modal
398
+ * came from a persona stream, cancel ALSO dispatches a literal
399
+ * `[ASK-RESPONSE:cancelled]` to admin-api so the persona observes the
400
+ * cancellation rather than hanging indefinitely on the missing
401
+ * follow-up. The matching documentation in the Mira system prompt
402
+ * teaches the persona to acknowledge cancellation and offer a
403
+ * different path. Local-origin modals (synthesised via `/ask`) skip
404
+ * the dispatch entirely - the persona never saw the question.
405
+ *
406
+ * Free-text sanitisation (Claude triple-review P1): the operator's
407
+ * customInput is stripped of any leading `[ASK-RESPONSE:...]` /
408
+ * `[PLAN-VERDICT:...]` prefix before being encoded, so a malicious
409
+ * (or accidental) operator string cannot forge a verdict header that
410
+ * a prefix-greedy persona would misinterpret as a different choice.
411
+ */
412
+ async resolveAsk(verdict) {
413
+ if (!this.state.pendingAsk)
414
+ return;
415
+ const tag = this.state.pendingAsk;
416
+ const source = this.state.pendingAskSource;
417
+ const sanitisedVerdict = sanitiseAskVerdict(verdict);
418
+ this.patch({ pendingAsk: null, pendingAskSource: null });
419
+ const encoded = encodeAskVerdictLocal(sanitisedVerdict);
420
+ // Tell the outstanding presentAsk caller, if any. The sanitised
421
+ // verdict is forwarded so downstream consumers cannot be tricked
422
+ // by a forged verdict header either.
423
+ if (this.outstandingAskResolver) {
424
+ const resolver = this.outstandingAskResolver;
425
+ this.outstandingAskResolver = undefined;
426
+ this.outstandingAskPromise = undefined;
427
+ resolver(sanitisedVerdict);
428
+ }
429
+ // Surface the operator's choice as a transcript row so the
430
+ // conversation reads linearly. The label of the chosen option
431
+ // (or the literal custom input) is more readable than the bare
432
+ // value - Codex CLI's "you chose: Vercel" pattern.
433
+ const humanLabel = humanLabelForVerdict(tag, sanitisedVerdict);
434
+ this.appendOperatorLine(humanLabel);
435
+ // Local-origin modals (operator typed `/ask`) never need an
436
+ // admin-api round trip - the persona never observed the question.
437
+ // Codex triple-review P2.
438
+ if (source !== 'persona')
439
+ return;
440
+ // Persona-origin modals always dispatch, including on cancel:
441
+ // without the cancellation echo the persona's last turn stays open
442
+ // and the agent hangs. Claude triple-review P1.
443
+ await this.dispatchAskFollowup(encoded);
444
+ }
445
+ /**
446
+ * Same shape as `presentAsk` for the plan-review modal.
447
+ */
448
+ presentPlanReview(tag) {
449
+ if (this.outstandingPlanReviewPromise
450
+ && this.state.pendingPlanReview?.signature === tag.signature) {
451
+ return this.outstandingPlanReviewPromise;
452
+ }
453
+ if (this.outstandingPlanReviewPromise) {
454
+ return Promise.reject(new Error('presentPlanReview: another plan review is already pending.'));
455
+ }
456
+ this.patch({ pendingPlanReview: tag, pendingPlanReviewSource: 'persona' });
457
+ const promise = new Promise((resolve) => {
458
+ this.outstandingPlanReviewResolver = resolve;
459
+ });
460
+ this.outstandingPlanReviewPromise = promise;
461
+ return promise;
462
+ }
463
+ /**
464
+ * Resolve the currently pending `<pugi-plan-review>` modal. Same
465
+ * mechanics as `resolveAsk` - including the cancel-dispatch contract
466
+ * and modifyText sanitisation. The persona always sees a
467
+ * `[PLAN-VERDICT:...]` echo (even on cancel) so it never hangs
468
+ * waiting for the verdict that the operator declined to send.
469
+ */
470
+ async resolvePlanReview(result) {
471
+ if (!this.state.pendingPlanReview)
472
+ return;
473
+ const source = this.state.pendingPlanReviewSource;
474
+ const sanitisedResult = sanitisePlanReviewResult(result);
475
+ this.patch({ pendingPlanReview: null, pendingPlanReviewSource: null });
476
+ const encoded = encodePlanReviewVerdictLocal(sanitisedResult);
477
+ if (this.outstandingPlanReviewResolver) {
478
+ const resolver = this.outstandingPlanReviewResolver;
479
+ this.outstandingPlanReviewResolver = undefined;
480
+ this.outstandingPlanReviewPromise = undefined;
481
+ resolver(sanitisedResult);
482
+ }
483
+ this.appendOperatorLine(humanLabelForPlanReviewVerdict(sanitisedResult));
484
+ // Local-origin plan reviews skip the dispatch (Codex P2). Persona
485
+ // origin always dispatches, including on cancel (Claude P1).
486
+ if (source !== 'persona')
487
+ return;
488
+ await this.dispatchAskFollowup(encoded);
489
+ }
490
+ /**
491
+ * Internal: post the verdict-encoded brief WITHOUT going through the
492
+ * cap-warning gate. The follow-up is the natural continuation of the
493
+ * same conversation the persona started, so blocking it on capacity
494
+ * would strand the operator with no way to answer.
495
+ */
496
+ async dispatchAskFollowup(encodedBrief) {
497
+ const sessionId = this.state.sessionId;
498
+ if (!sessionId) {
499
+ this.appendSystemLine('No server session - response queued locally.');
500
+ return;
501
+ }
502
+ try {
503
+ await this.options.transport.postBrief({
504
+ apiUrl: this.options.apiUrl,
505
+ apiKey: this.options.apiKey,
506
+ sessionId,
507
+ brief: encodedBrief,
508
+ });
509
+ }
510
+ catch (error) {
511
+ this.appendSystemLine(`Could not forward response: ${this.errorMessage(error)}`);
512
+ }
513
+ }
514
+ // ----- Outstanding resolver bookkeeping for presentAsk / presentPlanReview -----
515
+ outstandingAskResolver;
516
+ outstandingAskPromise;
517
+ outstandingPlanReviewResolver;
518
+ outstandingPlanReviewPromise;
200
519
  /* ------------- Tier 1 / Tier 2 wired handlers -------------- */
201
520
  async dispatchJobs() {
202
521
  try {
@@ -382,12 +701,118 @@ export class ReplSession {
382
701
  onError: (error) => {
383
702
  if (this.closed)
384
703
  return;
704
+ // α6.14.2 wave 5: when admin-api restarts it drops the in-memory
705
+ // session store, so subscribe returns HTTP 404 forever on the
706
+ // saved sessionId. Detect that case and mint a fresh server
707
+ // session silently rather than spamming the operator with
708
+ // "Stream interrupted (HTTP 404)" reconnect lines. Capped to
709
+ // MAX_SESSION_RECREATE_ATTEMPTS inside SESSION_RECREATE_WINDOW_MS
710
+ // so a permanently down admin-api fails loud.
711
+ //
712
+ // Race guard (triple-review P2 follow-up): the SSE transport can
713
+ // fire onError synchronously a second time while we are tearing
714
+ // down the dead stream inside recreateSessionSilently (the
715
+ // streamHandle.close() call there can flush a pending error
716
+ // synchronously in some transports). If that second 404 arrives
717
+ // with recreatingSession === true, we must SHORT-CIRCUIT it too
718
+ // rather than fall through to the legacy "Stream interrupted"
719
+ // path — otherwise the operator sees the exact 404 line the
720
+ // recreate is trying to suppress.
721
+ if (this.isSessionNotFoundError(error)) {
722
+ if (this.recreatingSession) {
723
+ // Recreate already in flight — drop the duplicate 404 on the
724
+ // floor. The first recreate will either succeed (new stream
725
+ // opens, this dead handle is gone) or fall through to the
726
+ // loud "keeps dropping" / "session recreate refused" paths
727
+ // already defined in recreateSessionSilently.
728
+ return;
729
+ }
730
+ this.patch({ connection: 'reconnecting' });
731
+ void this.recreateSessionSilently();
732
+ return;
733
+ }
385
734
  this.patch({ connection: 'reconnecting' });
386
735
  this.appendSystemLine(`Stream interrupted (${this.errorMessage(error)}). Reconnecting.`);
387
736
  this.scheduleReconnect();
388
737
  },
389
738
  });
390
739
  }
740
+ /**
741
+ * Detect "session not found" from the SSE transport. The production
742
+ * transport in `repl-render.tsx` wraps non-2xx responses as
743
+ * `Error("HTTP 404 on SSE stream")`. We pattern-match on the status
744
+ * 404 so a different transport (e.g. a test fake or a future polling
745
+ * fallback) can surface the same intent with the same shape.
746
+ * (α6.14.2 wave 5.)
747
+ */
748
+ isSessionNotFoundError(error) {
749
+ const msg = this.errorMessage(error);
750
+ return /\b404\b/.test(msg);
751
+ }
752
+ /**
753
+ * Mint a fresh server-side session, swap the consumer to the new
754
+ * stream URL, keep the conversation flowing. Caps at
755
+ * MAX_SESSION_RECREATE_ATTEMPTS inside SESSION_RECREATE_WINDOW_MS so
756
+ * a permanently down admin-api fails loud after a few seconds of
757
+ * trying. Logged once per attempt at debug level (we surface a
758
+ * single visible line on first auto-recreate so the operator knows
759
+ * what happened, then stay quiet). (α6.14.2 wave 5.)
760
+ */
761
+ async recreateSessionSilently() {
762
+ if (this.closed)
763
+ return;
764
+ if (this.recreatingSession)
765
+ return;
766
+ const nowMs = this.now();
767
+ // Drop stale window entries so the cap is rolling, not cumulative.
768
+ while (this.recentRecreateAtMs.length > 0
769
+ && nowMs - (this.recentRecreateAtMs[0] ?? 0) > SESSION_RECREATE_WINDOW_MS) {
770
+ this.recentRecreateAtMs.shift();
771
+ }
772
+ if (this.recentRecreateAtMs.length >= MAX_SESSION_RECREATE_ATTEMPTS) {
773
+ // Cap exceeded — fall back to the loud "give up" path so the
774
+ // operator sees something is actually wrong.
775
+ this.appendSystemLine('Admin API session keeps dropping (HTTP 404 x3). Type /quit and `pugi resume` to retry.');
776
+ this.patch({ connection: 'offline' });
777
+ return;
778
+ }
779
+ this.recreatingSession = true;
780
+ this.recentRecreateAtMs.push(nowMs);
781
+ // Tear down the dead SSE handle so the next openStream() does not
782
+ // close-over the stale sessionId.
783
+ if (this.streamHandle) {
784
+ this.streamHandle.close();
785
+ this.streamHandle = undefined;
786
+ }
787
+ // Reset reconnect attempt + lastEventId — the new session is a
788
+ // fresh stream, not a continuation of the dead one.
789
+ this.reconnectAttempt = 0;
790
+ this.lastEventId = undefined;
791
+ // Single visible line on the FIRST auto-recreate of the window so
792
+ // the operator knows the CLI is recovering; later recreates in
793
+ // the same window stay silent.
794
+ if (this.recentRecreateAtMs.length === 1) {
795
+ this.appendSystemLine('Admin API restarted - minting a fresh session.');
796
+ }
797
+ try {
798
+ const { sessionId } = await this.options.transport.createSession({
799
+ apiUrl: this.options.apiUrl,
800
+ apiKey: this.options.apiKey,
801
+ workspace: this.options.workspace,
802
+ });
803
+ this.patch({ sessionId, connection: 'connecting' });
804
+ this.openStream();
805
+ }
806
+ catch (error) {
807
+ // The recreate POST itself failed — fall back to the existing
808
+ // backoff reconnect so the operator still sees retry progress.
809
+ this.appendSystemLine(`Session recreate refused (${this.errorMessage(error)}). Reconnecting.`);
810
+ this.scheduleReconnect();
811
+ }
812
+ finally {
813
+ this.recreatingSession = false;
814
+ }
815
+ }
391
816
  scheduleReconnect() {
392
817
  if (this.closed)
393
818
  return;
@@ -411,7 +836,7 @@ export class ReplSession {
411
836
  // Wave 4 fix 2026-05-25: the roster collapses to one row per
412
837
  // persona slug. The α5.7 reducer pushed a fresh row on every
413
838
  // spawn, so after three turns the bottom panel stacked
414
- // "Mira orchestrator shipped" three times. The new contract:
839
+ // "Pugi orchestrator shipped" three times. The new contract:
415
840
  // - If a row already exists for this personaSlug, REUSE it.
416
841
  // Replace its taskId, reset status to 'queued', clear the
417
842
  // detail line, restart the duration clock, zero the token
@@ -447,20 +872,46 @@ export class ReplSession {
447
872
  // double-print. `void persona` keeps the resolved name in scope
448
873
  // for the agent tree node above without leaking it into the
449
874
  // transcript body.
875
+ // α6.14.3 CEO dogfood 2026-05-25: drop the "dispatched (X)"
876
+ // transcript echo. The agent tree pane already shows the
877
+ // spawned state; printing it as a persona row is pure noise
878
+ // between the operator's brief and the persona's real reply.
450
879
  void persona;
451
- this.appendPersonaLine(event.personaSlug, `dispatched (${event.role}).`);
452
880
  return;
453
881
  }
454
882
  case 'agent.step': {
883
+ // α6.3 office-hours: scan the running buffer for `<pugi-ask>` /
884
+ // `<pugi-plan-review>` envelopes BEFORE we cache the detail.
885
+ // The parser returns the cleaned remainder with the raw XML
886
+ // stripped, so the operator never sees the envelope as prose.
887
+ // Streaming partial tags (open seen, close not yet streamed)
888
+ // are kept in the buffer; the next step event extends it.
889
+ const sanitised = this.consumeAskAndPlanReviewTags(event.taskId, event.detail);
455
890
  // Cache the running detail per task so we can surface the
456
891
  // model's actual reply when agent.completed lands (otherwise
457
892
  // 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);
893
+ if (sanitised && sanitised.trim().length > 0) {
894
+ this.lastStepDetail.set(event.taskId, sanitised);
895
+ }
896
+ // α6.12: synthesise a tool call entry when the step detail
897
+ // matches a tool-invocation grammar. The pattern is generous
898
+ // (Read(path) / Edit(path:lines) / Bash(cmd) / Grep(pat) /
899
+ // Glob(pat) / WebFetch(url)) so the pane has rows to render
900
+ // before the admin-api side ships the proper tool.* SSE events.
901
+ // Use the sanitised detail (post-tag-strip) so a `<pugi-ask>`
902
+ // envelope never produces a phantom tool row.
903
+ const synthesised = synthesiseToolCall({
904
+ taskId: event.taskId,
905
+ detail: sanitised,
906
+ agent: this.personaSlugForTask(event.taskId),
907
+ now: this.now(),
908
+ });
909
+ if (synthesised) {
910
+ this.appendToolCall(synthesised);
460
911
  }
461
912
  this.patch({
462
913
  agents: this.state.agents.map((a) => a.taskId === event.taskId
463
- ? { ...a, status: 'thinking', detail: event.detail }
914
+ ? { ...a, status: 'thinking', detail: sanitised || event.detail }
464
915
  : a),
465
916
  });
466
917
  return;
@@ -483,6 +934,11 @@ export class ReplSession {
483
934
  const target = this.state.agents.find((a) => a.taskId === event.taskId);
484
935
  const finalDetail = this.lastStepDetail.get(event.taskId);
485
936
  this.lastStepDetail.delete(event.taskId);
937
+ if (this.askBufferPending.has(event.taskId)) {
938
+ this.appendSystemLine('Persona emitted incomplete <pugi-ask> or <pugi-plan-review> tag; dropped.');
939
+ }
940
+ this.askBuffer.delete(event.taskId);
941
+ this.askBufferPending.delete(event.taskId);
486
942
  this.patch({
487
943
  agents: this.state.agents.map((a) => a.taskId === event.taskId
488
944
  ? { ...a, status: 'shipped', detail: 'shipped' }
@@ -500,15 +956,34 @@ export class ReplSession {
500
956
  if (finalDetail
501
957
  && finalDetail !== 'queued for dispatch'
502
958
  && 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);
959
+ // α6.12: ship the WHOLE body as one transcript row when the
960
+ // reply contains ANY Markdown structure (code fence, bullet
961
+ // list, numbered list, headings). The conversation pane
962
+ // routes it through Markdown renderer в one pass, preserving
963
+ // grouped bullets + heading hierarchy. Plain prose still
964
+ // splits per line so word-wrap stays correct.
965
+ //
966
+ // Claude triple-review P1 (PR #369): the prior `includes('```')`
967
+ // gate only caught fences — multi-line bullets fragmented
968
+ // per row showed as `▸ Mira • read PUGI.md / ▸ Mira • patched
969
+ // bug / ...` instead of a single grouped bullet block.
970
+ if (looksLikeMarkdown(finalDetail)) {
971
+ this.appendPersonaLine(target.personaSlug, finalDetail);
972
+ }
973
+ else {
974
+ for (const line of finalDetail.split('\n')) {
975
+ const trimmed = line.trim();
976
+ if (trimmed.length > 0) {
977
+ this.appendPersonaLine(target.personaSlug, trimmed);
978
+ }
507
979
  }
508
980
  }
509
981
  }
510
982
  else {
511
- this.appendPersonaLine(target.personaSlug, 'shipped.');
983
+ // α6.14.3 CEO dogfood 2026-05-25: drop the literal
984
+ // "shipped." fallback row. If we have no cached detail to
985
+ // surface, stay silent. The agent tree pane already shows
986
+ // the green check + duration.
512
987
  }
513
988
  }
514
989
  return;
@@ -516,6 +991,11 @@ export class ReplSession {
516
991
  case 'agent.blocked': {
517
992
  const target = this.state.agents.find((a) => a.taskId === event.taskId);
518
993
  this.lastStepDetail.delete(event.taskId);
994
+ if (this.askBufferPending.has(event.taskId)) {
995
+ this.appendSystemLine('Persona emitted incomplete <pugi-ask> or <pugi-plan-review> tag; dropped.');
996
+ }
997
+ this.askBuffer.delete(event.taskId);
998
+ this.askBufferPending.delete(event.taskId);
519
999
  this.patch({
520
1000
  agents: this.state.agents.map((a) => a.taskId === event.taskId
521
1001
  ? { ...a, status: 'blocked', detail: event.detail }
@@ -529,6 +1009,11 @@ export class ReplSession {
529
1009
  case 'agent.failed': {
530
1010
  const target = this.state.agents.find((a) => a.taskId === event.taskId);
531
1011
  this.lastStepDetail.delete(event.taskId);
1012
+ if (this.askBufferPending.has(event.taskId)) {
1013
+ this.appendSystemLine('Persona emitted incomplete <pugi-ask> or <pugi-plan-review> tag; dropped.');
1014
+ }
1015
+ this.askBuffer.delete(event.taskId);
1016
+ this.askBufferPending.delete(event.taskId);
532
1017
  this.patch({
533
1018
  agents: this.state.agents.map((a) => a.taskId === event.taskId
534
1019
  ? { ...a, status: 'failed', detail: event.error }
@@ -542,6 +1027,39 @@ export class ReplSession {
542
1027
  }
543
1028
  }
544
1029
  /* ------------- transcript helpers -------------- */
1030
+ /**
1031
+ * Look up the persona slug for a running task. Used by the tool call
1032
+ * synthesiser to colour the tool stream rows correctly. Falls back to
1033
+ * the literal `unknown` slug when the task is not in the agent tree
1034
+ * (the SSE wire can emit a step before the matching spawn under heavy
1035
+ * load - rare in practice but the pane stays robust).
1036
+ */
1037
+ personaSlugForTask(taskId) {
1038
+ const agent = this.state.agents.find((a) => a.taskId === taskId);
1039
+ return agent?.personaSlug ?? 'unknown';
1040
+ }
1041
+ /**
1042
+ * Fold a tool call entry into the rolling list. If the entry id
1043
+ * already exists, replace it in-place (so a synthesised `running` →
1044
+ * `ok` transition reuses the same row). Otherwise append. The list
1045
+ * is capped at `MAX_TOOL_CALLS` so a long-running session does not
1046
+ * leak memory.
1047
+ */
1048
+ appendToolCall(entry) {
1049
+ const existingIndex = this.state.toolCalls.findIndex((c) => c.id === entry.id);
1050
+ let next;
1051
+ if (existingIndex >= 0) {
1052
+ next = this.state.toolCalls.slice();
1053
+ next[existingIndex] = entry;
1054
+ }
1055
+ else {
1056
+ next = this.state.toolCalls.concat(entry);
1057
+ }
1058
+ if (next.length > MAX_TOOL_CALLS) {
1059
+ next = next.slice(-MAX_TOOL_CALLS);
1060
+ }
1061
+ this.patch({ toolCalls: next });
1062
+ }
545
1063
  appendOperatorLine(text) {
546
1064
  this.appendRow({ source: 'operator', text });
547
1065
  }
@@ -549,7 +1067,19 @@ export class ReplSession {
549
1067
  this.appendRow({ source: 'system', text });
550
1068
  }
551
1069
  appendPersonaLine(personaSlug, text) {
552
- this.appendRow({ source: 'persona', text, personaSlug });
1070
+ // α6.14.2 wave 5: dedup the persona display-name prefix. The
1071
+ // conversation pane already renders `▸ <DisplayName> <text>` from
1072
+ // the slug → name map; when the model's own reply begins with
1073
+ // the same display name (CEO 2026-05-25 screenshot: "Pugi Pugi,
1074
+ // координатор Pugi"), the operator sees the name twice. Strip
1075
+ // the leading display-name token (with optional trailing comma /
1076
+ // colon / whitespace) so the prefix the pane adds is the only one
1077
+ // visible. We also drop any leaked `<workspace-context-NONCE>`
1078
+ // wrapper the model sometimes echoes back at the head of its
1079
+ // first turn — that envelope is for prompt scaffolding, not for
1080
+ // the operator's eyes.
1081
+ const stripped = stripPersonaPrefixEcho(personaSlug, text);
1082
+ this.appendRow({ source: 'persona', text: stripped, personaSlug });
553
1083
  }
554
1084
  appendRow(input) {
555
1085
  if (input.text.length === 0)
@@ -563,6 +1093,161 @@ export class ReplSession {
563
1093
  };
564
1094
  const next = this.state.transcript.concat(row).slice(-MAX_TRANSCRIPT_ROWS);
565
1095
  this.patch({ transcript: next });
1096
+ // Mirror into the local SessionStore so `/resume` can replay.
1097
+ // Persistence is fail-safe: a single error becomes one system
1098
+ // line, subsequent errors are silent so a stuck disk does not
1099
+ // flood the operator. The mapping from row.source -> store kind:
1100
+ // operator -> 'user' (drives turn_count + title)
1101
+ // persona -> 'persona'
1102
+ // system -> 'system'
1103
+ this.persistRow(row);
1104
+ }
1105
+ /**
1106
+ * Best-effort write of one transcript row into the local
1107
+ * SessionStore. Swallows errors after emitting one system line so a
1108
+ * broken store never blocks the conversation. Public callers go
1109
+ * through `appendRow` — this method is private on purpose.
1110
+ */
1111
+ persistRow(row) {
1112
+ if (!this.store)
1113
+ return;
1114
+ const kind = row.source === 'operator' ? 'user'
1115
+ : row.source === 'persona' ? 'persona'
1116
+ : 'system';
1117
+ const payload = row.source === 'persona'
1118
+ ? { text: row.text, personaSlug: row.personaSlug }
1119
+ : row.source === 'operator'
1120
+ ? { brief: row.text }
1121
+ : { text: row.text };
1122
+ const event = { t: row.timestampEpochMs, kind, payload };
1123
+ void this.store.appendEvent(event).catch((error) => {
1124
+ if (this.storeErrorEmitted)
1125
+ return;
1126
+ this.storeErrorEmitted = true;
1127
+ const msg = error instanceof Error ? error.message : String(error);
1128
+ // Use appendRow directly via state patch so we don't recurse
1129
+ // into persistRow (which would loop on a stuck store).
1130
+ const errRow = {
1131
+ id: randomUUID(),
1132
+ source: 'system',
1133
+ text: `Local session persistence failed: ${msg}. Conversation continues in-memory only.`,
1134
+ timestampEpochMs: this.now(),
1135
+ };
1136
+ const next = this.state.transcript.concat(errRow).slice(-MAX_TRANSCRIPT_ROWS);
1137
+ this.patch({ transcript: next });
1138
+ });
1139
+ }
1140
+ /**
1141
+ * Restore a transcript from a stored event log — α6.4. Called by
1142
+ * the CLI bootstrap when the operator runs `pugi resume <id>` or
1143
+ * picks an entry from the `/resume` picker. Replays each event into
1144
+ * the local transcript WITHOUT writing back to the store so the
1145
+ * restore is idempotent.
1146
+ *
1147
+ * Implementation note: we briefly disable persistence by setting
1148
+ * `storeErrorEmitted` BEFORE the replay and clearing it after — but
1149
+ * the cleaner path is to bypass `appendRow` entirely and patch
1150
+ * state directly. We do the latter so persistRow does not double-
1151
+ * write the restored events.
1152
+ */
1153
+ restoreTranscript(events) {
1154
+ const rows = [];
1155
+ for (const event of events) {
1156
+ const row = eventToTranscriptRow(event);
1157
+ if (row)
1158
+ rows.push(row);
1159
+ }
1160
+ // Cap at MAX_TRANSCRIPT_ROWS — the same cap appendRow uses so the
1161
+ // window math stays consistent post-restore.
1162
+ const capped = rows.slice(-MAX_TRANSCRIPT_ROWS);
1163
+ this.patch({ transcript: capped });
1164
+ }
1165
+ /**
1166
+ * Local session id used as the persistence key. Surfaced to the
1167
+ * CLI bootstrap so `pugi resume` listings can pin the id without
1168
+ * pulling it out of internal state.
1169
+ */
1170
+ getLocalSessionId() {
1171
+ return this.localSessionId;
1172
+ }
1173
+ /* ------------- α6.3 buffered tag detection -------------- */
1174
+ /**
1175
+ * Scan the running `agent.step.detail` buffer for `<pugi-ask>` /
1176
+ * `<pugi-plan-review>` envelopes. If a complete envelope is found,
1177
+ * the parser strips it from the visible body and sets the matching
1178
+ * `pendingAsk` / `pendingPlanReview` state so the REPL can render
1179
+ * the modal. Streaming partial tags (open observed, close not yet
1180
+ * arrived) are kept in the buffer so the next step event can extend
1181
+ * them.
1182
+ *
1183
+ * Returns the sanitised body the caller should treat as the
1184
+ * persona's prose. May be empty when the entire body was tag XML;
1185
+ * the caller then leaves `lastStepDetail` untouched and the
1186
+ * `agent.completed` fallback ("shipped.") fires.
1187
+ */
1188
+ consumeAskAndPlanReviewTags(taskId, detail) {
1189
+ if (!detail || detail.length === 0) {
1190
+ return this.askBuffer.get(taskId) ?? '';
1191
+ }
1192
+ // The persona emits the running detail as a cumulative string, so
1193
+ // a fresh `agent.step` carries the full body up to the current
1194
+ // token (matches the wave-2 caching contract above). We pass the
1195
+ // raw detail through the extractors directly rather than keeping a
1196
+ // separate buffer — but we still record the pre-extraction body so
1197
+ // a partial open tag is preserved when the next chunk arrives.
1198
+ this.askBuffer.set(taskId, detail);
1199
+ const askResult = extractAskTags(detail);
1200
+ let working = askResult.cleaned;
1201
+ for (const tag of askResult.tags) {
1202
+ if (this.seenTagSignatures.includes(tag.signature))
1203
+ continue;
1204
+ this.recordSeenTag(tag.signature);
1205
+ // Only one pending ask at a time — drop additional tags in the
1206
+ // same step into the cleaned body as a system warning. The
1207
+ // persona's prompt forbids concurrent asks, so this branch is a
1208
+ // defensive guard against a misbehaving model.
1209
+ if (this.state.pendingAsk) {
1210
+ this.appendSystemLine('Persona emitted a second <pugi-ask> while one was already open. Dropped.');
1211
+ continue;
1212
+ }
1213
+ this.patch({ pendingAsk: tag, pendingAskSource: 'persona' });
1214
+ }
1215
+ if (askResult.hadMalformedTag) {
1216
+ this.appendSystemLine('Malformed <pugi-ask> dropped (parser refusal).');
1217
+ }
1218
+ const planResult = extractPlanReviewTags(working);
1219
+ working = planResult.cleaned;
1220
+ for (const tag of planResult.tags) {
1221
+ if (this.seenTagSignatures.includes(tag.signature))
1222
+ continue;
1223
+ this.recordSeenTag(tag.signature);
1224
+ if (this.state.pendingPlanReview) {
1225
+ this.appendSystemLine('Persona emitted a second <pugi-plan-review> while one was already open. Dropped.');
1226
+ continue;
1227
+ }
1228
+ this.patch({ pendingPlanReview: tag, pendingPlanReviewSource: 'persona' });
1229
+ }
1230
+ if (planResult.hadMalformedTag) {
1231
+ this.appendSystemLine('Malformed <pugi-plan-review> dropped (parser refusal).');
1232
+ }
1233
+ // Record / clear the "pending open tag" flag so agent.completed can
1234
+ // emit a warning if the persona ends the turn with an unfinished
1235
+ // envelope. The flag flips OFF when both parsers report no
1236
+ // outstanding open tag - if either is still pending, we keep it on
1237
+ // so the warning fires once at turn end.
1238
+ if (askResult.pendingOpenTag || planResult.pendingOpenTag) {
1239
+ this.askBufferPending.add(taskId);
1240
+ }
1241
+ else {
1242
+ this.askBufferPending.delete(taskId);
1243
+ }
1244
+ return working;
1245
+ }
1246
+ recordSeenTag(signature) {
1247
+ this.seenTagSignatures.push(signature);
1248
+ while (this.seenTagSignatures.length > 32) {
1249
+ this.seenTagSignatures.shift();
1250
+ }
566
1251
  }
567
1252
  /* ------------- agent count + clock -------------- */
568
1253
  activeAgentCount() {
@@ -599,6 +1284,89 @@ export class ReplSession {
599
1284
  * does not recognise, the REPL still renders something usable rather
600
1285
  * than crashing mid-frame.
601
1286
  */
1287
+ /**
1288
+ * Map a stored SessionEvent back into a TranscriptRow for `/resume`
1289
+ * replay. Returns null when the event has no operator-visible body
1290
+ * (e.g. tool.start without a text payload — those land back as
1291
+ * tool stream rows, not transcript rows). The shape mirrors the
1292
+ * `persistRow` mapping in reverse:
1293
+ *
1294
+ * 'user' -> operator (brief)
1295
+ * 'persona' -> persona (text + personaSlug)
1296
+ * 'system' -> system (text)
1297
+ *
1298
+ * Exported indirectly via `restoreTranscript`.
1299
+ */
1300
+ function eventToTranscriptRow(event) {
1301
+ const payload = (event.payload ?? null);
1302
+ if (event.kind === 'user') {
1303
+ const text = typeof payload?.brief === 'string'
1304
+ ? payload.brief
1305
+ : typeof payload?.text === 'string'
1306
+ ? payload.text
1307
+ : '';
1308
+ if (text.length === 0)
1309
+ return null;
1310
+ return {
1311
+ id: randomUUID(),
1312
+ source: 'operator',
1313
+ text,
1314
+ timestampEpochMs: event.t,
1315
+ };
1316
+ }
1317
+ if (event.kind === 'persona') {
1318
+ const text = typeof payload?.text === 'string' ? payload.text : '';
1319
+ if (text.length === 0)
1320
+ return null;
1321
+ const personaSlug = typeof payload?.personaSlug === 'string'
1322
+ ? payload.personaSlug
1323
+ : undefined;
1324
+ return {
1325
+ id: randomUUID(),
1326
+ source: 'persona',
1327
+ text,
1328
+ personaSlug,
1329
+ timestampEpochMs: event.t,
1330
+ };
1331
+ }
1332
+ if (event.kind === 'system') {
1333
+ const text = typeof payload?.text === 'string' ? payload.text : '';
1334
+ if (text.length === 0)
1335
+ return null;
1336
+ return {
1337
+ id: randomUUID(),
1338
+ source: 'system',
1339
+ text,
1340
+ timestampEpochMs: event.t,
1341
+ };
1342
+ }
1343
+ return null;
1344
+ }
1345
+ /**
1346
+ * Heuristic: does this text contain Markdown structures that benefit
1347
+ * from atomic grouping? Code fences, bullet lists, numbered lists,
1348
+ * headings — anything where per-line splitting would fragment visual
1349
+ * grouping (Claude triple-review P1 PR #369).
1350
+ */
1351
+ function looksLikeMarkdown(text) {
1352
+ if (text.includes('```'))
1353
+ return true;
1354
+ const lines = text.split('\n');
1355
+ let bulletCount = 0;
1356
+ let numberedCount = 0;
1357
+ let headingCount = 0;
1358
+ for (const raw of lines) {
1359
+ const line = raw.trim();
1360
+ if (/^[-*+]\s+\S/.test(line))
1361
+ bulletCount += 1;
1362
+ if (/^\d+\.\s+\S/.test(line))
1363
+ numberedCount += 1;
1364
+ if (/^#{1,6}\s+\S/.test(line))
1365
+ headingCount += 1;
1366
+ }
1367
+ // 2+ bullets OR 2+ numbered OR any heading = group atomically.
1368
+ return bulletCount >= 2 || numberedCount >= 2 || headingCount >= 1;
1369
+ }
602
1370
  function safePersonaName(role) {
603
1371
  try {
604
1372
  return getPersonaForRole(role).name;
@@ -615,4 +1383,305 @@ function safePersonaName(role) {
615
1383
  export function knownRoles() {
616
1384
  return listRoles();
617
1385
  }
1386
+ /* ------------------------------------------------------------------ */
1387
+ /* Tool call synthesiser - α6.12 */
1388
+ /* ------------------------------------------------------------------ */
1389
+ /**
1390
+ * Match canonical tool invocation grammar in an `agent.step.detail`
1391
+ * string and emit a synthesised `ToolCallEntry`. Returns null when no
1392
+ * known tool pattern matches.
1393
+ *
1394
+ * The grammar mirrors the way Claude Code, Codex CLI, and Gemini CLI
1395
+ * display tool calls in their tool stream panes:
1396
+ *
1397
+ * Read(path)
1398
+ * Edit(path[:lines])
1399
+ * Bash(command)
1400
+ * Grep("pattern" [in path])
1401
+ * Glob(pattern)
1402
+ * WebFetch(url)
1403
+ *
1404
+ * The matcher is case-insensitive on the tool name so a persona that
1405
+ * spells the tool as `READ(...)` or `web_fetch(...)` still lands in
1406
+ * the pane. Args are capped at 80 characters; the pane will further
1407
+ * truncate to 60 on render so the row stays single-line on a narrow
1408
+ * terminal.
1409
+ *
1410
+ * Exported for unit testing - production code path is internal.
1411
+ */
1412
+ export function synthesiseToolCall(input) {
1413
+ const detail = input.detail.trim();
1414
+ if (detail.length === 0)
1415
+ return null;
1416
+ // Pattern: ToolName(args) optionally suffixed with a result hint.
1417
+ // We allow the canonical Claude Code casing AND the snake_case
1418
+ // alias `web_fetch` so the synthesiser matches what personas write.
1419
+ const match = /^(Read|Edit|Bash|Grep|Glob|WebFetch|web_fetch)\s*\(\s*([^)]*)\s*\)\s*(.*)$/i
1420
+ .exec(detail);
1421
+ if (!match)
1422
+ return null;
1423
+ const toolName = normaliseToolName(match[1]);
1424
+ const args = (match[2] ?? '').trim().slice(0, 80);
1425
+ const tail = (match[3] ?? '').trim();
1426
+ const status = parseStatusFromTail(tail);
1427
+ return {
1428
+ id: `${input.taskId}:${toolName}:${args}`,
1429
+ agent: input.agent,
1430
+ tool: toolName,
1431
+ args,
1432
+ status: status.status,
1433
+ detail: status.detail,
1434
+ startedAtEpochMs: input.now,
1435
+ };
1436
+ }
1437
+ function normaliseToolName(raw) {
1438
+ const lower = raw.toLowerCase();
1439
+ if (lower === 'webfetch' || lower === 'web_fetch')
1440
+ return 'web_fetch';
1441
+ if (lower === 'read')
1442
+ return 'read';
1443
+ if (lower === 'edit')
1444
+ return 'edit';
1445
+ if (lower === 'bash')
1446
+ return 'bash';
1447
+ if (lower === 'grep')
1448
+ return 'grep';
1449
+ if (lower === 'glob')
1450
+ return 'glob';
1451
+ // Unreachable - regex constrains the input. Fallback keeps types happy.
1452
+ return 'read';
1453
+ }
1454
+ /**
1455
+ * Cheap status inference from the tail string. We honour explicit
1456
+ * `OK` / `error` / `running` prefixes the dispatcher may write, plus
1457
+ * a `+/-` diff hint (treated as `ok`) and a `no match` (treated as
1458
+ * `ok` because grep with no result is not an error condition).
1459
+ */
1460
+ function parseStatusFromTail(tail) {
1461
+ if (tail.length === 0)
1462
+ return { status: 'running' };
1463
+ const lower = tail.toLowerCase();
1464
+ if (lower.startsWith('error') || lower.startsWith('failed')) {
1465
+ return { status: 'error', detail: tail };
1466
+ }
1467
+ if (lower.startsWith('running')) {
1468
+ return { status: 'running', detail: tail };
1469
+ }
1470
+ return { status: 'ok', detail: tail };
1471
+ }
1472
+ /* ------------------------------------------------------------------ */
1473
+ /* α6.3 office-hours encoders */
1474
+ /* */
1475
+ /* Mirrors `tui/ask-modal.tsx#encodeAskVerdict` so the session can */
1476
+ /* synthesise the operator-side echo without dragging an Ink module */
1477
+ /* into the test surface. The two encoders MUST agree byte-for-byte — */
1478
+ /* a divergence would silently mis-prefix the persona's follow-up. */
1479
+ /* ------------------------------------------------------------------ */
1480
+ function encodeAskVerdictLocal(verdict) {
1481
+ if (verdict.cancelled)
1482
+ return '[ASK-RESPONSE:cancelled]';
1483
+ if (verdict.value.length > 0)
1484
+ return `[ASK-RESPONSE:${verdict.value}]`;
1485
+ if (verdict.customInput && verdict.customInput.length > 0) {
1486
+ return `[ASK-RESPONSE:other] ${verdict.customInput}`;
1487
+ }
1488
+ return '[ASK-RESPONSE:cancelled]';
1489
+ }
1490
+ /**
1491
+ * Strip any leading `[ASK-RESPONSE:...]` or `[PLAN-VERDICT:...]`
1492
+ * pattern from free-text operator input so a malicious or accidental
1493
+ * operator string cannot forge a verdict header. Stripping iterates
1494
+ * because the operator could prepend several forged headers in a row;
1495
+ * we keep peeling until the head is clean.
1496
+ *
1497
+ * Example: operator types `[ASK-RESPONSE:vercel] my real answer` -
1498
+ * the leading `[ASK-RESPONSE:vercel] ` is stripped, leaving
1499
+ * `my real answer`, so the encoded wire string becomes
1500
+ * `[ASK-RESPONSE:other] my real answer` rather than
1501
+ * `[ASK-RESPONSE:other] [ASK-RESPONSE:vercel] my real answer` which
1502
+ * a prefix-greedy persona could read as "operator chose vercel".
1503
+ *
1504
+ * Claude triple-review P1 (PR #375).
1505
+ */
1506
+ function sanitiseVerdictText(raw) {
1507
+ let cleaned = raw;
1508
+ // Bounded loop: each iteration must strip a non-empty pattern, so it
1509
+ // terminates in O(input length). Hard cap as defence-in-depth in case
1510
+ // the regex ever matches an empty span.
1511
+ for (let i = 0; i < raw.length + 4; i += 1) {
1512
+ const stripped = cleaned.replace(/^\s*\[(?:ASK-RESPONSE|PLAN-VERDICT):[^\]]*\]\s*/u, '');
1513
+ if (stripped === cleaned)
1514
+ break;
1515
+ cleaned = stripped;
1516
+ }
1517
+ return cleaned.trim();
1518
+ }
1519
+ function sanitiseAskVerdict(verdict) {
1520
+ if (verdict.customInput === undefined)
1521
+ return verdict;
1522
+ const sanitisedCustom = sanitiseVerdictText(verdict.customInput);
1523
+ if (sanitisedCustom === verdict.customInput)
1524
+ return verdict;
1525
+ // If sanitisation emptied the buffer, treat the verdict as a
1526
+ // cancellation rather than dispatching a meaningless "other" with no
1527
+ // body. Preserves the dispatch invariant (no empty bodies) and
1528
+ // matches the encoder's fallback.
1529
+ if (sanitisedCustom.length === 0) {
1530
+ return { value: '', cancelled: true };
1531
+ }
1532
+ return { ...verdict, customInput: sanitisedCustom };
1533
+ }
1534
+ function sanitisePlanReviewResult(result) {
1535
+ if (result.verdict !== 'modify')
1536
+ return result;
1537
+ if (result.modifyText === undefined)
1538
+ return result;
1539
+ const sanitisedText = sanitiseVerdictText(result.modifyText);
1540
+ if (sanitisedText === result.modifyText)
1541
+ return result;
1542
+ if (sanitisedText.length === 0) {
1543
+ return { verdict: 'cancel' };
1544
+ }
1545
+ return { verdict: 'modify', modifyText: sanitisedText };
1546
+ }
1547
+ function encodePlanReviewVerdictLocal(result) {
1548
+ switch (result.verdict) {
1549
+ case 'approve':
1550
+ return '[PLAN-VERDICT:approve]';
1551
+ case 'cancel':
1552
+ return '[PLAN-VERDICT:cancel]';
1553
+ case 'modify':
1554
+ if (result.modifyText && result.modifyText.length > 0) {
1555
+ return `[PLAN-VERDICT:modify] ${result.modifyText}`;
1556
+ }
1557
+ return '[PLAN-VERDICT:cancel]';
1558
+ }
1559
+ }
1560
+ /**
1561
+ * Compose the human-readable transcript line that records the
1562
+ * operator's ask verdict. Mirrors Codex CLI's "you chose: <label>"
1563
+ * pattern so the conversation reads linearly.
1564
+ */
1565
+ function humanLabelForVerdict(tag, verdict) {
1566
+ if (verdict.cancelled)
1567
+ return '(cancelled the question)';
1568
+ if (verdict.value.length > 0) {
1569
+ const opt = tag.options.find((o) => o.value === verdict.value);
1570
+ return opt ? `chose: ${opt.label}` : `chose: ${verdict.value}`;
1571
+ }
1572
+ if (verdict.customInput && verdict.customInput.length > 0) {
1573
+ return `chose: other - ${verdict.customInput}`;
1574
+ }
1575
+ return '(cancelled the question)';
1576
+ }
1577
+ function humanLabelForPlanReviewVerdict(result) {
1578
+ switch (result.verdict) {
1579
+ case 'approve':
1580
+ return 'approved the plan';
1581
+ case 'cancel':
1582
+ return 'cancelled the plan';
1583
+ case 'modify':
1584
+ if (result.modifyText && result.modifyText.length > 0) {
1585
+ return `modified the plan: ${result.modifyText}`;
1586
+ }
1587
+ return 'cancelled the plan';
1588
+ }
1589
+ }
1590
+ /**
1591
+ * Synthesise a 2-option yes/no `<pugi-ask>` tag from a raw question
1592
+ * string. Used by the `/ask` slash command and by `pugi ask <question>`
1593
+ * to give the operator a manual entrypoint into the office-hours UI
1594
+ * without needing a persona-side emission.
1595
+ *
1596
+ * Returns null when the question fails the parser's length cap so the
1597
+ * caller can surface a clear error rather than crashing the modal.
1598
+ */
1599
+ export function synthesiseLocalAskTag(question) {
1600
+ const trimmed = question.trim();
1601
+ if (trimmed.length === 0 || trimmed.length > 80)
1602
+ return null;
1603
+ const options = [
1604
+ { value: 'yes', label: 'Yes' },
1605
+ { value: 'no', label: 'No' },
1606
+ ];
1607
+ // Use the single-source signature helper so a persona-emitted ask
1608
+ // with the same question + same option values does not collide with
1609
+ // this synthesised one under a divergent algorithm. Claude
1610
+ // triple-review P1 (PR #375).
1611
+ const signature = signatureForAsk(trimmed, options);
1612
+ return {
1613
+ question: trimmed,
1614
+ options,
1615
+ signature,
1616
+ start: 0,
1617
+ end: 0,
1618
+ };
1619
+ }
1620
+ /**
1621
+ * Strip the persona's own display name from the head of a streamed
1622
+ * reply, plus any leaked `<workspace-context-...>` envelope the model
1623
+ * may echo on its first turn. Exported for direct unit testing —
1624
+ * production callers go through `appendPersonaLine`.
1625
+ *
1626
+ * Examples (display name = "Pugi"):
1627
+ * "Pugi, координатор Pugi. Брифую..." -> "координатор Pugi. Брифую..."
1628
+ * "Pugi: вот результат" -> "вот результат"
1629
+ * "<workspace-context-abc>Pugi, привет" -> "привет"
1630
+ * "обычный ответ без префикса" -> "обычный ответ без префикса"
1631
+ *
1632
+ * The strip is conservative — we only remove the display name when it
1633
+ * is followed by a separator (comma, colon, dash, space) so a sentence
1634
+ * that legitimately contains the name mid-text ("спроси у Pugi") is
1635
+ * not mangled. (α6.14.2 wave 5 — CEO dogfood fix.)
1636
+ */
1637
+ export function stripPersonaPrefixEcho(personaSlug, text) {
1638
+ let working = text.trimStart();
1639
+ // Drop any leaked `<workspace-context-...>` / `</workspace-context-...>`
1640
+ // wrapper at the head. The Mira prompt v1.1 sometimes echoes the
1641
+ // scaffolding envelope back when the model is warm-starting the
1642
+ // first turn; cosmetic noise the operator never needs to see.
1643
+ // We strip both opening tag and any text up to (and including) the
1644
+ // matching closing tag if present, else just the opening tag.
1645
+ const openMatch = /^<workspace-context[^>]*>/i.exec(working);
1646
+ if (openMatch) {
1647
+ working = working.slice(openMatch[0].length).trimStart();
1648
+ const closeMatch = /^([\s\S]*?)<\/workspace-context[^>]*>/i.exec(working);
1649
+ if (closeMatch) {
1650
+ working = working.slice(closeMatch[0].length).trimStart();
1651
+ }
1652
+ }
1653
+ // Resolve the display name from the canonical roster. Unknown slugs
1654
+ // (forward-compat with future personas streamed by a newer server)
1655
+ // skip the strip — better to leave the text alone than to mis-strip.
1656
+ const persona = getPersona(personaSlug);
1657
+ if (!persona)
1658
+ return working;
1659
+ const display = persona.name;
1660
+ if (!display || display.length === 0)
1661
+ return working;
1662
+ // Match `<DisplayName>` followed by an end-of-string, or by a
1663
+ // separator (comma, colon, dash, period followed by space, single
1664
+ // space). The match is case-insensitive so "pugi" also strips.
1665
+ // Escape regex specials in the display name even though THE_TEN
1666
+ // names are alpha-only today (forward-defense).
1667
+ const escaped = display.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1668
+ const re = new RegExp(`^${escaped}(?:[\\s,:;\\-—–]+|$)`, 'i');
1669
+ // Loop the strip so cascading echoes ("Pugi Pugi Pugi, координатор ...")
1670
+ // collapse to a single name. The model occasionally emits the display
1671
+ // name two or three times back-to-back when the pane prefix also
1672
+ // injects "▸ Pugi"; without the loop, only the first token would be
1673
+ // peeled and the operator would still see "▸ Pugi Pugi, координатор".
1674
+ // Cap at 3 iterations — beyond that the text is either pathological
1675
+ // or unrelated and we should not keep chewing it. Bail when an
1676
+ // iteration makes no progress to avoid infinite loops on a regex that
1677
+ // matches an empty string (defence-in-depth even though the current
1678
+ // pattern guarantees at least one consumed char).
1679
+ for (let i = 0; i < 3; i += 1) {
1680
+ const m = re.exec(working);
1681
+ if (!m || m[0].length === 0)
1682
+ break;
1683
+ working = working.slice(m[0].length).trimStart();
1684
+ }
1685
+ return working;
1686
+ }
618
1687
  //# sourceMappingURL=session.js.map