@pugi/cli 0.1.0-beta.3 → 0.1.0-beta.30

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 (218) hide show
  1. package/THIRD_PARTY_NOTICES.md +40 -0
  2. package/assets/pugi-mascot.ansi +15 -40
  3. package/bin/run.js +33 -1
  4. package/dist/commands/jobs-watch.js +201 -0
  5. package/dist/commands/jobs.js +15 -0
  6. package/dist/core/agent-progress/cleanup.js +134 -0
  7. package/dist/core/agent-progress/schema.js +144 -0
  8. package/dist/core/agent-progress/writer.js +101 -0
  9. package/dist/core/artifact-chain/dispatcher.js +148 -0
  10. package/dist/core/artifact-chain/exporter.js +164 -0
  11. package/dist/core/artifact-chain/state.js +243 -0
  12. package/dist/core/artifact-chain/steps.js +169 -0
  13. package/dist/core/auth/env-provider.js +238 -0
  14. package/dist/core/auto-update/channels.js +122 -0
  15. package/dist/core/auto-update/checker.js +241 -0
  16. package/dist/core/auto-update/state.js +235 -0
  17. package/dist/core/bare-mode/index.js +107 -0
  18. package/dist/core/checkpoint/resumer.js +149 -0
  19. package/dist/core/checkpoint/rewinder.js +291 -0
  20. package/dist/core/compact/auto-trigger.js +96 -0
  21. package/dist/core/compact/buffer-rewriter.js +115 -0
  22. package/dist/core/compact/summarizer.js +208 -0
  23. package/dist/core/compact/token-counter.js +108 -0
  24. package/dist/core/consensus/diff-capture.js +73 -0
  25. package/dist/core/context/index.js +7 -0
  26. package/dist/core/context/markdown-traverse.js +255 -0
  27. package/dist/core/cost/rate-card.js +129 -0
  28. package/dist/core/cost/tracker.js +221 -0
  29. package/dist/core/denial-tracking/index.js +8 -0
  30. package/dist/core/denial-tracking/state.js +264 -0
  31. package/dist/core/diagnostics/probe-runner.js +93 -0
  32. package/dist/core/diagnostics/probes/api.js +46 -0
  33. package/dist/core/diagnostics/probes/auth.js +86 -0
  34. package/dist/core/diagnostics/probes/bare-mode.js +42 -0
  35. package/dist/core/diagnostics/probes/cli-version.js +127 -0
  36. package/dist/core/diagnostics/probes/config.js +72 -0
  37. package/dist/core/diagnostics/probes/denial-tracking.js +57 -0
  38. package/dist/core/diagnostics/probes/disk.js +81 -0
  39. package/dist/core/diagnostics/probes/git.js +65 -0
  40. package/dist/core/diagnostics/probes/mcp.js +75 -0
  41. package/dist/core/diagnostics/probes/node.js +59 -0
  42. package/dist/core/diagnostics/probes/pnpm.js +36 -0
  43. package/dist/core/diagnostics/probes/pugi-md.js +89 -0
  44. package/dist/core/diagnostics/probes/session.js +74 -0
  45. package/dist/core/diagnostics/probes/status-snapshot.js +442 -0
  46. package/dist/core/diagnostics/probes/workspace.js +63 -0
  47. package/dist/core/diagnostics/types.js +70 -0
  48. package/dist/core/dispatch/cache-cleanup.js +197 -0
  49. package/dist/core/dispatch/cache-handoff.js +295 -0
  50. package/dist/core/edits/dispatch.js +218 -2
  51. package/dist/core/edits/journal.js +199 -0
  52. package/dist/core/edits/layer-d-ast.js +557 -14
  53. package/dist/core/edits/verify-hook.js +273 -0
  54. package/dist/core/edits/worktree.js +111 -18
  55. package/dist/core/engine/anvil-client.js +115 -5
  56. package/dist/core/engine/budgets.js +89 -0
  57. package/dist/core/engine/context-prefix.js +155 -0
  58. package/dist/core/engine/intent.js +260 -0
  59. package/dist/core/engine/native-pugi.js +852 -210
  60. package/dist/core/engine/prompts.js +89 -6
  61. package/dist/core/engine/strip-internal-fields.js +124 -0
  62. package/dist/core/engine/tool-bridge.js +972 -33
  63. package/dist/core/feedback/queue.js +177 -0
  64. package/dist/core/feedback/submitter.js +145 -0
  65. package/dist/core/file-cache.js +113 -1
  66. package/dist/core/hooks/events.js +44 -0
  67. package/dist/core/hooks/index.js +15 -0
  68. package/dist/core/hooks/registry.js +213 -0
  69. package/dist/core/hooks/runner.js +236 -0
  70. package/dist/core/init/scaffold.js +195 -0
  71. package/dist/core/lsp/cache.js +105 -0
  72. package/dist/core/lsp/client.js +174 -29
  73. package/dist/core/lsp/language-detect.js +66 -0
  74. package/dist/core/lsp/post-edit-diagnostics.js +171 -0
  75. package/dist/core/mcp/client.js +75 -6
  76. package/dist/core/mcp/http-server.js +553 -0
  77. package/dist/core/mcp/permission.js +190 -0
  78. package/dist/core/mcp/registry.js +24 -2
  79. package/dist/core/mcp/server-tools.js +219 -0
  80. package/dist/core/mcp/server.js +397 -0
  81. package/dist/core/memory/dual-write.js +416 -0
  82. package/dist/core/memory/dual-write.spec.js +297 -0
  83. package/dist/core/memory/phase1-kinds.js +20 -0
  84. package/dist/core/memory-sync/queue.js +158 -0
  85. package/dist/core/memory-sync/queue.spec.js +105 -0
  86. package/dist/core/onboarding/marker.js +111 -0
  87. package/dist/core/onboarding/telemetry-state.js +108 -0
  88. package/dist/core/output-style/presets.js +176 -0
  89. package/dist/core/output-style/state.js +185 -0
  90. package/dist/core/permissions/gate.js +187 -0
  91. package/dist/core/permissions/index.js +18 -0
  92. package/dist/core/permissions/mode.js +102 -0
  93. package/dist/core/permissions/state.js +215 -0
  94. package/dist/core/permissions/tool-class.js +93 -0
  95. package/dist/core/prd-check/parser.js +215 -0
  96. package/dist/core/prd-check/reporter.js +127 -0
  97. package/dist/core/prd-check/verifiers.js +223 -0
  98. package/dist/core/pugi-md/context-injector.js +76 -0
  99. package/dist/core/pugi-md/walk-up.js +207 -0
  100. package/dist/core/release-notes/parser.js +241 -0
  101. package/dist/core/release-notes/state.js +116 -0
  102. package/dist/core/repl/codebase-survey.js +308 -0
  103. package/dist/core/repl/history.js +11 -1
  104. package/dist/core/repl/init-interview.js +457 -0
  105. package/dist/core/repl/model-pricing.js +135 -0
  106. package/dist/core/repl/onboarding-state.js +297 -0
  107. package/dist/core/repl/session.js +1486 -30
  108. package/dist/core/repl/slash-commands.js +345 -9
  109. package/dist/core/repl/store/session-store.js +31 -2
  110. package/dist/core/repl/workspace-context.js +22 -0
  111. package/dist/core/repo-map/build.js +125 -0
  112. package/dist/core/repo-map/cache.js +185 -0
  113. package/dist/core/repo-map/extractor.js +254 -0
  114. package/dist/core/repo-map/formatter.js +145 -0
  115. package/dist/core/repo-map/scanner.js +211 -0
  116. package/dist/core/retry-budget/budget.js +284 -0
  117. package/dist/core/retry-budget/index.js +5 -0
  118. package/dist/core/session.js +44 -0
  119. package/dist/core/settings.js +80 -0
  120. package/dist/core/share/formatter.js +271 -0
  121. package/dist/core/share/redactor.js +221 -0
  122. package/dist/core/share/uploader.js +267 -0
  123. package/dist/core/skills/defaults.js +457 -0
  124. package/dist/core/subagents/dispatcher-real.js +600 -0
  125. package/dist/core/subagents/dispatcher.js +113 -24
  126. package/dist/core/subagents/index.js +18 -5
  127. package/dist/core/subagents/isolation-matrix.js +213 -0
  128. package/dist/core/subagents/spawn.js +19 -4
  129. package/dist/core/telemetry/emitter.js +229 -0
  130. package/dist/core/telemetry/queue.js +251 -0
  131. package/dist/core/theme/context.js +91 -0
  132. package/dist/core/theme/presets.js +228 -0
  133. package/dist/core/theme/state.js +181 -0
  134. package/dist/core/todos/invariant.js +10 -0
  135. package/dist/core/todos/state.js +177 -0
  136. package/dist/core/transport/version-interceptor.js +166 -0
  137. package/dist/core/vim/keymap.js +288 -0
  138. package/dist/core/vim/state.js +92 -0
  139. package/dist/index.js +28 -0
  140. package/dist/runtime/bootstrap.js +190 -0
  141. package/dist/runtime/cli.js +2595 -278
  142. package/dist/runtime/commands/chain.js +489 -0
  143. package/dist/runtime/commands/compact.js +297 -0
  144. package/dist/runtime/commands/cost.js +199 -0
  145. package/dist/runtime/commands/delegate.js +312 -0
  146. package/dist/runtime/commands/dispatch.js +126 -0
  147. package/dist/runtime/commands/doctor.js +390 -0
  148. package/dist/runtime/commands/feedback.js +184 -0
  149. package/dist/runtime/commands/hooks.js +184 -0
  150. package/dist/runtime/commands/lsp.js +212 -28
  151. package/dist/runtime/commands/mcp.js +824 -0
  152. package/dist/runtime/commands/memory.js +508 -0
  153. package/dist/runtime/commands/memory.spec.js +174 -0
  154. package/dist/runtime/commands/model.js +237 -0
  155. package/dist/runtime/commands/onboarding.js +275 -0
  156. package/dist/runtime/commands/patch.js +17 -0
  157. package/dist/runtime/commands/permissions.js +87 -0
  158. package/dist/runtime/commands/plan.js +143 -0
  159. package/dist/runtime/commands/prd-check.js +235 -0
  160. package/dist/runtime/commands/release-notes.js +229 -0
  161. package/dist/runtime/commands/repo-map.js +95 -0
  162. package/dist/runtime/commands/report.js +299 -0
  163. package/dist/runtime/commands/resume.js +118 -0
  164. package/dist/runtime/commands/review-consensus.js +17 -2
  165. package/dist/runtime/commands/rewind.js +333 -0
  166. package/dist/runtime/commands/roster.js +117 -0
  167. package/dist/runtime/commands/sessions.js +163 -0
  168. package/dist/runtime/commands/share.js +316 -0
  169. package/dist/runtime/commands/status.js +178 -0
  170. package/dist/runtime/commands/stickers.js +82 -0
  171. package/dist/runtime/commands/style.js +194 -0
  172. package/dist/runtime/commands/theme.js +196 -0
  173. package/dist/runtime/commands/update.js +289 -0
  174. package/dist/runtime/commands/vim.js +140 -0
  175. package/dist/runtime/commands/worktree.js +50 -6
  176. package/dist/runtime/headless.js +543 -0
  177. package/dist/runtime/load-hooks-or-exit.js +71 -0
  178. package/dist/runtime/plan-decompose.js +531 -0
  179. package/dist/runtime/version.js +65 -0
  180. package/dist/tools/agent-tool.js +229 -0
  181. package/dist/tools/apply-patch.js +281 -39
  182. package/dist/tools/ask-user-question.js +213 -0
  183. package/dist/tools/ask-user.js +115 -0
  184. package/dist/tools/file-tools.js +85 -14
  185. package/dist/tools/mcp-tool.js +260 -0
  186. package/dist/tools/multi-edit.js +361 -0
  187. package/dist/tools/registry.js +30 -2
  188. package/dist/tools/skill-tool.js +96 -0
  189. package/dist/tools/tasks.js +208 -0
  190. package/dist/tools/todo-write.js +184 -0
  191. package/dist/tools/web-fetch.js +147 -2
  192. package/dist/tools/web-search.js +458 -0
  193. package/dist/tui/agent-progress-card.js +111 -0
  194. package/dist/tui/agent-tree.js +10 -0
  195. package/dist/tui/ask-modal.js +2 -2
  196. package/dist/tui/ask-user-question-prompt.js +192 -0
  197. package/dist/tui/compact-banner.js +81 -0
  198. package/dist/tui/conversation-pane.js +82 -8
  199. package/dist/tui/cost-table.js +111 -0
  200. package/dist/tui/doctor-table.js +46 -0
  201. package/dist/tui/feedback-prompt.js +156 -0
  202. package/dist/tui/input-box.js +46 -2
  203. package/dist/tui/markdown-render.js +4 -4
  204. package/dist/tui/onboarding-wizard.js +240 -0
  205. package/dist/tui/repl-render.js +293 -35
  206. package/dist/tui/repl-splash.js +2 -2
  207. package/dist/tui/repl.js +45 -13
  208. package/dist/tui/splash.js +1 -1
  209. package/dist/tui/status-bar.js +94 -16
  210. package/dist/tui/status-table.js +7 -0
  211. package/dist/tui/stickers-art.js +136 -0
  212. package/dist/tui/style-table.js +28 -0
  213. package/dist/tui/theme-table.js +29 -0
  214. package/dist/tui/tool-stream-pane.js +7 -0
  215. package/dist/tui/update-banner.js +20 -2
  216. package/dist/tui/vim-input.js +267 -0
  217. package/docs/examples/codegraph.mcp.json +10 -0
  218. package/package.json +9 -6
@@ -27,6 +27,7 @@
27
27
  * verbatim - the brand gate on those happens at the controller.
28
28
  */
29
29
  import { randomUUID } from 'node:crypto';
30
+ import { homedir } from 'node:os';
30
31
  import { getPersona } from '@pugi/personas';
31
32
  import { listRoles, getPersonaForRole } from '../agents/registry.js';
32
33
  import { evaluateCap, describeVerdict } from './cap-warning.js';
@@ -34,11 +35,16 @@ import { parseSlashCommand } from './slash-commands.js';
34
35
  import { webFetchTool } from '../../tools/web-fetch.js';
35
36
  import { loadSettings } from '../settings.js';
36
37
  import { getJobRegistry } from '../jobs/registry.js';
38
+ import { applyCompactMask } from '../compact/buffer-rewriter.js';
39
+ import { applyRewindMask } from '../checkpoint/rewinder.js';
40
+ import { evaluateAutoCompact } from '../compact/auto-trigger.js';
41
+ import { estimateTokensInMany } from '../compact/token-counter.js';
37
42
  import { extractAskTags, extractPlanReviewTags, signatureForAsk, } from './ask.js';
38
43
  import { existsSync, readdirSync, statSync } from 'node:fs';
39
44
  import { resolve as resolvePath } from 'node:path';
40
45
  import { CancellationToken } from './cancellation.js';
41
46
  import { DispatchFSM } from './dispatch-fsm.js';
47
+ import { computeCostUsd, formatCostUsd, formatTokens } from './model-pricing.js';
42
48
  const MAX_TRANSCRIPT_ROWS = 500;
43
49
  const MAX_TOOL_CALLS = 200;
44
50
  const MAX_RECONNECT_ATTEMPTS = 10;
@@ -315,6 +321,19 @@ export class ReplSession {
315
321
  toolCalls: [],
316
322
  transcript: [],
317
323
  tokensDownstreamTotal: 0,
324
+ // α7 cost-meter sprint — cost accumulators land at zero on boot.
325
+ // `sessionStartedAtEpochMs` is set at construction time (vs the
326
+ // server-side `agent.session.opened` event) so the elapsed slot
327
+ // on the status row starts ticking the moment the REPL mounts.
328
+ sessionTokensIn: 0,
329
+ sessionTokensOut: 0,
330
+ sessionCostUsd: 0,
331
+ sessionStartedAtEpochMs: this.now(),
332
+ recentTurns: [],
333
+ turnTokensIn: 0,
334
+ turnTokensOut: 0,
335
+ turnCostUsd: 0,
336
+ lastTurnDelta: null,
318
337
  briefStartedAtEpochMs: undefined,
319
338
  pendingAsk: null,
320
339
  pendingAskSource: null,
@@ -322,6 +341,7 @@ export class ReplSession {
322
341
  pendingPlanReviewSource: null,
323
342
  dispatchState: 'idle',
324
343
  dispatchToolLabel: null,
344
+ lastCompletedOutcome: null,
325
345
  };
326
346
  // α6.9: mirror every FSM transition into the public state so the
327
347
  // status-bar surface can rerender on the next frame. Local listener
@@ -359,6 +379,7 @@ export class ReplSession {
359
379
  apiUrl: this.options.apiUrl,
360
380
  apiKey: this.options.apiKey,
361
381
  workspace: this.options.workspace,
382
+ cyberZoo: this.options.cyberZoo,
362
383
  });
363
384
  this.patch({ sessionId, connection: 'connecting' });
364
385
  this.openStream();
@@ -371,6 +392,11 @@ export class ReplSession {
371
392
  // admin-api down) is silent - the operator can still type
372
393
  // `/privacy` to see the contract.
373
394
  void this.fetchAndAnnouncePrivacyMode().catch(() => undefined);
395
+ // Leak L21 (2026-05-27): silently drain any feedback envelopes
396
+ // that landed offline during a previous session. Best-effort —
397
+ // a failed flush leaves the queue intact for the next start.
398
+ // Never blocks bootstrap.
399
+ void this.flushFeedbackQueueOnBootstrap().catch(() => undefined);
374
400
  }
375
401
  catch (error) {
376
402
  this.appendSystemLine(`Could not open Pugi session: ${this.errorMessage(error)}`);
@@ -414,6 +440,21 @@ export class ReplSession {
414
440
  // Silent fail - offline / DNS / unauth all collapse to no banner.
415
441
  }
416
442
  }
443
+ /**
444
+ * Leak L21 (2026-05-27): on bootstrap, drain the local feedback
445
+ * queue silently. Operators who ran `pugi feedback` while offline
446
+ * see their envelopes flushed on the next online session without
447
+ * any extra command. The drain is best-effort and never blocks
448
+ * the REPL — a failed flush leaves the queue intact for the next
449
+ * bootstrap attempt.
450
+ */
451
+ async flushFeedbackQueueOnBootstrap() {
452
+ const { flushFeedbackQueueSilently } = await import('../../runtime/commands/feedback.js');
453
+ await flushFeedbackQueueSilently(process.cwd(), {
454
+ apiUrl: this.options.apiUrl,
455
+ apiKey: this.options.apiKey,
456
+ });
457
+ }
417
458
  /**
418
459
  * Tear down the SSE stream and stop the reconnect timer. The session
419
460
  * id stays valid server-side; `pugi resume <id>` reopens later.
@@ -539,6 +580,73 @@ export class ReplSession {
539
580
  getDispatchState() {
540
581
  return this.fsm.current;
541
582
  }
583
+ /**
584
+ * Wave 6 BT 8 (Claude Code parity): Esc-Esc walkback. Trim the last
585
+ * operator/persona turn pair from the in-memory transcript so the
586
+ * model's next call sees the conversation as if the most recent
587
+ * turn never happened. The local SessionStore still has the events
588
+ * on disk (append-only); the in-memory mask is advisory and the next
589
+ * `/compact` boundary will fold them naturally.
590
+ *
591
+ * Refusal modes:
592
+ * - `'no-turn'` - transcript has no operator/persona row to pop.
593
+ * - `'in-flight'` - dispatch is mid-flight; popping would race with
594
+ * the streaming persona row. The operator must
595
+ * cancel (Ctrl+C) before walking back.
596
+ *
597
+ * Success mode:
598
+ * - `'walked-back'` - the trailing persona row + the operator row
599
+ * that triggered it are gone from the transcript.
600
+ * A `↩ walked back 1 turn` status row is appended
601
+ * so the operator sees the state change without
602
+ * guessing.
603
+ *
604
+ * The mask is in-memory only on purpose. Disk-side rewind already has
605
+ * a separate first-class command (`/rewind`) with checkpoint
606
+ * semantics — the Esc-Esc shortcut is a one-tap "oops, undo that" for
607
+ * the live transcript, NOT a transactional rollback.
608
+ */
609
+ walkbackLastTurn() {
610
+ // Refuse while a dispatch is running. Popping the operator row that
611
+ // is currently driving the model's response would leave the persona
612
+ // line orphaned on the next streamed chunk; the FSM also lacks a
613
+ // clean teardown path here. The operator gets a one-line refusal
614
+ // and can Ctrl+C first if they really want to walk back.
615
+ const current = this.fsm.current;
616
+ if (current !== 'idle' && current !== 'completed'
617
+ && current !== 'aborted' && current !== 'failed') {
618
+ this.appendSystemLine('Walkback refused: dispatch in flight. Cancel with Ctrl+C, then Esc-Esc again.');
619
+ return 'in-flight';
620
+ }
621
+ // Find the trailing operator row. Walking backwards because the
622
+ // transcript is append-only and the most recent operator turn is
623
+ // by definition the last `source === 'operator'` row.
624
+ const transcript = this.state.transcript;
625
+ let operatorIdx = -1;
626
+ for (let i = transcript.length - 1; i >= 0; i -= 1) {
627
+ const row = transcript[i];
628
+ if (row.source === 'operator') {
629
+ operatorIdx = i;
630
+ break;
631
+ }
632
+ }
633
+ if (operatorIdx === -1) {
634
+ // No operator turn to pop. Quiet refusal — surfacing a "nothing
635
+ // to undo" line on every accidental double-Esc would be noisy.
636
+ return 'no-turn';
637
+ }
638
+ // Trim everything from the operator row onward (its echo + any
639
+ // persona/system rows that landed in response). The slice keeps
640
+ // every row BEFORE the operator turn, which is the conversation
641
+ // exactly as it stood right before the operator pressed Enter.
642
+ const trimmed = transcript.slice(0, operatorIdx);
643
+ this.patch({ transcript: trimmed });
644
+ // Status row so the operator sees the state change without
645
+ // guessing. Brand voice: single ASCII line, return-arrow glyph
646
+ // (U+21A9) which renders across every modern terminal.
647
+ this.appendSystemLine('↩ walked back 1 turn');
648
+ return 'walked-back';
649
+ }
542
650
  /**
543
651
  * Current cancellation token. Returned for the tool execution path
544
652
  * (file-tools.ts) so it can pass the token down into a ToolContext
@@ -577,6 +685,18 @@ export class ReplSession {
577
685
  await this.dispatchStop(verdict.persona);
578
686
  return verdict;
579
687
  }
688
+ case 'delegate': {
689
+ // α7.5 Phase 1: surface the dispatch intent inline. The actual
690
+ // wire shape (POST /api/pugi/sessions/:id/delegate) requires the
691
+ // SDK transport extension that ships alongside this PR; the
692
+ // REPL session module wires the call when the matching transport
693
+ // method lands (paired CLI follow-up). Today we surface the
694
+ // delegation intent in the transcript so the operator sees the
695
+ // verdict echo for muscle-memory before the round-trip lights up.
696
+ this.appendSystemLine(`delegate ${verdict.persona}: ${verdict.brief.length > 80 ? `${verdict.brief.slice(0, 77)}...` : verdict.brief}`);
697
+ this.appendSystemLine('Run `pugi delegate <slug> "<brief>"` from a fresh shell while the REPL transport wiring lands.');
698
+ return verdict;
699
+ }
580
700
  case 'dispatch': {
581
701
  await this.dispatchBrief(verdict.brief);
582
702
  return verdict;
@@ -602,11 +722,15 @@ export class ReplSession {
602
722
  return verdict;
603
723
  }
604
724
  case 'cost': {
605
- this.dispatchCost();
725
+ await this.dispatchCost();
726
+ return verdict;
727
+ }
728
+ case 'quota': {
729
+ await this.dispatchQuota();
606
730
  return verdict;
607
731
  }
608
732
  case 'status': {
609
- this.dispatchStatus();
733
+ await this.dispatchStatus();
610
734
  return verdict;
611
735
  }
612
736
  case 'consensus': {
@@ -651,12 +775,816 @@ export class ReplSession {
651
775
  await this.dispatchPrivacy();
652
776
  return verdict;
653
777
  }
778
+ case 'init': {
779
+ // β1 Sl11 → β1a r1 (real inline scaffold, 2026-05-26): invoke
780
+ // `scaffoldPugiWorkspace` directly so the operator gets the
781
+ // same .pugi/ setup they would from `pugi init` on a fresh
782
+ // shell. Already-initialised workspaces (every artifact already
783
+ // present) get the "Already initialised" copy; partial / fresh
784
+ // workspaces get the full Created+Skipped breakdown. Default
785
+ // skills install is best-effort — any error from the bundled
786
+ // pack is surfaced as a system line and does not break the
787
+ // REPL session. The dynamic import keeps the slash dispatcher
788
+ // free of a runtime/cli.ts cycle on every keystroke.
789
+ try {
790
+ const { scaffoldPugiWorkspace } = await import('../../runtime/cli.js');
791
+ const lines = [];
792
+ const result = await scaffoldPugiWorkspace({
793
+ cwd: process.cwd(),
794
+ // Slash callers default to the full default-skills pack so
795
+ // the in-REPL experience matches `pugi init`. Operators who
796
+ // want a minimal scaffold still have the shell command.
797
+ noDefaults: false,
798
+ log: (line) => {
799
+ const trimmed = line.replace(/\n+$/u, '');
800
+ if (trimmed.length > 0)
801
+ lines.push(trimmed);
802
+ },
803
+ });
804
+ if (result.alreadyInitialized) {
805
+ this.appendSystemLine(`.pugi/ already initialised at ${result.root}. ${result.skipped.length} artefact(s) verified.`);
806
+ }
807
+ else {
808
+ this.appendSystemLine(`Pugi initialised at ${result.root}. Created ${result.created.length} artefact(s), skipped ${result.skipped.length}.`);
809
+ }
810
+ if (result.defaultSkills.length > 0) {
811
+ const installed = result.defaultSkills.filter((s) => s.status === 'installed').length;
812
+ const skippedSkills = result.defaultSkills.filter((s) => s.status === 'skipped-existing').length;
813
+ this.appendSystemLine(`Default skills: ${installed} installed, ${skippedSkills} already present.`);
814
+ }
815
+ for (const line of lines)
816
+ this.appendSystemLine(line);
817
+ }
818
+ catch (error) {
819
+ const message = error instanceof Error ? error.message : String(error);
820
+ this.appendSystemLine(`/init failed: ${message}`);
821
+ }
822
+ return verdict;
823
+ }
824
+ case 'mcp': {
825
+ // β4 Sl7 (2026-05-26): /mcp [sub] [args...] forwards to the
826
+ // runtime command. We deliberately route through the same
827
+ // entry-point used by `pugi mcp` from a fresh shell so the
828
+ // surface stays single-sourced. `serve` is refused inline —
829
+ // booting an MCP server inside an active REPL would compete
830
+ // with the REPL itself for stdio, which is exactly the wrong
831
+ // thing to do.
832
+ if (verdict.args[0] === 'serve') {
833
+ this.appendSystemLine('/mcp serve is not safe inside the REPL (it competes for stdio). ' +
834
+ 'Run `pugi mcp serve` from a fresh shell instead.');
835
+ return verdict;
836
+ }
837
+ try {
838
+ const { runMcpCommand } = await import('../../runtime/commands/mcp.js');
839
+ const lines = [];
840
+ await runMcpCommand(verdict.args, {
841
+ workspaceRoot: process.cwd(),
842
+ writeOutput: (_payload, text) => {
843
+ const trimmed = text.replace(/\n+$/u, '');
844
+ if (trimmed.length > 0)
845
+ lines.push(trimmed);
846
+ },
847
+ });
848
+ for (const line of lines)
849
+ this.appendSystemLine(line);
850
+ if (lines.length === 0) {
851
+ this.appendSystemLine('/mcp: no output.');
852
+ }
853
+ }
854
+ catch (error) {
855
+ const message = error instanceof Error ? error.message : String(error);
856
+ this.appendSystemLine(`/mcp failed: ${message}`);
857
+ }
858
+ return verdict;
859
+ }
860
+ case 'theme': {
861
+ // Leak L30 (2026-05-27): /theme [name] [--persist|--reset|--list]
862
+ // forwards to the shared `runThemeCommand` runner. Same async
863
+ // buffer-then-flush pattern as `/style` so a future async
864
+ // write path inside the runner cannot drop a tail emission
865
+ // and so multi-line payloads (banner + preview table) land
866
+ // one row per visual line in the conversation pane.
867
+ try {
868
+ const { runThemeCommand } = await import('../../runtime/commands/theme.js');
869
+ const lines = [];
870
+ await runThemeCommand(verdict.args, {
871
+ workspaceRoot: process.cwd(),
872
+ writeOutput: (_payload, text) => {
873
+ for (const raw of text.split('\n')) {
874
+ const trimmed = raw.replace(/\s+$/u, '');
875
+ lines.push(trimmed);
876
+ }
877
+ },
878
+ });
879
+ if (lines.length === 0) {
880
+ this.appendSystemLine('/theme: no output.');
881
+ }
882
+ else {
883
+ for (const line of lines)
884
+ this.appendSystemLine(line);
885
+ }
886
+ }
887
+ catch (error) {
888
+ const message = error instanceof Error ? error.message : String(error);
889
+ this.appendSystemLine(`/theme failed: ${message}`);
890
+ }
891
+ return verdict;
892
+ }
893
+ case 'style': {
894
+ // Leak L18 (2026-05-27): /style [name] [--persist|--reset|--list]
895
+ // forwards to the shared `runStyleCommand` runner so the slash
896
+ // + top-level surfaces share one code path. Dynamic import
897
+ // keeps the dispatcher free of the output-style module graph
898
+ // until the operator first invokes the slash. The runner's
899
+ // exit code is captured but NOT propagated to process.exitCode
900
+ // — REPL session should not die because a bad preset slug was
901
+ // typed in the input box.
902
+ try {
903
+ const { runStyleCommand } = await import('../../runtime/commands/style.js');
904
+ // L18 P1 fix (2026-05-27): writeOutput is invoked SYNCHRONOUSLY
905
+ // by `runStyleCommand` for each emitted block. We buffer every
906
+ // emission into `lines` and flush after the await resolves so
907
+ // that:
908
+ // (1) any future async write path inside the runner cannot
909
+ // drop a tail emission (callback never references the
910
+ // Ink frame directly), and
911
+ // (2) multi-line payloads (e.g. the active-style banner +
912
+ // catalogue table) render one row per visual line in the
913
+ // conversation pane, matching the `/stickers` surface.
914
+ const lines = [];
915
+ await runStyleCommand(verdict.args, {
916
+ workspaceRoot: process.cwd(),
917
+ writeOutput: (_payload, text) => {
918
+ for (const raw of text.split('\n')) {
919
+ const trimmed = raw.replace(/\s+$/u, '');
920
+ lines.push(trimmed);
921
+ }
922
+ },
923
+ });
924
+ if (lines.length === 0) {
925
+ this.appendSystemLine('/style: no output.');
926
+ }
927
+ else {
928
+ for (const line of lines)
929
+ this.appendSystemLine(line);
930
+ }
931
+ }
932
+ catch (error) {
933
+ const message = error instanceof Error ? error.message : String(error);
934
+ this.appendSystemLine(`/style failed: ${message}`);
935
+ }
936
+ return verdict;
937
+ }
938
+ case 'onboarding': {
939
+ // Leak L25 (2026-05-27): /onboarding forwards to the shared
940
+ // `runOnboardingCommand` runner. From inside the REPL we ALWAYS
941
+ // route through the non-interactive snapshot path — the REPL
942
+ // already owns the Ink tree and mounting a second Ink wizard
943
+ // on top would conflict over stdin raw mode. Operators who
944
+ // want the interactive walk exit the REPL and run
945
+ // `pugi onboarding` from a fresh shell; the slash surface
946
+ // surfaces the recap card + hints inline so the operator
947
+ // sees current values without leaving the session.
948
+ try {
949
+ const { runOnboardingCommand } = await import('../../runtime/commands/onboarding.js');
950
+ const { resolveActiveCredential } = await import('../credentials.js');
951
+ const credential = resolveActiveCredential();
952
+ const lines = [];
953
+ await runOnboardingCommand(verdict.args, {
954
+ workspaceRoot: process.cwd(),
955
+ env: process.env,
956
+ authPresent: credential !== null,
957
+ interactive: false,
958
+ writeOutput: (_payload, text) => {
959
+ const trimmed = text.replace(/\n+$/u, '');
960
+ if (trimmed.length > 0)
961
+ lines.push(trimmed);
962
+ },
963
+ });
964
+ for (const line of lines)
965
+ this.appendSystemLine(line);
966
+ if (lines.length === 0) {
967
+ this.appendSystemLine('/onboarding: no output.');
968
+ }
969
+ }
970
+ catch (error) {
971
+ const message = error instanceof Error ? error.message : String(error);
972
+ this.appendSystemLine(`/onboarding failed: ${message}`);
973
+ }
974
+ return verdict;
975
+ }
976
+ case 'vim': {
977
+ // Leak L26 (2026-05-27): /vim forwards to the shared
978
+ // `runVimCommand` runner so the slash + top-level surfaces
979
+ // stay single-sourced. Dynamic import mirrors /style so the
980
+ // dispatcher does not drag the vim module graph into every
981
+ // keystroke.
982
+ //
983
+ // The runner mutates `~/.pugi/config.json::vimMode`; the
984
+ // active REPL session does NOT live-pick-up the flip (the
985
+ // VimInput wrapper is mounted once at REPL boot). Operators
986
+ // get a hint that the next session will reflect the change.
987
+ // A follow-up sprint can plumb a state-store subscriber so
988
+ // the flip takes effect mid-session.
989
+ try {
990
+ const { runVimCommand } = await import('../../runtime/commands/vim.js');
991
+ const lines = [];
992
+ await runVimCommand(verdict.args, {
993
+ env: process.env,
994
+ writeOutput: (_payload, text) => {
995
+ for (const raw of text.split('\n')) {
996
+ const trimmed = raw.replace(/\s+$/u, '');
997
+ lines.push(trimmed);
998
+ }
999
+ },
1000
+ });
1001
+ if (lines.length === 0) {
1002
+ this.appendSystemLine('/vim: no output.');
1003
+ }
1004
+ else {
1005
+ for (const line of lines)
1006
+ this.appendSystemLine(line);
1007
+ }
1008
+ }
1009
+ catch (error) {
1010
+ const message = error instanceof Error ? error.message : String(error);
1011
+ this.appendSystemLine(`/vim failed: ${message}`);
1012
+ }
1013
+ return verdict;
1014
+ }
1015
+ case 'doctor': {
1016
+ // L17 (2026-05-27): run the doctor probe sweep inline. We
1017
+ // dynamic-import the runtime/commands/doctor module so the
1018
+ // slash dispatcher does not pull the diagnostics graph
1019
+ // (execFileSync + fs probes) into every keystroke. The
1020
+ // module's output is captured into local lines so we can
1021
+ // render it as system entries in the conversation pane;
1022
+ // an Ink-rendered table inside the REPL frame is a follow-up.
1023
+ try {
1024
+ const { runDoctorCommand, defaultHome } = await import('../../runtime/commands/doctor.js');
1025
+ const lines = [];
1026
+ await runDoctorCommand({
1027
+ cwd: process.cwd(),
1028
+ home: defaultHome(),
1029
+ env: process.env,
1030
+ json: false,
1031
+ writeOutput: (_payload, text) => {
1032
+ const trimmed = text.replace(/\n+$/u, '');
1033
+ if (trimmed.length > 0)
1034
+ lines.push(trimmed);
1035
+ },
1036
+ });
1037
+ for (const line of lines)
1038
+ this.appendSystemLine(line);
1039
+ if (lines.length === 0) {
1040
+ this.appendSystemLine('/doctor: no output.');
1041
+ }
1042
+ }
1043
+ catch (error) {
1044
+ const message = error instanceof Error ? error.message : String(error);
1045
+ this.appendSystemLine(`/doctor failed: ${message}`);
1046
+ }
1047
+ return verdict;
1048
+ }
1049
+ case 'prd-check': {
1050
+ // Wave 6 (2026-05-27): forward to the same handler the shell
1051
+ // surface uses so the verdict is identical between
1052
+ // `/prd-check` and `pugi prd-check`. Dynamic-import the
1053
+ // module to keep the parser + verifier graph out of the
1054
+ // REPL hot path.
1055
+ try {
1056
+ const { parsePrdCheckArgs, runPrdCheckCommand } = await import('../../runtime/commands/prd-check.js');
1057
+ const parsed = parsePrdCheckArgs(verdict.args, { jsonDefault: false });
1058
+ if (!parsed.ok) {
1059
+ this.appendSystemLine(`/prd-check: ${parsed.error}`);
1060
+ return verdict;
1061
+ }
1062
+ const lines = [];
1063
+ await runPrdCheckCommand({
1064
+ cwd: process.cwd(),
1065
+ ...(parsed.prdPath !== undefined ? { prdPath: parsed.prdPath } : {}),
1066
+ flags: parsed.flags,
1067
+ // The REPL slash does not have a snapshot of the CLI
1068
+ // command registry, so we pass an empty set; the
1069
+ // command:<name> verifier will report FAIL for now.
1070
+ // This is a deliberate trade-off — the slash surface
1071
+ // primarily exists for quick eyeball checks during a
1072
+ // session; the shell surface (which DOES inject the
1073
+ // full registry) is the canonical gate.
1074
+ knownCommands: new Set(),
1075
+ writeOutput: (_payload, text) => {
1076
+ const trimmed = text.replace(/\n+$/u, '');
1077
+ if (trimmed.length > 0)
1078
+ lines.push(trimmed);
1079
+ },
1080
+ });
1081
+ for (const line of lines)
1082
+ this.appendSystemLine(line);
1083
+ if (lines.length === 0) {
1084
+ this.appendSystemLine('/prd-check: no output.');
1085
+ }
1086
+ }
1087
+ catch (error) {
1088
+ const message = error instanceof Error ? error.message : String(error);
1089
+ this.appendSystemLine(`/prd-check failed: ${message}`);
1090
+ }
1091
+ return verdict;
1092
+ }
1093
+ case 'chain': {
1094
+ // Wave 6 (2026-05-27): forward to the shell-surface runner so
1095
+ // the slash + top-level CLI share one parser + dispatcher.
1096
+ // Dynamic import keeps the chain module out of the REPL hot
1097
+ // path. The slash variant does NOT inject the live delegate
1098
+ // wire-up — operators wanting full dispatch run `pugi chain
1099
+ // next` from a fresh shell. The slash form is best-effort for
1100
+ // status / show / list which are read-only.
1101
+ try {
1102
+ const { runChainCommand } = await import('../../runtime/commands/chain.js');
1103
+ const lines = [];
1104
+ await runChainCommand(verdict.args, {
1105
+ cwd: process.cwd(),
1106
+ json: false,
1107
+ writeOutput: (_payload, text) => {
1108
+ const trimmed = text.replace(/\n+$/u, '');
1109
+ if (trimmed.length > 0)
1110
+ lines.push(trimmed);
1111
+ },
1112
+ });
1113
+ for (const line of lines)
1114
+ this.appendSystemLine(line);
1115
+ if (lines.length === 0) {
1116
+ this.appendSystemLine('/chain: no output.');
1117
+ }
1118
+ }
1119
+ catch (error) {
1120
+ const message = error instanceof Error ? error.message : String(error);
1121
+ this.appendSystemLine(`/chain failed: ${message}`);
1122
+ }
1123
+ return verdict;
1124
+ }
1125
+ case 'permissions': {
1126
+ // Leak L6: handle the `/permissions [mode] [--persist]` flow.
1127
+ // The session module forwards to the runtime helper so the
1128
+ // workspace + global-config writes share one code path with
1129
+ // the CLI's top-level `--mode` resolution. The dynamic import
1130
+ // keeps the dispatcher free of a session.ts -> runtime/cli.ts
1131
+ // cycle.
1132
+ try {
1133
+ const { runPermissionsCommand } = await import('../../runtime/commands/permissions.js');
1134
+ const lines = [];
1135
+ await runPermissionsCommand(verdict, {
1136
+ workspaceRoot: process.cwd(),
1137
+ writeOutput: (line) => {
1138
+ const trimmed = line.replace(/\n+$/u, '');
1139
+ if (trimmed.length > 0)
1140
+ lines.push(trimmed);
1141
+ },
1142
+ });
1143
+ for (const line of lines)
1144
+ this.appendSystemLine(line);
1145
+ }
1146
+ catch (error) {
1147
+ const message = error instanceof Error ? error.message : String(error);
1148
+ this.appendSystemLine(`/permissions failed: ${message}`);
1149
+ }
1150
+ return verdict;
1151
+ }
1152
+ case 'compact': {
1153
+ // Leak L8 (2026-05-27): /compact summarises older turns and
1154
+ // appends a boundary marker. We forward to the same runner the
1155
+ // top-level `pugi compact` command uses so the surface stays
1156
+ // single-sourced. The session module owns the in-memory
1157
+ // transcript echo (system line + banner row) so the operator
1158
+ // sees the marker land without a fresh REPL bootstrap.
1159
+ //
1160
+ // Wave 6 BT 8 (Claude Code parity): `--force` bypasses the
1161
+ // noop-empty guard so the operator can compact even short
1162
+ // sessions (useful before a manual checkpoint).
1163
+ await this.dispatchCompact('manual', { force: verdict.force });
1164
+ return verdict;
1165
+ }
1166
+ case 'model': {
1167
+ // Wave 6 BT 8 (Claude Code parity): /model lists OR selects the
1168
+ // active model. Slash + top-level CLI share `runModelCommand`.
1169
+ // The session module forwards writeOutput → appendSystemLine so
1170
+ // the menu + the confirmation line land inline in the
1171
+ // transcript. Tier override is undefined at the slash surface;
1172
+ // the runner defaults to 'team' so unauthenticated operators
1173
+ // see every model. Server-side calls enforce the real tier cap.
1174
+ try {
1175
+ const { runModelCommand } = await import('../../runtime/commands/model.js');
1176
+ await runModelCommand({ slug: verdict.slug }, {
1177
+ workspaceRoot: process.cwd(),
1178
+ writeOutput: (line) => {
1179
+ const trimmed = line.replace(/\n+$/u, '');
1180
+ if (trimmed.length > 0)
1181
+ this.appendSystemLine(trimmed);
1182
+ else
1183
+ this.appendSystemLine('');
1184
+ },
1185
+ });
1186
+ }
1187
+ catch (error) {
1188
+ const message = error instanceof Error ? error.message : String(error);
1189
+ this.appendSystemLine(`/model failed: ${message}`);
1190
+ }
1191
+ return verdict;
1192
+ }
1193
+ case 'rewind': {
1194
+ // Leak L9 (2026-05-27): /rewind appends an append-only
1195
+ // tombstone marker that rolls the conversation back to a
1196
+ // checkpoint. The actual replay-mask is advisory — the on-disk
1197
+ // events stay durable so `pugi sessions undo-rewind` can
1198
+ // reverse the operation. We forward to the same runner the
1199
+ // top-level `pugi rewind` command uses to keep the surface
1200
+ // single-sourced. Dynamic import avoids pulling the checkpoint
1201
+ // graph into the dispatcher at module load.
1202
+ if (!this.store || !this.localSessionId) {
1203
+ this.appendSystemLine('Local session store is disabled — /rewind is unavailable.');
1204
+ return verdict;
1205
+ }
1206
+ try {
1207
+ const { runRewindCommand } = await import('../../runtime/commands/rewind.js');
1208
+ await runRewindCommand(verdict.args, {
1209
+ workspaceRoot: process.cwd(),
1210
+ sessionId: this.localSessionId,
1211
+ store: this.store,
1212
+ writeOutput: (_payload, text) => {
1213
+ if (text.length > 0)
1214
+ this.appendSystemLine(text);
1215
+ },
1216
+ });
1217
+ }
1218
+ catch (error) {
1219
+ const message = error instanceof Error ? error.message : String(error);
1220
+ this.appendSystemLine(`/rewind failed: ${message}`);
1221
+ }
1222
+ return verdict;
1223
+ }
1224
+ case 'share': {
1225
+ // Leak L20 (2026-05-27): /share forwards to the same runner the
1226
+ // top-level `pugi share` command uses. The session module
1227
+ // wires writeOutput to appendSystemLine so the upload result +
1228
+ // privacy gate banner land in the REPL transcript inline.
1229
+ // Confirmation prompt + readline still use stdio because the
1230
+ // Ink frame is held by the input box; operators wanting fully
1231
+ // scripted shares pass `--yes` so no prompt fires.
1232
+ try {
1233
+ const { runShareCommand } = await import('../../runtime/commands/share.js');
1234
+ const lines = [];
1235
+ await runShareCommand(verdict.args, {
1236
+ workspaceRoot: process.cwd(),
1237
+ cliVersion: this.options.cliVersion,
1238
+ sessionId: this.localSessionId ?? undefined,
1239
+ writeOutput: (_payload, text) => {
1240
+ const trimmed = text.replace(/\n+$/u, '');
1241
+ if (trimmed.length > 0)
1242
+ lines.push(trimmed);
1243
+ },
1244
+ });
1245
+ for (const line of lines)
1246
+ this.appendSystemLine(line);
1247
+ if (lines.length === 0) {
1248
+ this.appendSystemLine('/share: no output.');
1249
+ }
1250
+ }
1251
+ catch (error) {
1252
+ const message = error instanceof Error ? error.message : String(error);
1253
+ this.appendSystemLine(`/share failed: ${message}`);
1254
+ }
1255
+ return verdict;
1256
+ }
1257
+ case 'plan': {
1258
+ // Leak L7: handle `/plan [--back | --persist] [<prompt>]`.
1259
+ // The session module forwards the mode-switch portion to the
1260
+ // shared runtime helper so the workspace + global-config writes
1261
+ // share one code path with `pugi plan`. When the operator
1262
+ // typed a prompt alongside (`/plan write me X`), the prompt is
1263
+ // forwarded through the dispatch FSM exactly as if they had
1264
+ // typed it directly — the only difference is the gate now
1265
+ // refuses write/dispatch tools because the workspace mode flipped
1266
+ // to plan first. Same dynamic-import trick as /permissions to
1267
+ // avoid pulling the engine adapter graph into the dispatcher.
1268
+ try {
1269
+ const { runPlanCommand } = await import('../../runtime/commands/plan.js');
1270
+ const lines = [];
1271
+ await runPlanCommand({ back: verdict.back, persist: verdict.persist }, {
1272
+ workspaceRoot: process.cwd(),
1273
+ writeOutput: (line) => {
1274
+ const trimmed = line.replace(/\n+$/u, '');
1275
+ if (trimmed.length > 0)
1276
+ lines.push(trimmed);
1277
+ },
1278
+ });
1279
+ for (const line of lines)
1280
+ this.appendSystemLine(line);
1281
+ // Optional one-shot engine dispatch: when the operator typed
1282
+ // a prompt alongside the slash, route it through the existing
1283
+ // dispatch path. We rewrite the verdict into a synthetic
1284
+ // `dispatch` result so the engine sees the user's prompt with
1285
+ // the plan-mode gate already in place. `--auto-back` is NOT
1286
+ // honoured in the slash surface today — operators stay in
1287
+ // plan mode and revert manually with `/plan --back`. The CLI
1288
+ // top-level `pugi plan --auto-back` exists for scripted use.
1289
+ if (verdict.prompt.length > 0 && !verdict.back) {
1290
+ return { kind: 'dispatch', brief: verdict.prompt };
1291
+ }
1292
+ }
1293
+ catch (error) {
1294
+ const message = error instanceof Error ? error.message : String(error);
1295
+ this.appendSystemLine(`/plan failed: ${message}`);
1296
+ }
1297
+ return verdict;
1298
+ }
1299
+ case 'release-notes': {
1300
+ // Leak L24 (2026-05-27): changelog diff between the operator's
1301
+ // last-seen + installed CLI versions. Delegate к the shared
1302
+ // `runReleaseNotesCommand` runner so the slash + top-level
1303
+ // paths stay single-sourced. The renderer collects each line
1304
+ // into the system pane via `appendSystemLine` — no fresh Ink
1305
+ // mount, no boxed render. `--reset` is honoured via the
1306
+ // `verdict.reset` field parsed in slash-commands.ts.
1307
+ try {
1308
+ const { runReleaseNotesCommand, defaultReleaseNotesHome } = await import('../../runtime/commands/release-notes.js');
1309
+ const lines = [];
1310
+ runReleaseNotesCommand({
1311
+ home: defaultReleaseNotesHome(),
1312
+ json: false,
1313
+ reset: verdict.reset,
1314
+ writeOutput: (_payload, text) => {
1315
+ for (const line of text.split('\n')) {
1316
+ lines.push(line.replace(/\s+$/u, ''));
1317
+ }
1318
+ },
1319
+ });
1320
+ if (lines.length === 0) {
1321
+ this.appendSystemLine('/release-notes: no output.');
1322
+ }
1323
+ else {
1324
+ for (const line of lines)
1325
+ this.appendSystemLine(line);
1326
+ }
1327
+ }
1328
+ catch (error) {
1329
+ const message = error instanceof Error ? error.message : String(error);
1330
+ this.appendSystemLine(`/release-notes failed: ${message}`);
1331
+ }
1332
+ return verdict;
1333
+ }
1334
+ case 'stickers': {
1335
+ // Leak L33 (2026-05-27): brand-personality gimmick. Delegate to
1336
+ // the shared `runStickersCommand` so the slash + top-level
1337
+ // paths stay single-sourced. The renderer routes the text
1338
+ // through the system pane line-buffer (ascii-only — no fresh
1339
+ // Ink mount) so the gimmick lands as a single contiguous
1340
+ // block в the conversation transcript.
1341
+ try {
1342
+ const { runStickersCommand } = await import('../../runtime/commands/stickers.js');
1343
+ // L33 P1 fix (2026-05-27): await the runner even though the
1344
+ // current implementation is synchronous. Two reasons:
1345
+ // (1) future-proofs the call site against the runner growing
1346
+ // an async path (e.g. remote stickerpack fetch) — without
1347
+ // this await, a returned promise would resolve AFTER we
1348
+ // flushed `lines` and the gimmick would render blank, and
1349
+ // (2) keeps the slash dispatcher uniform with the other
1350
+ // command runners (style, doctor, permissions, plan), all
1351
+ // of which are awaited.
1352
+ const lines = [];
1353
+ await runStickersCommand({
1354
+ json: false,
1355
+ asciiOnly: true,
1356
+ writeOutput: (_payload, text) => {
1357
+ for (const line of text.split('\n')) {
1358
+ const trimmed = line.replace(/\s+$/u, '');
1359
+ lines.push(trimmed);
1360
+ }
1361
+ },
1362
+ });
1363
+ if (lines.length === 0) {
1364
+ this.appendSystemLine('/stickers: no output.');
1365
+ }
1366
+ else {
1367
+ for (const line of lines)
1368
+ this.appendSystemLine(line);
1369
+ }
1370
+ }
1371
+ catch (error) {
1372
+ const message = error instanceof Error ? error.message : String(error);
1373
+ this.appendSystemLine(`/stickers failed: ${message}`);
1374
+ }
1375
+ return verdict;
1376
+ }
1377
+ case 'update': {
1378
+ // Leak L27 (2026-05-27): /update probes the npm registry for a
1379
+ // newer @pugi/cli version on the configured channel and prints
1380
+ // the install command. The slash form NEVER spawns `npm install
1381
+ // -g` — that would corrupt the binary we are currently running.
1382
+ // Operators see the install command + run it manually (or run
1383
+ // `pugi update --apply` from a fresh shell after the REPL
1384
+ // exits). The slash + top-level paths share the dispatcher so
1385
+ // channel resolution + last-check persistence stay single-
1386
+ // sourced.
1387
+ try {
1388
+ const { parseUpdateArgs, runUpdateCommand } = await import('../../runtime/commands/update.js');
1389
+ const parsed = parseUpdateArgs(verdict.args);
1390
+ if ('error' in parsed) {
1391
+ this.appendSystemLine(parsed.error);
1392
+ return verdict;
1393
+ }
1394
+ // Force `apply=false` on the slash path — see comment above.
1395
+ const slashFlags = { ...parsed, apply: false };
1396
+ const lines = [];
1397
+ await runUpdateCommand({
1398
+ cwd: process.cwd(),
1399
+ home: homedir(),
1400
+ env: process.env,
1401
+ flags: slashFlags,
1402
+ promptConfirm: async () => false,
1403
+ writeOutput: (_payload, text) => {
1404
+ for (const line of text.split('\n')) {
1405
+ const trimmed = line.replace(/\s+$/u, '');
1406
+ if (trimmed.length > 0)
1407
+ lines.push(trimmed);
1408
+ }
1409
+ },
1410
+ });
1411
+ if (lines.length === 0) {
1412
+ this.appendSystemLine('/update: no output.');
1413
+ }
1414
+ else {
1415
+ for (const line of lines)
1416
+ this.appendSystemLine(line);
1417
+ }
1418
+ }
1419
+ catch (error) {
1420
+ const message = error instanceof Error ? error.message : String(error);
1421
+ this.appendSystemLine(`/update failed: ${message}`);
1422
+ }
1423
+ return verdict;
1424
+ }
1425
+ case 'feedback': {
1426
+ // Leak L21 (2026-05-27): in-CLI feedback collector. The wizard
1427
+ // mounts a fresh Ink tree (renderFeedbackPrompt) outside the
1428
+ // live REPL input box so the operator can step through
1429
+ // category / rating / comment / context / confirm without
1430
+ // interleaving with persona output. The session module owns
1431
+ // the submit + queue wiring so the slash + top-level CLI
1432
+ // surfaces stay single-sourced through `runFeedbackCommand`.
1433
+ try {
1434
+ await this.runFeedbackSlash();
1435
+ }
1436
+ catch (error) {
1437
+ const message = error instanceof Error ? error.message : String(error);
1438
+ this.appendSystemLine(`/feedback failed: ${message}`);
1439
+ }
1440
+ return verdict;
1441
+ }
1442
+ case 'repo-map': {
1443
+ // Leak L28 (2026-05-27): AST-light workspace summary. Delegate
1444
+ // к the shared `runRepoMapCommand` so the slash + top-level
1445
+ // paths stay single-sourced. The rendered text lands on the
1446
+ // system pane via `appendSystemLine` (no fresh Ink mount) so
1447
+ // the listing flows into the conversation transcript like
1448
+ // any other command output.
1449
+ try {
1450
+ const { runRepoMapCommand } = await import('../../runtime/commands/repo-map.js');
1451
+ const lines = [];
1452
+ await runRepoMapCommand({
1453
+ cwd: process.cwd(),
1454
+ refresh: verdict.refresh,
1455
+ json: false,
1456
+ writeOutput: (_payload, text) => {
1457
+ for (const line of text.split('\n')) {
1458
+ const trimmed = line.replace(/\s+$/u, '');
1459
+ lines.push(trimmed);
1460
+ }
1461
+ },
1462
+ });
1463
+ if (lines.length === 0) {
1464
+ this.appendSystemLine('/repo-map: no output.');
1465
+ }
1466
+ else {
1467
+ for (const line of lines)
1468
+ this.appendSystemLine(line);
1469
+ }
1470
+ }
1471
+ catch (error) {
1472
+ const message = error instanceof Error ? error.message : String(error);
1473
+ this.appendSystemLine(`/repo-map failed: ${message}`);
1474
+ }
1475
+ return verdict;
1476
+ }
654
1477
  case 'stub': {
655
1478
  this.appendSystemLine(verdict.message);
656
1479
  return verdict;
657
1480
  }
658
1481
  }
659
1482
  }
1483
+ /**
1484
+ * Leak L21 (2026-05-27): drive the `/feedback` wizard from inside
1485
+ * the REPL. Mounts the Ink prompt, collects the draft, hands it to
1486
+ * `runFeedbackCommand` (which routes to submit-now or
1487
+ * queue-locally), then writes the operator-facing toast to the
1488
+ * conversation system pane.
1489
+ *
1490
+ * The session module owns the wiring (cwd, cliVersion, apiUrl,
1491
+ * apiKey, transcript provider) so the slash + top-level CLI paths
1492
+ * stay single-sourced through `runFeedbackCommand`.
1493
+ */
1494
+ async runFeedbackSlash() {
1495
+ const { renderFeedbackPrompt } = await import('../../tui/feedback-prompt.js');
1496
+ const { runFeedbackCommand, renderFeedbackToast } = await import('../../runtime/commands/feedback.js');
1497
+ const { submitFeedback, redactSessionContext } = await import('../feedback/submitter.js');
1498
+ const verdict = await renderFeedbackPrompt();
1499
+ if (verdict.cancelled || !verdict.draft) {
1500
+ this.appendSystemLine('Feedback cancelled. Nothing was sent.');
1501
+ return;
1502
+ }
1503
+ // Build a session-context provider that reads the LAST 5 turns
1504
+ // from the live transcript + applies the redactor. Only invoked
1505
+ // when the operator opted in on step 4.
1506
+ const sessionContextProvider = () => {
1507
+ const last5 = this.state.transcript
1508
+ .filter((row) => row.source !== 'system')
1509
+ .slice(-5)
1510
+ .map((row) => ({
1511
+ role: row.source === 'operator' ? 'user' : 'assistant',
1512
+ text: row.text,
1513
+ }));
1514
+ // The workspace context exposed to the session does not carry
1515
+ // a git branch field today, so we omit `gitBranch` here. When
1516
+ // `ReplWorkspaceContext` gains the field we can forward it via
1517
+ // an extra options entry without changing the redactor contract.
1518
+ return redactSessionContext(last5);
1519
+ };
1520
+ const result = await runFeedbackCommand({
1521
+ cwd: process.cwd(),
1522
+ cliVersion: this.options.cliVersion,
1523
+ submit: async (env) => submitFeedback(env, {
1524
+ apiUrl: this.options.apiUrl,
1525
+ apiKey: this.options.apiKey,
1526
+ }),
1527
+ draft: verdict.draft,
1528
+ sessionContext: sessionContextProvider,
1529
+ });
1530
+ this.appendSystemLine(renderFeedbackToast(result));
1531
+ }
1532
+ /**
1533
+ * Leak L8 (2026-05-27): drive the `/compact` flow from inside the
1534
+ * REPL. Reuses the standalone runner so the wire shape + reason
1535
+ * codes stay single-sourced. The result is echoed into the
1536
+ * transcript as a system line; on success the operator sees the
1537
+ * banner sentinel on next render.
1538
+ *
1539
+ * `trigger='manual'` for explicit `/compact` invocations;
1540
+ * `trigger='auto'` for the threshold gate. The runner records the
1541
+ * trigger in the marker payload so the banner can distinguish them.
1542
+ */
1543
+ async dispatchCompact(trigger, options = {}) {
1544
+ if (!this.store || !this.localSessionId) {
1545
+ this.appendSystemLine('Local session store is disabled — /compact is unavailable.');
1546
+ return;
1547
+ }
1548
+ try {
1549
+ const { runCompactCommand } = await import('../../runtime/commands/compact.js');
1550
+ const result = await runCompactCommand([], {
1551
+ workspaceRoot: process.cwd(),
1552
+ sessionId: this.localSessionId,
1553
+ store: this.store,
1554
+ trigger,
1555
+ force: options.force === true,
1556
+ writeOutput: (_payload, text) => {
1557
+ if (text.length > 0)
1558
+ this.appendSystemLine(text);
1559
+ },
1560
+ });
1561
+ if (result.status === 'compacted') {
1562
+ // L29 (2026-05-27): emit a structured `compact-boundary` row so
1563
+ // the conversation pane routes the marker through the dedicated
1564
+ // `<CompactBanner />` Ink component (gray, terminal-width
1565
+ // separator) instead of leaking the raw text into a `system`
1566
+ // row. The plain-text body is kept as a deterministic fallback
1567
+ // for non-Ink consumers (snapshot tests, JSON-mode exports).
1568
+ const turnsBefore = result.turnsBefore ?? 0;
1569
+ this.appendRow({
1570
+ source: 'compact-boundary',
1571
+ text: `─── context compacted (${turnsBefore} turns → 1 summary, ${trigger}) ───`,
1572
+ compaction: {
1573
+ turnsBefore,
1574
+ trigger,
1575
+ summaryTokenCount: result.tokensSummarised,
1576
+ // Fresh in-REPL compaction lands at the head of the
1577
+ // transcript — no turns have followed it yet.
1578
+ turnsAgo: 0,
1579
+ },
1580
+ });
1581
+ }
1582
+ }
1583
+ catch (error) {
1584
+ const message = error instanceof Error ? error.message : String(error);
1585
+ this.appendSystemLine(`/compact failed: ${message}`);
1586
+ }
1587
+ }
660
1588
  /**
661
1589
  * In-REPL `/privacy` - alpha 6.13. Prints the full 3-mode contract
662
1590
  * doc + the current mode banner inline. The current mode is fetched
@@ -938,22 +1866,195 @@ export class ReplSession {
938
1866
  this.appendSystemLine(`/diff failed: ${this.errorMessage(error)}`);
939
1867
  }
940
1868
  }
941
- dispatchCost() {
942
- const { tokensDownstreamTotal, agents } = this.state;
1869
+ async dispatchCost() {
1870
+ // α7 cost-meter sprint full breakdown matching the TUI status row
1871
+ // footer. The session totals line mirrors the footer format
1872
+ // (`↑ <in> ↓ <out> · $X.XX · <elapsed>`) so the operator scans the
1873
+ // same numbers in two places. Per-turn list shows the last 5 turns
1874
+ // oldest → newest; an empty list renders one system line so the
1875
+ // operator knows the surface is wired (`No completed turns yet.`).
1876
+ //
1877
+ // L19 (2026-05-27) — after the in-memory recap, also render the
1878
+ // persisted per-model table from `.pugi/cost.json`. That surface
1879
+ // survives a REPL restart and answers the "what did I spend on
1880
+ // claude-opus vs qwen this week?" question the in-memory recap can
1881
+ // not. Errors loading the file collapse to a single warning line so
1882
+ // the in-memory recap (the older, well-tested surface) is never
1883
+ // gated behind a fresh dependency.
1884
+ const { sessionTokensIn, sessionTokensOut, sessionCostUsd, sessionStartedAtEpochMs, recentTurns, agents, } = this.state;
943
1885
  const active = agents.filter((a) => a.status === 'queued' || a.status === 'thinking').length;
944
- const lineTokens = `Tokens this session: ${tokensDownstreamTotal.toLocaleString()} (in+out).`;
945
- const lineAgents = `Active dispatches: ${active} of cap.`;
946
- this.appendSystemLine(lineTokens);
947
- this.appendSystemLine(lineAgents);
948
- this.appendSystemLine('Full per-persona budget breakdown lands in α6.5.');
949
- }
950
- dispatchStatus() {
951
- const sessionId = this.state.sessionId ?? '(unbound)';
952
- const reach = this.state.connection;
953
- this.appendSystemLine(`Backend: ${this.options.apiUrl} (${reach}).`);
954
- this.appendSystemLine(`Session: ${sessionId}.`);
955
- this.appendSystemLine(`Workspace: ${this.state.workspaceLabel}.`);
956
- this.appendSystemLine(`CLI: pugi ${this.state.cliVersion}.`);
1886
+ const elapsedMs = Math.max(0, this.now() - sessionStartedAtEpochMs);
1887
+ const elapsedLabel = formatElapsedShort(elapsedMs);
1888
+ this.appendSystemLine(`Session: ↑ ${formatTokens(sessionTokensIn)} ↓ ${formatTokens(sessionTokensOut)} · ${formatCostUsd(sessionCostUsd)} · ${elapsedLabel}`);
1889
+ this.appendSystemLine(`Active dispatches: ${active} of cap.`);
1890
+ if (recentTurns.length === 0) {
1891
+ this.appendSystemLine('No completed turns yet — brief the workforce to charge the meter.');
1892
+ }
1893
+ else {
1894
+ this.appendSystemLine(`Recent turns (last ${recentTurns.length}):`);
1895
+ for (let i = 0; i < recentTurns.length; i += 1) {
1896
+ const turn = recentTurns[i];
1897
+ const idx = (i + 1).toString().padStart(2, ' ');
1898
+ this.appendSystemLine(` ${idx}. ${formatTokens(turn.tokensIn)} ↓ ${formatTokens(turn.tokensOut)} · ${formatCostUsd(turn.costUsd)}`);
1899
+ }
1900
+ }
1901
+ // L19: append the persisted per-model table from .pugi/cost.json.
1902
+ try {
1903
+ const [{ createCostTracker }, { renderCostForSlash }] = await Promise.all([
1904
+ import('../cost/tracker.js'),
1905
+ import('../../runtime/commands/cost.js'),
1906
+ ]);
1907
+ const workspaceRoot = this.options.workspace?.workspaceCwd ?? process.cwd();
1908
+ const sessionId = this.state.sessionId ?? 'no-session';
1909
+ const tracker = createCostTracker({
1910
+ workspaceRoot,
1911
+ sessionIdProvider: () => sessionId,
1912
+ now: () => this.now(),
1913
+ });
1914
+ const current = tracker.current();
1915
+ if (current && Object.keys(current.models).length > 0) {
1916
+ this.appendSystemLine('');
1917
+ const { lines } = renderCostForSlash({
1918
+ tracker,
1919
+ allSessions: false,
1920
+ windowDays: 30,
1921
+ now: () => this.now(),
1922
+ });
1923
+ for (const line of lines)
1924
+ this.appendSystemLine(line);
1925
+ }
1926
+ }
1927
+ catch {
1928
+ // best-effort — the persisted view is additive; failure never
1929
+ // breaks the in-memory recap above
1930
+ }
1931
+ }
1932
+ /**
1933
+ * α7 cost-meter sprint — `/quota` slash handler. Fetches the live
1934
+ * `/api/pugi/usage` snapshot and renders three lines: plan tier,
1935
+ * monthly window, and per-counter `used/cap (pct%)`. Failure modes
1936
+ * (offline, unauth, older admin-api) collapse to a single one-line
1937
+ * `Could not fetch quota…` system message so the surface never throws
1938
+ * from a keystroke handler.
1939
+ *
1940
+ * The fetch is best-effort with a 4s timeout — mirrors the `whoami`
1941
+ * pattern in `runtime/cli.ts` so the operator gets the same UX on the
1942
+ * REPL slash and the CLI command.
1943
+ */
1944
+ async dispatchQuota() {
1945
+ const controller = new AbortController();
1946
+ const timer = setTimeout(() => controller.abort(), 4000);
1947
+ try {
1948
+ const url = `${this.options.apiUrl.replace(/\/+$/, '')}/api/pugi/usage`;
1949
+ const res = await fetch(url, {
1950
+ method: 'GET',
1951
+ headers: {
1952
+ authorization: `Bearer ${this.options.apiKey}`,
1953
+ accept: 'application/json',
1954
+ },
1955
+ signal: controller.signal,
1956
+ });
1957
+ if (!res.ok) {
1958
+ this.appendSystemLine(`Could not fetch quota: HTTP ${res.status}.`);
1959
+ return;
1960
+ }
1961
+ const body = (await res.json());
1962
+ const tier = typeof body.tier === 'string' ? body.tier : '(unknown)';
1963
+ const tierLabel = QUOTA_TIER_LABELS[tier] ?? tier;
1964
+ const month = typeof body.billingMonth === 'string' ? body.billingMonth : '(unknown month)';
1965
+ const resetAt = typeof body.resetAt === 'string' ? body.resetAt : null;
1966
+ const resetLine = resetAt ? ` · resets ${formatResetWindow(resetAt, this.now())}` : '';
1967
+ this.appendSystemLine(`Plan: ${tierLabel} · ${month}${resetLine}`);
1968
+ const used = body.used ?? {};
1969
+ const caps = body.quotas ?? {};
1970
+ const counters = [
1971
+ ['sync', used.sync, caps.sync],
1972
+ ['review', used.review, caps.review],
1973
+ ['engine', used.engine, caps.engine],
1974
+ ];
1975
+ for (const [name, value, cap] of counters) {
1976
+ const v = typeof value === 'number' ? value : 0;
1977
+ if (cap === null || cap === undefined) {
1978
+ this.appendSystemLine(` ${name.padEnd(7, ' ')} ${v.toLocaleString()} / unlimited`);
1979
+ }
1980
+ else {
1981
+ const pct = cap > 0 ? Math.round((v / cap) * 100) : 0;
1982
+ this.appendSystemLine(` ${name.padEnd(7, ' ')} ${v.toLocaleString()} / ${cap.toLocaleString()} (${pct}%)`);
1983
+ }
1984
+ }
1985
+ }
1986
+ catch (error) {
1987
+ const msg = error instanceof Error ? error.message : String(error);
1988
+ this.appendSystemLine(`Could not fetch quota: ${msg}.`);
1989
+ }
1990
+ finally {
1991
+ clearTimeout(timer);
1992
+ }
1993
+ }
1994
+ /**
1995
+ * In-REPL `/status` — Leak L34 (2026-05-27). Surfaces the full
1996
+ * session snapshot (id + age, cwd, permission mode, CLI version,
1997
+ * tokens, dispatches, last cmd, compact boundaries, auth identity,
1998
+ * connection) by delegating к the same `runStatusCommand` the
1999
+ * top-level `pugi status` shell uses. Live REPL state (session
2000
+ * id, token totals, last operator command) flows in through the
2001
+ * context so the slash variant shows MORE than the shell path.
2002
+ *
2003
+ * The renderer routes к the system pane via `appendSystemLine`
2004
+ * so the snapshot lands as a single contiguous block в the
2005
+ * conversation transcript. Migrating к the Ink `<StatusTable>`
2006
+ * mounted directly в the REPL frame is a follow-up sprint —
2007
+ * keeping the line-buffered path here avoids cycling the
2008
+ * conversation pane's render model mid-α7.
2009
+ */
2010
+ async dispatchStatus() {
2011
+ try {
2012
+ const { runStatusCommand, defaultStatusHome } = await import('../../runtime/commands/status.js');
2013
+ // Find the most-recent operator transcript row + its timestamp
2014
+ // so the snapshot's `Last cmd` field has real content в REPL
2015
+ // mode. Walking от newest end is O(transcript) worst case but
2016
+ // bounded by MAX_TRANSCRIPT_ROWS so this stays cheap.
2017
+ let lastCommand = null;
2018
+ let lastCommandAtEpochMs = null;
2019
+ for (let i = this.state.transcript.length - 1; i >= 0; i -= 1) {
2020
+ const row = this.state.transcript[i];
2021
+ if (row.source === 'operator') {
2022
+ lastCommand = row.text;
2023
+ lastCommandAtEpochMs = row.timestampEpochMs;
2024
+ break;
2025
+ }
2026
+ }
2027
+ const liveTokens = this.state.sessionTokensIn + this.state.sessionTokensOut;
2028
+ const lines = [];
2029
+ await runStatusCommand({
2030
+ cwd: process.cwd(),
2031
+ home: defaultStatusHome(),
2032
+ env: process.env,
2033
+ json: false,
2034
+ liveSessionId: this.state.sessionId ?? null,
2035
+ sessionStartedAtEpochMs: this.state.sessionStartedAtEpochMs,
2036
+ liveTokensUsed: liveTokens >= 0 ? liveTokens : 0,
2037
+ lastCommand,
2038
+ lastCommandAtEpochMs,
2039
+ writeOutput: (_payload, text) => {
2040
+ for (const line of text.split('\n')) {
2041
+ const trimmed = line.replace(/\s+$/u, '');
2042
+ if (trimmed.length > 0)
2043
+ lines.push(trimmed);
2044
+ }
2045
+ },
2046
+ });
2047
+ if (lines.length === 0) {
2048
+ this.appendSystemLine('/status: no output.');
2049
+ return;
2050
+ }
2051
+ for (const line of lines)
2052
+ this.appendSystemLine(line);
2053
+ }
2054
+ catch (error) {
2055
+ const message = error instanceof Error ? error.message : String(error);
2056
+ this.appendSystemLine(`/status failed: ${message}`);
2057
+ }
957
2058
  }
958
2059
  /**
959
2060
  * α6.5 `/context` slash handler. Surfaces the three-tier context
@@ -1136,7 +2237,10 @@ export class ReplSession {
1136
2237
  this.appendSystemLine(capLine);
1137
2238
  }
1138
2239
  this.appendOperatorLine(brief);
1139
- this.patch({ briefStartedAtEpochMs: this.now() });
2240
+ // Reset `lastCompletedOutcome` so a fresh dispatch does not
2241
+ // inherit the prior turn's status-bar label (e.g. a stale
2242
+ // "replied" sticking around while the next dispatch is in flight).
2243
+ this.patch({ briefStartedAtEpochMs: this.now(), lastCompletedOutcome: null });
1140
2244
  // α6.9 + R3 P1 (Codex triple-review 2026-05-25): supersede the
1141
2245
  // prior dispatch when one is in flight. Steps in order:
1142
2246
  //
@@ -1463,6 +2567,7 @@ export class ReplSession {
1463
2567
  apiUrl: this.options.apiUrl,
1464
2568
  apiKey: this.options.apiKey,
1465
2569
  workspace: this.options.workspace,
2570
+ cyberZoo: this.options.cyberZoo,
1466
2571
  });
1467
2572
  this.patch({ sessionId, connection: 'connecting' });
1468
2573
  this.openStream();
@@ -1619,8 +2724,22 @@ export class ReplSession {
1619
2724
  }
1620
2725
  case 'agent.tokens': {
1621
2726
  const delta = event.tokensIn + event.tokensOut;
2727
+ // α7 cost-meter sprint — bind a client-side USD figure to this
2728
+ // frame. The model slug rides on the event (optional for back-
2729
+ // compat); the price ladder in `model-pricing.ts` falls back to
2730
+ // a Sonnet-tier rate when the slug is missing, so the meter is
2731
+ // always populated. Negative / NaN values are clamped to zero
2732
+ // inside `computeCostUsd` so a buggy upstream never credits the
2733
+ // meter.
2734
+ const deltaCostUsd = computeCostUsd(event.tokensIn, event.tokensOut, event.model);
1622
2735
  this.patch({
1623
2736
  tokensDownstreamTotal: this.state.tokensDownstreamTotal + delta,
2737
+ sessionTokensIn: this.state.sessionTokensIn + event.tokensIn,
2738
+ sessionTokensOut: this.state.sessionTokensOut + event.tokensOut,
2739
+ sessionCostUsd: this.state.sessionCostUsd + deltaCostUsd,
2740
+ turnTokensIn: this.state.turnTokensIn + event.tokensIn,
2741
+ turnTokensOut: this.state.turnTokensOut + event.tokensOut,
2742
+ turnCostUsd: this.state.turnCostUsd + deltaCostUsd,
1624
2743
  agents: this.state.agents.map((a) => a.taskId === event.taskId
1625
2744
  ? {
1626
2745
  ...a,
@@ -1640,10 +2759,36 @@ export class ReplSession {
1640
2759
  }
1641
2760
  this.askBuffer.delete(event.taskId);
1642
2761
  this.askBufferPending.delete(event.taskId);
2762
+ // Honour the work-done signal from admin-api.
2763
+ // `outcome === 'replied'` means the turn was a pure text reply
2764
+ // with no delegate XML and no tool call — render it as
2765
+ // "replied" so the operator can tell the difference between
2766
+ // "the orchestrator just talked" and "real work shipped".
2767
+ // Older servers omit the field; default to 'shipped' so the
2768
+ // existing wire stays back-compat.
2769
+ const completedStatus = event.outcome === 'replied' ? 'replied' : 'shipped';
1643
2770
  this.patch({
1644
2771
  agents: this.state.agents.map((a) => a.taskId === event.taskId
1645
- ? { ...a, status: 'shipped', detail: 'shipped' }
2772
+ ? { ...a, status: completedStatus, detail: completedStatus }
1646
2773
  : a),
2774
+ // Mirror the outcome to top-level state so the status-bar
2775
+ // can render `replied` instead of the legacy `shipped`
2776
+ // label when the FSM lands in `completed`. Without this
2777
+ // the bottom-bar would still say "shipped" while the
2778
+ // agent-tree said "replied", restoring the same
2779
+ // contradiction this PR is fixing (Codex triple-review P2).
2780
+ //
2781
+ // r2: gate on the same stale-dispatch check that
2782
+ // advanceFsmOnDispatchEnd applies. If this completion
2783
+ // belongs to a SUPERSEDED dispatch (a newer dispatchBrief
2784
+ // already bumped dispatchSeq before this late terminal
2785
+ // arrived), don't let the status-bar label flip to the
2786
+ // stale outcome — the current turn is the live one.
2787
+ // The agent-tree row patch above is still safe because
2788
+ // it only updates the row keyed by taskId.
2789
+ ...(this.isStaleTaskEvent(event.taskId)
2790
+ ? {}
2791
+ : { lastCompletedOutcome: completedStatus }),
1647
2792
  });
1648
2793
  // α6.9: transition the FSM to `completed` when no other
1649
2794
  // dispatch is still in flight. The check uses the agents list
@@ -1651,6 +2796,12 @@ export class ReplSession {
1651
2796
  // the dispatch alive; the FSM only goes terminal when the last
1652
2797
  // agent ships.
1653
2798
  this.advanceFsmOnDispatchEnd('completed', 'agent_completed', event.taskId);
2799
+ // α7 cost-meter sprint — flush the per-turn delta when the
2800
+ // LAST agent settles. Decoupled from the FSM gate so a test
2801
+ // fixture (or a single-agent dispatch that never reached
2802
+ // `awaiting_response` — happens on instant SSE replay) still
2803
+ // gets the row written into recentTurns + lastTurnDelta.
2804
+ this.maybeFlushTurnOnAgentSettle(event.taskId);
1654
2805
  if (target) {
1655
2806
  // If the persona actually produced a reply via incremental
1656
2807
  // agent.step events, render that reply in the transcript so
@@ -1716,6 +2867,10 @@ export class ReplSession {
1716
2867
  // operator sees the bottom-bar settle back to `idle` after the
1717
2868
  // last block clears.
1718
2869
  this.advanceFsmOnDispatchEnd('completed', 'agent_blocked', event.taskId);
2870
+ // α7 cost-meter sprint — flush the per-turn delta (blocked
2871
+ // still counts as a billable turn — the operator paid for the
2872
+ // tokens that landed before the refusal).
2873
+ this.maybeFlushTurnOnAgentSettle(event.taskId);
1719
2874
  return;
1720
2875
  }
1721
2876
  case 'agent.failed': {
@@ -1739,6 +2894,10 @@ export class ReplSession {
1739
2894
  // `completed` so the bottom-bar surface tracks the dispatch
1740
2895
  // collectively.
1741
2896
  this.advanceFsmOnDispatchEnd('failed', 'agent_failed', event.taskId);
2897
+ // α7 cost-meter sprint — flush the per-turn delta when the
2898
+ // dispatch fails (the operator still paid for whatever tokens
2899
+ // landed before the failure).
2900
+ this.maybeFlushTurnOnAgentSettle(event.taskId);
1742
2901
  return;
1743
2902
  }
1744
2903
  }
@@ -1777,13 +2936,25 @@ export class ReplSession {
1777
2936
  * after a manual `cancel()` finds the FSM already in `aborted` and
1778
2937
  * is silently dropped.
1779
2938
  */
2939
+ /**
2940
+ * 2026-05-26 — shared stale-task check used by both the FSM advance
2941
+ * gate AND the status-bar `lastCompletedOutcome` mirror. Lifts the
2942
+ * R2 dispatchSeq compare out of `advanceFsmOnDispatchEnd` so other
2943
+ * agent.completed-handler side-effects (status-bar label, future
2944
+ * metric counters) can apply the same guard without duplicating it.
2945
+ * Returns true iff the task's stamped dispatchSeq is older than the
2946
+ * current dispatchSeq — i.e. a newer dispatchBrief() superseded it
2947
+ * and the late terminal event must not corrupt live-turn state.
2948
+ */
2949
+ isStaleTaskEvent(taskId) {
2950
+ const taskSeq = this.taskDispatchSeq.get(taskId);
2951
+ return taskSeq !== undefined && taskSeq < this.dispatchSeq;
2952
+ }
1780
2953
  advanceFsmOnDispatchEnd(outcome, reason, taskId) {
1781
2954
  // R2 P1 fix (Codex triple-review 2026-05-25): a terminal event
1782
2955
  // for a SUPERSEDED dispatch must NOT advance the live FSM or null
1783
- // the live token. If the event carries a taskId and the stamped
1784
- // dispatchSeq for that task is older than the current dispatchSeq,
1785
- // the event belongs to a prior dispatch that was replaced by a
1786
- // newer `dispatchBrief()`. Silently drop the FSM advance.
2956
+ // the live token. Delegates to isStaleTaskEvent so the agent.completed
2957
+ // status-bar mirror in the handler above uses the same gate.
1787
2958
  if (taskId !== undefined) {
1788
2959
  const taskSeq = this.taskDispatchSeq.get(taskId);
1789
2960
  if (taskSeq !== undefined && taskSeq < this.dispatchSeq) {
@@ -1815,6 +2986,63 @@ export class ReplSession {
1815
2986
  this.currentDispatchToken = null;
1816
2987
  this.patch({ briefStartedAtEpochMs: undefined });
1817
2988
  }
2989
+ /**
2990
+ * α7 cost-meter sprint — gate the per-turn flush on "this was the
2991
+ * LAST in-flight agent". Mirrors the `stillActive` guard inside
2992
+ * `advanceFsmOnDispatchEnd` so a multi-agent dispatch only emits a
2993
+ * single recentTurns row + a single lastTurnDelta flash.
2994
+ *
2995
+ * Idempotent: if no tokens have been billed this turn, the inner
2996
+ * `flushTurnAccumulator` short-circuits without pushing an empty row.
2997
+ */
2998
+ maybeFlushTurnOnAgentSettle(taskId) {
2999
+ const stillActive = this.state.agents.some((a) => a.status === 'queued' || a.status === 'thinking');
3000
+ if (stillActive)
3001
+ return;
3002
+ this.flushTurnAccumulator(taskId);
3003
+ }
3004
+ /**
3005
+ * α7 cost-meter sprint — flush the per-turn accumulator into
3006
+ * `recentTurns` + `lastTurnDelta`. Idempotent + safe to call from any
3007
+ * terminal-state branch (`agent.completed` / `agent.blocked` /
3008
+ * `agent.failed`). When no tokens have been billed this turn
3009
+ * (instant abort, cap-warning gate), the helper short-circuits
3010
+ * without pushing an empty row.
3011
+ */
3012
+ flushTurnAccumulator(taskId) {
3013
+ const turnTokensIn = this.state.turnTokensIn;
3014
+ const turnTokensOut = this.state.turnTokensOut;
3015
+ const turnCostUsd = this.state.turnCostUsd;
3016
+ if (turnTokensIn === 0 && turnTokensOut === 0) {
3017
+ // Idempotent zero-flush — never push an empty row into recentTurns.
3018
+ return;
3019
+ }
3020
+ const turnId = taskId !== undefined ? taskId : `turn-${this.dispatchSeq}-${this.now()}`;
3021
+ const newTurn = {
3022
+ id: turnId,
3023
+ tokensIn: turnTokensIn,
3024
+ tokensOut: turnTokensOut,
3025
+ costUsd: turnCostUsd,
3026
+ completedAt: new Date(this.now()).toISOString(),
3027
+ };
3028
+ // Keep the buffer capped at 5 entries (oldest first). The push
3029
+ // order matches the surface contract: `/cost` paginates oldest →
3030
+ // newest so the operator scans top-down chronologically.
3031
+ const recent = [...this.state.recentTurns, newTurn];
3032
+ const trimmed = recent.length > 5 ? recent.slice(-5) : recent;
3033
+ this.patch({
3034
+ recentTurns: trimmed,
3035
+ lastTurnDelta: {
3036
+ tokensIn: turnTokensIn,
3037
+ tokensOut: turnTokensOut,
3038
+ costUsd: turnCostUsd,
3039
+ completedAtEpochMs: this.now(),
3040
+ },
3041
+ turnTokensIn: 0,
3042
+ turnTokensOut: 0,
3043
+ turnCostUsd: 0,
3044
+ });
3045
+ }
1818
3046
  /* ------------- transcript helpers -------------- */
1819
3047
  /**
1820
3048
  * Look up the persona slug for a running task. Used by the tool call
@@ -1871,13 +3099,14 @@ export class ReplSession {
1871
3099
  this.appendRow({ source: 'persona', text: stripped, personaSlug });
1872
3100
  }
1873
3101
  appendRow(input) {
1874
- if (input.text.length === 0)
3102
+ if (input.text.length === 0 && input.source !== 'compact-boundary')
1875
3103
  return;
1876
3104
  const row = {
1877
3105
  id: randomUUID(),
1878
3106
  source: input.source,
1879
3107
  text: input.text,
1880
3108
  personaSlug: input.personaSlug,
3109
+ compaction: input.compaction,
1881
3110
  timestampEpochMs: this.now(),
1882
3111
  };
1883
3112
  const next = this.state.transcript.concat(row).slice(-MAX_TRANSCRIPT_ROWS);
@@ -1890,6 +3119,62 @@ export class ReplSession {
1890
3119
  // persona -> 'persona'
1891
3120
  // system -> 'system'
1892
3121
  this.persistRow(row);
3122
+ // Leak L8 (2026-05-27): evaluate the auto-compact gate after
3123
+ // every appendRow that produces a transcript turn. Wrapped in a
3124
+ // setImmediate so the gate never blocks the input-handling fast
3125
+ // path; if the threshold is tripped, the auto-trigger dispatches
3126
+ // `/compact` in the background while the operator keeps typing.
3127
+ if (row.source === 'operator' || row.source === 'persona') {
3128
+ this.maybeAutoCompact();
3129
+ }
3130
+ }
3131
+ /**
3132
+ * Auto-compact gate. Cheap: builds an in-memory token estimate from
3133
+ * the current transcript and consults `evaluateAutoCompact`. When the
3134
+ * gate fires AND a compaction is not already in flight, we dispatch
3135
+ * `/compact` with `trigger='auto'`. The fire-and-forget shape means
3136
+ * the input box stays responsive while the background round-trip
3137
+ * runs.
3138
+ *
3139
+ * Hysteresis: `compactionInFlight` blocks re-entry. The gate is
3140
+ * cleared when the dispatch promise resolves regardless of outcome
3141
+ * so a transient transport failure does not permanently disable the
3142
+ * auto-trigger.
3143
+ */
3144
+ compactionInFlight = false;
3145
+ maybeAutoCompact() {
3146
+ if (this.compactionInFlight)
3147
+ return;
3148
+ if (!this.store || !this.localSessionId)
3149
+ return;
3150
+ if (process.env['PUGI_AUTOCOMPACT_DISABLED'] === '1')
3151
+ return;
3152
+ // Token estimate from the in-memory transcript. The estimate is a
3153
+ // lower bound on actual context pressure (server-side system
3154
+ // prompts add overhead) but the 4-char/token heuristic plus the
3155
+ // 0.75 default threshold gives generous headroom.
3156
+ const texts = this.state.transcript.map((r) => r.text);
3157
+ const tokenCount = estimateTokensInMany(texts);
3158
+ // Conservative default: assume the smallest commonly-used window
3159
+ // (32k tokens for deepseek-v3.1). Resolving the live model slug
3160
+ // through DispatchFSM + admin-api adds latency on a hot path; the
3161
+ // 0.75 threshold + smallest-window assumption errs toward
3162
+ // EARLY trigger which is the safe direction.
3163
+ const verdict = evaluateAutoCompact({
3164
+ tokenCount,
3165
+ windowSize: 32_000,
3166
+ });
3167
+ if (verdict.kind !== 'fire')
3168
+ return;
3169
+ this.compactionInFlight = true;
3170
+ void (async () => {
3171
+ try {
3172
+ await this.dispatchCompact('auto');
3173
+ }
3174
+ finally {
3175
+ this.compactionInFlight = false;
3176
+ }
3177
+ })();
1893
3178
  }
1894
3179
  /**
1895
3180
  * Best-effort write of one transcript row into the local
@@ -1900,6 +3185,15 @@ export class ReplSession {
1900
3185
  persistRow(row) {
1901
3186
  if (!this.store)
1902
3187
  return;
3188
+ // L29 (2026-05-27): `compact-boundary` transcript rows are echoes of
3189
+ // the JSONL `compaction` event the compact runner already appended
3190
+ // via `appendCompactBoundary`. Persisting them here would double-
3191
+ // write the marker (and worse, with a stripped payload that lacks
3192
+ // `summary` / `coversUntilOffset`) — `isCompactBoundary` would
3193
+ // reject the duplicate but `applyCompactMask` would still index off
3194
+ // the wrong offset. Skip the write.
3195
+ if (row.source === 'compact-boundary')
3196
+ return;
1903
3197
  const kind = row.source === 'operator' ? 'user'
1904
3198
  : row.source === 'persona' ? 'persona'
1905
3199
  : 'system';
@@ -1940,12 +3234,30 @@ export class ReplSession {
1940
3234
  * write the restored events.
1941
3235
  */
1942
3236
  restoreTranscript(events) {
3237
+ // Leak L8 (2026-05-27): apply compact-boundary masking BEFORE the
3238
+ // row conversion. Events strictly before the latest marker are
3239
+ // condensed into the boundary's `keptTailTurns + marker` slice so
3240
+ // the post-resume transcript starts at the most-recent context
3241
+ // floor rather than re-playing the full pre-compaction history.
3242
+ //
3243
+ // Leak L9 (2026-05-27): then apply rewind-marker masking. Any
3244
+ // event inside an active rewind range is stripped from the
3245
+ // visible transcript; the on-disk events stay durable so a
3246
+ // follow-up `pugi sessions undo-rewind` can restore them.
3247
+ const masked = applyRewindMask(applyCompactMask(events));
1943
3248
  const rows = [];
1944
- for (const event of events) {
3249
+ for (const event of masked) {
1945
3250
  const row = eventToTranscriptRow(event);
1946
3251
  if (row)
1947
3252
  rows.push(row);
1948
3253
  }
3254
+ // L29 (2026-05-27): tag each compact-boundary row with the count of
3255
+ // operator + persona turns that landed AFTER it in the replay
3256
+ // window. The banner reads `turnsAgo` to render the "N turns ago"
3257
+ // suffix so a long session that resumes across multiple compactions
3258
+ // stays self-orienting. System rows + sibling boundaries are NOT
3259
+ // counted — they are chrome, not operator-visible turns.
3260
+ annotateBoundaryTurnsAgo(rows);
1949
3261
  // Cap at MAX_TRANSCRIPT_ROWS - the same cap appendRow uses so the
1950
3262
  // window math stays consistent post-restore.
1951
3263
  const capped = rows.slice(-MAX_TRANSCRIPT_ROWS);
@@ -2129,8 +3441,71 @@ function eventToTranscriptRow(event) {
2129
3441
  timestampEpochMs: event.t,
2130
3442
  };
2131
3443
  }
3444
+ if (event.kind === 'compaction') {
3445
+ // L8 + L29 (2026-05-27): render the marker as a structured
3446
+ // `compact-boundary` row so the renderer can route it to the
3447
+ // dedicated <CompactBanner /> Ink component. The full summary text
3448
+ // is intentionally NOT inlined here (a 2k-token summary in the
3449
+ // transcript would defeat the purpose of compacting); the operator
3450
+ // sees the "context compacted" banner and can run `/context` to
3451
+ // inspect the marker payload when they want the details. The plain
3452
+ // text fallback stays in place for non-Ink consumers (snapshot
3453
+ // tests, future JSON exports).
3454
+ const compactionPayload = (event.payload ?? null);
3455
+ const trigger = compactionPayload?.trigger === 'auto' ? 'auto' : 'manual';
3456
+ const turns = typeof compactionPayload?.summaryTurnsBefore === 'number'
3457
+ ? compactionPayload.summaryTurnsBefore
3458
+ : 0;
3459
+ const tokens = typeof compactionPayload?.summaryTokenCount === 'number'
3460
+ ? compactionPayload.summaryTokenCount
3461
+ : undefined;
3462
+ return {
3463
+ id: randomUUID(),
3464
+ source: 'compact-boundary',
3465
+ text: `─── context compacted (${turns} turns → 1 summary, ${trigger}) ───`,
3466
+ compaction: {
3467
+ turnsBefore: turns,
3468
+ trigger,
3469
+ summaryTokenCount: tokens,
3470
+ },
3471
+ timestampEpochMs: event.t,
3472
+ };
3473
+ }
2132
3474
  return null;
2133
3475
  }
3476
+ /**
3477
+ * L29 (2026-05-27): walk a chronological transcript window and stamp
3478
+ * every `compact-boundary` row's `compaction.turnsAgo` with the count of
3479
+ * operator + persona rows that land AFTER it. The annotation runs in
3480
+ * place on the array — boundaries earlier in time get larger `turnsAgo`
3481
+ * values, the boundary at the head of the window gets zero. System rows
3482
+ * and sibling boundaries are excluded from the count (they are chrome,
3483
+ * not operator-visible turns).
3484
+ *
3485
+ * Exported so a future spec can lock the contract and so the in-REPL
3486
+ * `/compact` path can reuse the same counter on live appends if it ever
3487
+ * needs to. Pure function (mutates only the input slice).
3488
+ */
3489
+ export function annotateBoundaryTurnsAgo(rows) {
3490
+ let trailingTurns = 0;
3491
+ for (let i = rows.length - 1; i >= 0; i -= 1) {
3492
+ const row = rows[i];
3493
+ if (row.source === 'operator' || row.source === 'persona') {
3494
+ trailingTurns += 1;
3495
+ continue;
3496
+ }
3497
+ if (row.source === 'compact-boundary') {
3498
+ // Re-assign with the live `turnsAgo`. Carry forward the existing
3499
+ // structured payload so we never lose the trigger / token-count
3500
+ // data the renderer needs.
3501
+ const compaction = row.compaction ?? { turnsBefore: 0, trigger: 'manual' };
3502
+ rows[i] = {
3503
+ ...row,
3504
+ compaction: { ...compaction, turnsAgo: trailingTurns },
3505
+ };
3506
+ }
3507
+ }
3508
+ }
2134
3509
  /**
2135
3510
  * Heuristic: does this text contain Markdown structures that benefit
2136
3511
  * from atomic grouping? Code fences, bullet lists, numbered lists,
@@ -2197,6 +3572,62 @@ function formatAgeSeconds(deltaMs) {
2197
3572
  export function knownRoles() {
2198
3573
  return listRoles();
2199
3574
  }
3575
+ /**
3576
+ * α7 cost-meter sprint — render a session-elapsed ms delta as the
3577
+ * status-row's compact `XmYs` / `XhYm` shape. Distinct from
3578
+ * `formatAgeSeconds` above because `/cost` needs minute-granularity
3579
+ * uniformly (operator wants `2m44s`, not `2m`). Pure / branch-cheap;
3580
+ * the TUI status row + `/cost` both call this on every render.
3581
+ */
3582
+ function formatElapsedShort(elapsedMs) {
3583
+ if (!Number.isFinite(elapsedMs) || elapsedMs <= 0)
3584
+ return '0s';
3585
+ const totalSec = Math.floor(elapsedMs / 1000);
3586
+ if (totalSec < 60)
3587
+ return `${totalSec}s`;
3588
+ const min = Math.floor(totalSec / 60);
3589
+ const sec = totalSec % 60;
3590
+ if (min < 60)
3591
+ return `${min}m${sec.toString().padStart(2, '0')}s`;
3592
+ const hr = Math.floor(min / 60);
3593
+ const restMin = min % 60;
3594
+ return `${hr}h${restMin.toString().padStart(2, '0')}m`;
3595
+ }
3596
+ /**
3597
+ * α7 cost-meter sprint — public-facing tier labels for the `/quota`
3598
+ * slash. Mirrors `TIER_PRICE_LABEL` in `runtime/cli.ts` (kept in sync
3599
+ * via `pricing.spec.ts` gate). Falls through to the raw slug when an
3600
+ * unknown tier ships from a forward-compat admin-api build.
3601
+ */
3602
+ const QUOTA_TIER_LABELS = Object.freeze({
3603
+ free: 'Free',
3604
+ founder: 'Founder ($20/mo)',
3605
+ builder: 'Builder ($99/mo)',
3606
+ team: 'Team ($199/mo)',
3607
+ });
3608
+ /**
3609
+ * α7 cost-meter sprint — render the time-until-reset window for the
3610
+ * `/quota` plan line. `resetAt` is the ISO string admin-api returns;
3611
+ * `now` is the current epoch ms (injected for test determinism). Falls
3612
+ * back to the raw ISO string when parsing fails so the operator never
3613
+ * sees an empty hint.
3614
+ */
3615
+ function formatResetWindow(resetAtIso, nowEpochMs) {
3616
+ const resetMs = Date.parse(resetAtIso);
3617
+ if (!Number.isFinite(resetMs))
3618
+ return resetAtIso;
3619
+ const deltaMs = resetMs - nowEpochMs;
3620
+ if (deltaMs <= 0)
3621
+ return 'now';
3622
+ const days = Math.floor(deltaMs / (24 * 60 * 60 * 1000));
3623
+ if (days >= 2)
3624
+ return `in ${days}d`;
3625
+ const hours = Math.floor(deltaMs / (60 * 60 * 1000));
3626
+ if (hours >= 1)
3627
+ return `in ${hours}h`;
3628
+ const minutes = Math.max(1, Math.floor(deltaMs / (60 * 1000)));
3629
+ return `in ${minutes}m`;
3630
+ }
2200
3631
  /* ------------------------------------------------------------------ */
2201
3632
  /* Tool call synthesiser - α6.12 */
2202
3633
  /* ------------------------------------------------------------------ */
@@ -2230,7 +3661,7 @@ export function synthesiseToolCall(input) {
2230
3661
  // Pattern: ToolName(args) optionally suffixed with a result hint.
2231
3662
  // We allow the canonical Claude Code casing AND the snake_case
2232
3663
  // alias `web_fetch` so the synthesiser matches what personas write.
2233
- const match = /^(Read|Edit|Bash|Grep|Glob|WebFetch|web_fetch)\s*\(\s*([^)]*)\s*\)\s*(.*)$/i
3664
+ const match = /^(Read|Write|Edit|Bash|Grep|Glob|WebFetch|web_fetch)\s*\(\s*([^)]*)\s*\)\s*(.*)$/i
2234
3665
  .exec(detail);
2235
3666
  if (!match)
2236
3667
  return null;
@@ -2254,6 +3685,8 @@ function normaliseToolName(raw) {
2254
3685
  return 'web_fetch';
2255
3686
  if (lower === 'read')
2256
3687
  return 'read';
3688
+ if (lower === 'write')
3689
+ return 'write';
2257
3690
  if (lower === 'edit')
2258
3691
  return 'edit';
2259
3692
  if (lower === 'bash')
@@ -2479,7 +3912,22 @@ export function stripPersonaPrefixEcho(personaSlug, text) {
2479
3912
  // Escape regex specials in the display name even though THE_TEN
2480
3913
  // names are alpha-only today (forward-defense).
2481
3914
  const escaped = display.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
3915
+ // Match `<DisplayName>` (case-insensitive) followed by EITHER:
3916
+ // - an end-of-string, OR
3917
+ // - a separator (whitespace / comma / colon / dash / period+space).
3918
+ // The `i` flag is needed so a model writing "PUGI:" or "pugi," still
3919
+ // strips. After this match the post-fix `noSepUppercaseRe` handles
3920
+ // the "PugiПринял" / "PugiHello" no-separator emission pattern
3921
+ // (CEO red-alert 2026-05-27) using a SEPARATE regex without the `i`
3922
+ // flag so the lookahead is case-strict (Pugineous must NOT strip).
2482
3923
  const re = new RegExp(`^${escaped}(?:[\\s,:;\\-—–]+|$)`, 'i');
3924
+ // No-separator case-strict matcher. Display name in either of its
3925
+ // canonical casings ("Pugi" / "PUGI") immediately followed by an
3926
+ // uppercase Cyrillic or Latin letter. The strip is intentionally
3927
+ // narrower than the case-insensitive `re` above because a lowercase
3928
+ // continuation ("Pugineous") is a single word, not a display-name
3929
+ // echo - we must not eat real content.
3930
+ const noSepUppercaseRe = new RegExp(`^(?:${escaped}|${escaped.toUpperCase()})(?=[А-ЯЁA-Z])`);
2483
3931
  // Loop the strip so cascading echoes ("Pugi Pugi Pugi, координатор ...")
2484
3932
  // collapse to a single name. The model occasionally emits the display
2485
3933
  // name two or three times back-to-back when the pane prefix also
@@ -2491,10 +3939,18 @@ export function stripPersonaPrefixEcho(personaSlug, text) {
2491
3939
  // matches an empty string (defence-in-depth even though the current
2492
3940
  // pattern guarantees at least one consumed char).
2493
3941
  for (let i = 0; i < 3; i += 1) {
2494
- const m = re.exec(working);
2495
- if (!m || m[0].length === 0)
2496
- break;
2497
- working = working.slice(m[0].length).trimStart();
3942
+ let m = re.exec(working);
3943
+ if (m && m[0].length > 0) {
3944
+ working = working.slice(m[0].length).trimStart();
3945
+ continue;
3946
+ }
3947
+ // Fallback: no-separator match for "PugiПринял" / "PugiHello" shape.
3948
+ m = noSepUppercaseRe.exec(working);
3949
+ if (m && m[0].length > 0) {
3950
+ working = working.slice(m[0].length);
3951
+ continue;
3952
+ }
3953
+ break;
2498
3954
  }
2499
3955
  return working;
2500
3956
  }