@pugi/cli 0.1.0-beta.2 → 0.1.0-beta.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) 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/compact/auto-trigger.js +96 -0
  10. package/dist/core/compact/buffer-rewriter.js +115 -0
  11. package/dist/core/compact/summarizer.js +196 -0
  12. package/dist/core/compact/token-counter.js +108 -0
  13. package/dist/core/consensus/diff-capture.js +73 -0
  14. package/dist/core/context/index.js +7 -0
  15. package/dist/core/context/markdown-traverse.js +255 -0
  16. package/dist/core/cost/rate-card.js +129 -0
  17. package/dist/core/cost/tracker.js +221 -0
  18. package/dist/core/denial-tracking/index.js +8 -0
  19. package/dist/core/denial-tracking/state.js +264 -0
  20. package/dist/core/diagnostics/probe-runner.js +93 -0
  21. package/dist/core/diagnostics/probes/api.js +46 -0
  22. package/dist/core/diagnostics/probes/auth.js +86 -0
  23. package/dist/core/diagnostics/probes/cli-version.js +127 -0
  24. package/dist/core/diagnostics/probes/config.js +72 -0
  25. package/dist/core/diagnostics/probes/denial-tracking.js +57 -0
  26. package/dist/core/diagnostics/probes/disk.js +81 -0
  27. package/dist/core/diagnostics/probes/git.js +65 -0
  28. package/dist/core/diagnostics/probes/mcp.js +75 -0
  29. package/dist/core/diagnostics/probes/node.js +59 -0
  30. package/dist/core/diagnostics/probes/pnpm.js +36 -0
  31. package/dist/core/diagnostics/probes/session.js +74 -0
  32. package/dist/core/diagnostics/probes/status-snapshot.js +442 -0
  33. package/dist/core/diagnostics/probes/workspace.js +63 -0
  34. package/dist/core/diagnostics/types.js +70 -0
  35. package/dist/core/edits/dispatch.js +218 -2
  36. package/dist/core/edits/journal.js +199 -0
  37. package/dist/core/edits/layer-d-ast.js +557 -14
  38. package/dist/core/edits/verify-hook.js +273 -0
  39. package/dist/core/edits/worktree.js +111 -18
  40. package/dist/core/engine/anvil-client.js +115 -5
  41. package/dist/core/engine/budgets.js +89 -0
  42. package/dist/core/engine/context-prefix.js +155 -0
  43. package/dist/core/engine/intent.js +260 -0
  44. package/dist/core/engine/native-pugi.js +744 -210
  45. package/dist/core/engine/prompts.js +61 -6
  46. package/dist/core/engine/strip-internal-fields.js +124 -0
  47. package/dist/core/engine/tool-bridge.js +818 -31
  48. package/dist/core/file-cache.js +113 -1
  49. package/dist/core/init/scaffold.js +195 -0
  50. package/dist/core/lsp/client.js +174 -29
  51. package/dist/core/mcp/client.js +75 -6
  52. package/dist/core/mcp/http-server.js +553 -0
  53. package/dist/core/mcp/permission.js +190 -0
  54. package/dist/core/mcp/registry.js +24 -2
  55. package/dist/core/mcp/server-tools.js +219 -0
  56. package/dist/core/mcp/server.js +397 -0
  57. package/dist/core/permissions/gate.js +187 -0
  58. package/dist/core/permissions/index.js +18 -0
  59. package/dist/core/permissions/mode.js +102 -0
  60. package/dist/core/permissions/state.js +160 -0
  61. package/dist/core/permissions/tool-class.js +93 -0
  62. package/dist/core/repl/codebase-survey.js +308 -0
  63. package/dist/core/repl/history.js +11 -1
  64. package/dist/core/repl/init-interview.js +457 -0
  65. package/dist/core/repl/model-pricing.js +135 -0
  66. package/dist/core/repl/onboarding-state.js +297 -0
  67. package/dist/core/repl/session.js +719 -29
  68. package/dist/core/repl/slash-commands.js +133 -9
  69. package/dist/core/retry-budget/budget.js +284 -0
  70. package/dist/core/retry-budget/index.js +5 -0
  71. package/dist/core/settings.js +71 -0
  72. package/dist/core/skills/defaults.js +457 -0
  73. package/dist/core/subagents/dispatcher-real.js +600 -0
  74. package/dist/core/subagents/dispatcher.js +113 -24
  75. package/dist/core/subagents/index.js +18 -5
  76. package/dist/core/subagents/isolation-matrix.js +213 -0
  77. package/dist/core/subagents/spawn.js +19 -4
  78. package/dist/core/transport/version-interceptor.js +166 -0
  79. package/dist/index.js +28 -0
  80. package/dist/runtime/bootstrap.js +190 -0
  81. package/dist/runtime/cli.js +1588 -266
  82. package/dist/runtime/commands/compact.js +296 -0
  83. package/dist/runtime/commands/cost.js +199 -0
  84. package/dist/runtime/commands/delegate.js +289 -0
  85. package/dist/runtime/commands/doctor.js +369 -0
  86. package/dist/runtime/commands/lsp.js +187 -5
  87. package/dist/runtime/commands/mcp.js +824 -0
  88. package/dist/runtime/commands/patch.js +17 -0
  89. package/dist/runtime/commands/permissions.js +87 -0
  90. package/dist/runtime/commands/report.js +299 -0
  91. package/dist/runtime/commands/review-consensus.js +17 -2
  92. package/dist/runtime/commands/roster.js +117 -0
  93. package/dist/runtime/commands/status.js +178 -0
  94. package/dist/runtime/commands/worktree.js +50 -6
  95. package/dist/runtime/headless.js +543 -0
  96. package/dist/runtime/load-hooks-or-exit.js +71 -0
  97. package/dist/runtime/plan-decompose.js +531 -0
  98. package/dist/runtime/version.js +65 -0
  99. package/dist/tools/agent-tool.js +206 -0
  100. package/dist/tools/apply-patch.js +281 -39
  101. package/dist/tools/ask-user-question.js +213 -0
  102. package/dist/tools/ask-user.js +115 -0
  103. package/dist/tools/file-tools.js +85 -14
  104. package/dist/tools/mcp-tool.js +260 -0
  105. package/dist/tools/multi-edit.js +361 -0
  106. package/dist/tools/registry.js +22 -2
  107. package/dist/tools/skill-tool.js +96 -0
  108. package/dist/tools/tasks.js +208 -0
  109. package/dist/tools/web-fetch.js +147 -2
  110. package/dist/tools/web-search.js +458 -0
  111. package/dist/tui/agent-progress-card.js +111 -0
  112. package/dist/tui/agent-tree.js +10 -0
  113. package/dist/tui/ask-modal.js +2 -2
  114. package/dist/tui/ask-user-question-prompt.js +192 -0
  115. package/dist/tui/compact-banner.js +54 -0
  116. package/dist/tui/conversation-pane.js +69 -8
  117. package/dist/tui/cost-table.js +111 -0
  118. package/dist/tui/doctor-table.js +31 -0
  119. package/dist/tui/input-box.js +1 -1
  120. package/dist/tui/markdown-render.js +4 -4
  121. package/dist/tui/repl-render.js +276 -37
  122. package/dist/tui/repl-splash.js +2 -2
  123. package/dist/tui/repl.js +25 -6
  124. package/dist/tui/splash.js +1 -1
  125. package/dist/tui/status-bar.js +94 -16
  126. package/dist/tui/status-table.js +7 -0
  127. package/dist/tui/tool-stream-pane.js +7 -0
  128. package/dist/tui/update-banner.js +20 -2
  129. package/docs/examples/codegraph.mcp.json +10 -0
  130. package/package.json +9 -6
@@ -34,11 +34,15 @@ import { parseSlashCommand } from './slash-commands.js';
34
34
  import { webFetchTool } from '../../tools/web-fetch.js';
35
35
  import { loadSettings } from '../settings.js';
36
36
  import { getJobRegistry } from '../jobs/registry.js';
37
+ import { applyCompactMask } from '../compact/buffer-rewriter.js';
38
+ import { evaluateAutoCompact } from '../compact/auto-trigger.js';
39
+ import { estimateTokensInMany } from '../compact/token-counter.js';
37
40
  import { extractAskTags, extractPlanReviewTags, signatureForAsk, } from './ask.js';
38
41
  import { existsSync, readdirSync, statSync } from 'node:fs';
39
42
  import { resolve as resolvePath } from 'node:path';
40
43
  import { CancellationToken } from './cancellation.js';
41
44
  import { DispatchFSM } from './dispatch-fsm.js';
45
+ import { computeCostUsd, formatCostUsd, formatTokens } from './model-pricing.js';
42
46
  const MAX_TRANSCRIPT_ROWS = 500;
43
47
  const MAX_TOOL_CALLS = 200;
44
48
  const MAX_RECONNECT_ATTEMPTS = 10;
@@ -315,6 +319,19 @@ export class ReplSession {
315
319
  toolCalls: [],
316
320
  transcript: [],
317
321
  tokensDownstreamTotal: 0,
322
+ // α7 cost-meter sprint — cost accumulators land at zero on boot.
323
+ // `sessionStartedAtEpochMs` is set at construction time (vs the
324
+ // server-side `agent.session.opened` event) so the elapsed slot
325
+ // on the status row starts ticking the moment the REPL mounts.
326
+ sessionTokensIn: 0,
327
+ sessionTokensOut: 0,
328
+ sessionCostUsd: 0,
329
+ sessionStartedAtEpochMs: this.now(),
330
+ recentTurns: [],
331
+ turnTokensIn: 0,
332
+ turnTokensOut: 0,
333
+ turnCostUsd: 0,
334
+ lastTurnDelta: null,
318
335
  briefStartedAtEpochMs: undefined,
319
336
  pendingAsk: null,
320
337
  pendingAskSource: null,
@@ -322,6 +339,7 @@ export class ReplSession {
322
339
  pendingPlanReviewSource: null,
323
340
  dispatchState: 'idle',
324
341
  dispatchToolLabel: null,
342
+ lastCompletedOutcome: null,
325
343
  };
326
344
  // α6.9: mirror every FSM transition into the public state so the
327
345
  // status-bar surface can rerender on the next frame. Local listener
@@ -359,6 +377,7 @@ export class ReplSession {
359
377
  apiUrl: this.options.apiUrl,
360
378
  apiKey: this.options.apiKey,
361
379
  workspace: this.options.workspace,
380
+ cyberZoo: this.options.cyberZoo,
362
381
  });
363
382
  this.patch({ sessionId, connection: 'connecting' });
364
383
  this.openStream();
@@ -577,6 +596,18 @@ export class ReplSession {
577
596
  await this.dispatchStop(verdict.persona);
578
597
  return verdict;
579
598
  }
599
+ case 'delegate': {
600
+ // α7.5 Phase 1: surface the dispatch intent inline. The actual
601
+ // wire shape (POST /api/pugi/sessions/:id/delegate) requires the
602
+ // SDK transport extension that ships alongside this PR; the
603
+ // REPL session module wires the call when the matching transport
604
+ // method lands (paired CLI follow-up). Today we surface the
605
+ // delegation intent in the transcript so the operator sees the
606
+ // verdict echo for muscle-memory before the round-trip lights up.
607
+ this.appendSystemLine(`delegate ${verdict.persona}: ${verdict.brief.length > 80 ? `${verdict.brief.slice(0, 77)}...` : verdict.brief}`);
608
+ this.appendSystemLine('Run `pugi delegate <slug> "<brief>"` from a fresh shell while the REPL transport wiring lands.');
609
+ return verdict;
610
+ }
580
611
  case 'dispatch': {
581
612
  await this.dispatchBrief(verdict.brief);
582
613
  return verdict;
@@ -602,11 +633,15 @@ export class ReplSession {
602
633
  return verdict;
603
634
  }
604
635
  case 'cost': {
605
- this.dispatchCost();
636
+ await this.dispatchCost();
637
+ return verdict;
638
+ }
639
+ case 'quota': {
640
+ await this.dispatchQuota();
606
641
  return verdict;
607
642
  }
608
643
  case 'status': {
609
- this.dispatchStatus();
644
+ await this.dispatchStatus();
610
645
  return verdict;
611
646
  }
612
647
  case 'consensus': {
@@ -651,12 +686,205 @@ export class ReplSession {
651
686
  await this.dispatchPrivacy();
652
687
  return verdict;
653
688
  }
689
+ case 'init': {
690
+ // β1 Sl11 → β1a r1 (real inline scaffold, 2026-05-26): invoke
691
+ // `scaffoldPugiWorkspace` directly so the operator gets the
692
+ // same .pugi/ setup they would from `pugi init` on a fresh
693
+ // shell. Already-initialised workspaces (every artifact already
694
+ // present) get the "Already initialised" copy; partial / fresh
695
+ // workspaces get the full Created+Skipped breakdown. Default
696
+ // skills install is best-effort — any error from the bundled
697
+ // pack is surfaced as a system line and does not break the
698
+ // REPL session. The dynamic import keeps the slash dispatcher
699
+ // free of a runtime/cli.ts cycle on every keystroke.
700
+ try {
701
+ const { scaffoldPugiWorkspace } = await import('../../runtime/cli.js');
702
+ const lines = [];
703
+ const result = await scaffoldPugiWorkspace({
704
+ cwd: process.cwd(),
705
+ // Slash callers default to the full default-skills pack so
706
+ // the in-REPL experience matches `pugi init`. Operators who
707
+ // want a minimal scaffold still have the shell command.
708
+ noDefaults: false,
709
+ log: (line) => {
710
+ const trimmed = line.replace(/\n+$/u, '');
711
+ if (trimmed.length > 0)
712
+ lines.push(trimmed);
713
+ },
714
+ });
715
+ if (result.alreadyInitialized) {
716
+ this.appendSystemLine(`.pugi/ already initialised at ${result.root}. ${result.skipped.length} artefact(s) verified.`);
717
+ }
718
+ else {
719
+ this.appendSystemLine(`Pugi initialised at ${result.root}. Created ${result.created.length} artefact(s), skipped ${result.skipped.length}.`);
720
+ }
721
+ if (result.defaultSkills.length > 0) {
722
+ const installed = result.defaultSkills.filter((s) => s.status === 'installed').length;
723
+ const skippedSkills = result.defaultSkills.filter((s) => s.status === 'skipped-existing').length;
724
+ this.appendSystemLine(`Default skills: ${installed} installed, ${skippedSkills} already present.`);
725
+ }
726
+ for (const line of lines)
727
+ this.appendSystemLine(line);
728
+ }
729
+ catch (error) {
730
+ const message = error instanceof Error ? error.message : String(error);
731
+ this.appendSystemLine(`/init failed: ${message}`);
732
+ }
733
+ return verdict;
734
+ }
735
+ case 'mcp': {
736
+ // β4 Sl7 (2026-05-26): /mcp [sub] [args...] forwards to the
737
+ // runtime command. We deliberately route through the same
738
+ // entry-point used by `pugi mcp` from a fresh shell so the
739
+ // surface stays single-sourced. `serve` is refused inline —
740
+ // booting an MCP server inside an active REPL would compete
741
+ // with the REPL itself for stdio, which is exactly the wrong
742
+ // thing to do.
743
+ if (verdict.args[0] === 'serve') {
744
+ this.appendSystemLine('/mcp serve is not safe inside the REPL (it competes for stdio). ' +
745
+ 'Run `pugi mcp serve` from a fresh shell instead.');
746
+ return verdict;
747
+ }
748
+ try {
749
+ const { runMcpCommand } = await import('../../runtime/commands/mcp.js');
750
+ const lines = [];
751
+ await runMcpCommand(verdict.args, {
752
+ workspaceRoot: process.cwd(),
753
+ writeOutput: (_payload, text) => {
754
+ const trimmed = text.replace(/\n+$/u, '');
755
+ if (trimmed.length > 0)
756
+ lines.push(trimmed);
757
+ },
758
+ });
759
+ for (const line of lines)
760
+ this.appendSystemLine(line);
761
+ if (lines.length === 0) {
762
+ this.appendSystemLine('/mcp: no output.');
763
+ }
764
+ }
765
+ catch (error) {
766
+ const message = error instanceof Error ? error.message : String(error);
767
+ this.appendSystemLine(`/mcp failed: ${message}`);
768
+ }
769
+ return verdict;
770
+ }
771
+ case 'doctor': {
772
+ // L17 (2026-05-27): run the doctor probe sweep inline. We
773
+ // dynamic-import the runtime/commands/doctor module so the
774
+ // slash dispatcher does not pull the diagnostics graph
775
+ // (execFileSync + fs probes) into every keystroke. The
776
+ // module's output is captured into local lines so we can
777
+ // render it as system entries in the conversation pane;
778
+ // an Ink-rendered table inside the REPL frame is a follow-up.
779
+ try {
780
+ const { runDoctorCommand, defaultHome } = await import('../../runtime/commands/doctor.js');
781
+ const lines = [];
782
+ await runDoctorCommand({
783
+ cwd: process.cwd(),
784
+ home: defaultHome(),
785
+ env: process.env,
786
+ json: false,
787
+ writeOutput: (_payload, text) => {
788
+ const trimmed = text.replace(/\n+$/u, '');
789
+ if (trimmed.length > 0)
790
+ lines.push(trimmed);
791
+ },
792
+ });
793
+ for (const line of lines)
794
+ this.appendSystemLine(line);
795
+ if (lines.length === 0) {
796
+ this.appendSystemLine('/doctor: no output.');
797
+ }
798
+ }
799
+ catch (error) {
800
+ const message = error instanceof Error ? error.message : String(error);
801
+ this.appendSystemLine(`/doctor failed: ${message}`);
802
+ }
803
+ return verdict;
804
+ }
805
+ case 'permissions': {
806
+ // Leak L6: handle the `/permissions [mode] [--persist]` flow.
807
+ // The session module forwards to the runtime helper so the
808
+ // workspace + global-config writes share one code path with
809
+ // the CLI's top-level `--mode` resolution. The dynamic import
810
+ // keeps the dispatcher free of a session.ts -> runtime/cli.ts
811
+ // cycle.
812
+ try {
813
+ const { runPermissionsCommand } = await import('../../runtime/commands/permissions.js');
814
+ const lines = [];
815
+ await runPermissionsCommand(verdict, {
816
+ workspaceRoot: process.cwd(),
817
+ writeOutput: (line) => {
818
+ const trimmed = line.replace(/\n+$/u, '');
819
+ if (trimmed.length > 0)
820
+ lines.push(trimmed);
821
+ },
822
+ });
823
+ for (const line of lines)
824
+ this.appendSystemLine(line);
825
+ }
826
+ catch (error) {
827
+ const message = error instanceof Error ? error.message : String(error);
828
+ this.appendSystemLine(`/permissions failed: ${message}`);
829
+ }
830
+ return verdict;
831
+ }
832
+ case 'compact': {
833
+ // Leak L8 (2026-05-27): /compact summarises older turns and
834
+ // appends a boundary marker. We forward to the same runner the
835
+ // top-level `pugi compact` command uses so the surface stays
836
+ // single-sourced. The session module owns the in-memory
837
+ // transcript echo (system line + banner row) so the operator
838
+ // sees the marker land without a fresh REPL bootstrap.
839
+ await this.dispatchCompact('manual');
840
+ return verdict;
841
+ }
654
842
  case 'stub': {
655
843
  this.appendSystemLine(verdict.message);
656
844
  return verdict;
657
845
  }
658
846
  }
659
847
  }
848
+ /**
849
+ * Leak L8 (2026-05-27): drive the `/compact` flow from inside the
850
+ * REPL. Reuses the standalone runner so the wire shape + reason
851
+ * codes stay single-sourced. The result is echoed into the
852
+ * transcript as a system line; on success the operator sees the
853
+ * banner sentinel on next render.
854
+ *
855
+ * `trigger='manual'` for explicit `/compact` invocations;
856
+ * `trigger='auto'` for the threshold gate. The runner records the
857
+ * trigger in the marker payload so the banner can distinguish them.
858
+ */
859
+ async dispatchCompact(trigger) {
860
+ if (!this.store || !this.localSessionId) {
861
+ this.appendSystemLine('Local session store is disabled — /compact is unavailable.');
862
+ return;
863
+ }
864
+ try {
865
+ const { runCompactCommand } = await import('../../runtime/commands/compact.js');
866
+ const result = await runCompactCommand([], {
867
+ workspaceRoot: process.cwd(),
868
+ sessionId: this.localSessionId,
869
+ store: this.store,
870
+ trigger,
871
+ writeOutput: (_payload, text) => {
872
+ if (text.length > 0)
873
+ this.appendSystemLine(text);
874
+ },
875
+ });
876
+ if (result.status === 'compacted') {
877
+ // Echo a visible separator into the transcript so the operator
878
+ // immediately sees where the compaction landed. The Ink banner
879
+ // renders the row when the session reloads / resumes.
880
+ this.appendSystemLine(`─── context compacted (${result.turnsBefore} turns → 1 summary, ${trigger}) ───`);
881
+ }
882
+ }
883
+ catch (error) {
884
+ const message = error instanceof Error ? error.message : String(error);
885
+ this.appendSystemLine(`/compact failed: ${message}`);
886
+ }
887
+ }
660
888
  /**
661
889
  * In-REPL `/privacy` - alpha 6.13. Prints the full 3-mode contract
662
890
  * doc + the current mode banner inline. The current mode is fetched
@@ -938,22 +1166,195 @@ export class ReplSession {
938
1166
  this.appendSystemLine(`/diff failed: ${this.errorMessage(error)}`);
939
1167
  }
940
1168
  }
941
- dispatchCost() {
942
- const { tokensDownstreamTotal, agents } = this.state;
1169
+ async dispatchCost() {
1170
+ // α7 cost-meter sprint full breakdown matching the TUI status row
1171
+ // footer. The session totals line mirrors the footer format
1172
+ // (`↑ <in> ↓ <out> · $X.XX · <elapsed>`) so the operator scans the
1173
+ // same numbers in two places. Per-turn list shows the last 5 turns
1174
+ // oldest → newest; an empty list renders one system line so the
1175
+ // operator knows the surface is wired (`No completed turns yet.`).
1176
+ //
1177
+ // L19 (2026-05-27) — after the in-memory recap, also render the
1178
+ // persisted per-model table from `.pugi/cost.json`. That surface
1179
+ // survives a REPL restart and answers the "what did I spend on
1180
+ // claude-opus vs qwen this week?" question the in-memory recap can
1181
+ // not. Errors loading the file collapse to a single warning line so
1182
+ // the in-memory recap (the older, well-tested surface) is never
1183
+ // gated behind a fresh dependency.
1184
+ const { sessionTokensIn, sessionTokensOut, sessionCostUsd, sessionStartedAtEpochMs, recentTurns, agents, } = this.state;
943
1185
  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}.`);
1186
+ const elapsedMs = Math.max(0, this.now() - sessionStartedAtEpochMs);
1187
+ const elapsedLabel = formatElapsedShort(elapsedMs);
1188
+ this.appendSystemLine(`Session: ↑ ${formatTokens(sessionTokensIn)} ↓ ${formatTokens(sessionTokensOut)} · ${formatCostUsd(sessionCostUsd)} · ${elapsedLabel}`);
1189
+ this.appendSystemLine(`Active dispatches: ${active} of cap.`);
1190
+ if (recentTurns.length === 0) {
1191
+ this.appendSystemLine('No completed turns yet — brief the workforce to charge the meter.');
1192
+ }
1193
+ else {
1194
+ this.appendSystemLine(`Recent turns (last ${recentTurns.length}):`);
1195
+ for (let i = 0; i < recentTurns.length; i += 1) {
1196
+ const turn = recentTurns[i];
1197
+ const idx = (i + 1).toString().padStart(2, ' ');
1198
+ this.appendSystemLine(` ${idx}. ${formatTokens(turn.tokensIn)} ↓ ${formatTokens(turn.tokensOut)} · ${formatCostUsd(turn.costUsd)}`);
1199
+ }
1200
+ }
1201
+ // L19: append the persisted per-model table from .pugi/cost.json.
1202
+ try {
1203
+ const [{ createCostTracker }, { renderCostForSlash }] = await Promise.all([
1204
+ import('../cost/tracker.js'),
1205
+ import('../../runtime/commands/cost.js'),
1206
+ ]);
1207
+ const workspaceRoot = this.options.workspace?.workspaceCwd ?? process.cwd();
1208
+ const sessionId = this.state.sessionId ?? 'no-session';
1209
+ const tracker = createCostTracker({
1210
+ workspaceRoot,
1211
+ sessionIdProvider: () => sessionId,
1212
+ now: () => this.now(),
1213
+ });
1214
+ const current = tracker.current();
1215
+ if (current && Object.keys(current.models).length > 0) {
1216
+ this.appendSystemLine('');
1217
+ const { lines } = renderCostForSlash({
1218
+ tracker,
1219
+ allSessions: false,
1220
+ windowDays: 30,
1221
+ now: () => this.now(),
1222
+ });
1223
+ for (const line of lines)
1224
+ this.appendSystemLine(line);
1225
+ }
1226
+ }
1227
+ catch {
1228
+ // best-effort — the persisted view is additive; failure never
1229
+ // breaks the in-memory recap above
1230
+ }
1231
+ }
1232
+ /**
1233
+ * α7 cost-meter sprint — `/quota` slash handler. Fetches the live
1234
+ * `/api/pugi/usage` snapshot and renders three lines: plan tier,
1235
+ * monthly window, and per-counter `used/cap (pct%)`. Failure modes
1236
+ * (offline, unauth, older admin-api) collapse to a single one-line
1237
+ * `Could not fetch quota…` system message so the surface never throws
1238
+ * from a keystroke handler.
1239
+ *
1240
+ * The fetch is best-effort with a 4s timeout — mirrors the `whoami`
1241
+ * pattern in `runtime/cli.ts` so the operator gets the same UX on the
1242
+ * REPL slash and the CLI command.
1243
+ */
1244
+ async dispatchQuota() {
1245
+ const controller = new AbortController();
1246
+ const timer = setTimeout(() => controller.abort(), 4000);
1247
+ try {
1248
+ const url = `${this.options.apiUrl.replace(/\/+$/, '')}/api/pugi/usage`;
1249
+ const res = await fetch(url, {
1250
+ method: 'GET',
1251
+ headers: {
1252
+ authorization: `Bearer ${this.options.apiKey}`,
1253
+ accept: 'application/json',
1254
+ },
1255
+ signal: controller.signal,
1256
+ });
1257
+ if (!res.ok) {
1258
+ this.appendSystemLine(`Could not fetch quota: HTTP ${res.status}.`);
1259
+ return;
1260
+ }
1261
+ const body = (await res.json());
1262
+ const tier = typeof body.tier === 'string' ? body.tier : '(unknown)';
1263
+ const tierLabel = QUOTA_TIER_LABELS[tier] ?? tier;
1264
+ const month = typeof body.billingMonth === 'string' ? body.billingMonth : '(unknown month)';
1265
+ const resetAt = typeof body.resetAt === 'string' ? body.resetAt : null;
1266
+ const resetLine = resetAt ? ` · resets ${formatResetWindow(resetAt, this.now())}` : '';
1267
+ this.appendSystemLine(`Plan: ${tierLabel} · ${month}${resetLine}`);
1268
+ const used = body.used ?? {};
1269
+ const caps = body.quotas ?? {};
1270
+ const counters = [
1271
+ ['sync', used.sync, caps.sync],
1272
+ ['review', used.review, caps.review],
1273
+ ['engine', used.engine, caps.engine],
1274
+ ];
1275
+ for (const [name, value, cap] of counters) {
1276
+ const v = typeof value === 'number' ? value : 0;
1277
+ if (cap === null || cap === undefined) {
1278
+ this.appendSystemLine(` ${name.padEnd(7, ' ')} ${v.toLocaleString()} / unlimited`);
1279
+ }
1280
+ else {
1281
+ const pct = cap > 0 ? Math.round((v / cap) * 100) : 0;
1282
+ this.appendSystemLine(` ${name.padEnd(7, ' ')} ${v.toLocaleString()} / ${cap.toLocaleString()} (${pct}%)`);
1283
+ }
1284
+ }
1285
+ }
1286
+ catch (error) {
1287
+ const msg = error instanceof Error ? error.message : String(error);
1288
+ this.appendSystemLine(`Could not fetch quota: ${msg}.`);
1289
+ }
1290
+ finally {
1291
+ clearTimeout(timer);
1292
+ }
1293
+ }
1294
+ /**
1295
+ * In-REPL `/status` — Leak L34 (2026-05-27). Surfaces the full
1296
+ * session snapshot (id + age, cwd, permission mode, CLI version,
1297
+ * tokens, dispatches, last cmd, compact boundaries, auth identity,
1298
+ * connection) by delegating к the same `runStatusCommand` the
1299
+ * top-level `pugi status` shell uses. Live REPL state (session
1300
+ * id, token totals, last operator command) flows in through the
1301
+ * context so the slash variant shows MORE than the shell path.
1302
+ *
1303
+ * The renderer routes к the system pane via `appendSystemLine`
1304
+ * so the snapshot lands as a single contiguous block в the
1305
+ * conversation transcript. Migrating к the Ink `<StatusTable>`
1306
+ * mounted directly в the REPL frame is a follow-up sprint —
1307
+ * keeping the line-buffered path here avoids cycling the
1308
+ * conversation pane's render model mid-α7.
1309
+ */
1310
+ async dispatchStatus() {
1311
+ try {
1312
+ const { runStatusCommand, defaultStatusHome } = await import('../../runtime/commands/status.js');
1313
+ // Find the most-recent operator transcript row + its timestamp
1314
+ // so the snapshot's `Last cmd` field has real content в REPL
1315
+ // mode. Walking от newest end is O(transcript) worst case but
1316
+ // bounded by MAX_TRANSCRIPT_ROWS so this stays cheap.
1317
+ let lastCommand = null;
1318
+ let lastCommandAtEpochMs = null;
1319
+ for (let i = this.state.transcript.length - 1; i >= 0; i -= 1) {
1320
+ const row = this.state.transcript[i];
1321
+ if (row.source === 'operator') {
1322
+ lastCommand = row.text;
1323
+ lastCommandAtEpochMs = row.timestampEpochMs;
1324
+ break;
1325
+ }
1326
+ }
1327
+ const liveTokens = this.state.sessionTokensIn + this.state.sessionTokensOut;
1328
+ const lines = [];
1329
+ await runStatusCommand({
1330
+ cwd: process.cwd(),
1331
+ home: defaultStatusHome(),
1332
+ env: process.env,
1333
+ json: false,
1334
+ liveSessionId: this.state.sessionId ?? null,
1335
+ sessionStartedAtEpochMs: this.state.sessionStartedAtEpochMs,
1336
+ liveTokensUsed: liveTokens >= 0 ? liveTokens : 0,
1337
+ lastCommand,
1338
+ lastCommandAtEpochMs,
1339
+ writeOutput: (_payload, text) => {
1340
+ for (const line of text.split('\n')) {
1341
+ const trimmed = line.replace(/\s+$/u, '');
1342
+ if (trimmed.length > 0)
1343
+ lines.push(trimmed);
1344
+ }
1345
+ },
1346
+ });
1347
+ if (lines.length === 0) {
1348
+ this.appendSystemLine('/status: no output.');
1349
+ return;
1350
+ }
1351
+ for (const line of lines)
1352
+ this.appendSystemLine(line);
1353
+ }
1354
+ catch (error) {
1355
+ const message = error instanceof Error ? error.message : String(error);
1356
+ this.appendSystemLine(`/status failed: ${message}`);
1357
+ }
957
1358
  }
958
1359
  /**
959
1360
  * α6.5 `/context` slash handler. Surfaces the three-tier context
@@ -1136,7 +1537,10 @@ export class ReplSession {
1136
1537
  this.appendSystemLine(capLine);
1137
1538
  }
1138
1539
  this.appendOperatorLine(brief);
1139
- this.patch({ briefStartedAtEpochMs: this.now() });
1540
+ // Reset `lastCompletedOutcome` so a fresh dispatch does not
1541
+ // inherit the prior turn's status-bar label (e.g. a stale
1542
+ // "replied" sticking around while the next dispatch is in flight).
1543
+ this.patch({ briefStartedAtEpochMs: this.now(), lastCompletedOutcome: null });
1140
1544
  // α6.9 + R3 P1 (Codex triple-review 2026-05-25): supersede the
1141
1545
  // prior dispatch when one is in flight. Steps in order:
1142
1546
  //
@@ -1463,6 +1867,7 @@ export class ReplSession {
1463
1867
  apiUrl: this.options.apiUrl,
1464
1868
  apiKey: this.options.apiKey,
1465
1869
  workspace: this.options.workspace,
1870
+ cyberZoo: this.options.cyberZoo,
1466
1871
  });
1467
1872
  this.patch({ sessionId, connection: 'connecting' });
1468
1873
  this.openStream();
@@ -1619,8 +2024,22 @@ export class ReplSession {
1619
2024
  }
1620
2025
  case 'agent.tokens': {
1621
2026
  const delta = event.tokensIn + event.tokensOut;
2027
+ // α7 cost-meter sprint — bind a client-side USD figure to this
2028
+ // frame. The model slug rides on the event (optional for back-
2029
+ // compat); the price ladder in `model-pricing.ts` falls back to
2030
+ // a Sonnet-tier rate when the slug is missing, so the meter is
2031
+ // always populated. Negative / NaN values are clamped to zero
2032
+ // inside `computeCostUsd` so a buggy upstream never credits the
2033
+ // meter.
2034
+ const deltaCostUsd = computeCostUsd(event.tokensIn, event.tokensOut, event.model);
1622
2035
  this.patch({
1623
2036
  tokensDownstreamTotal: this.state.tokensDownstreamTotal + delta,
2037
+ sessionTokensIn: this.state.sessionTokensIn + event.tokensIn,
2038
+ sessionTokensOut: this.state.sessionTokensOut + event.tokensOut,
2039
+ sessionCostUsd: this.state.sessionCostUsd + deltaCostUsd,
2040
+ turnTokensIn: this.state.turnTokensIn + event.tokensIn,
2041
+ turnTokensOut: this.state.turnTokensOut + event.tokensOut,
2042
+ turnCostUsd: this.state.turnCostUsd + deltaCostUsd,
1624
2043
  agents: this.state.agents.map((a) => a.taskId === event.taskId
1625
2044
  ? {
1626
2045
  ...a,
@@ -1640,10 +2059,36 @@ export class ReplSession {
1640
2059
  }
1641
2060
  this.askBuffer.delete(event.taskId);
1642
2061
  this.askBufferPending.delete(event.taskId);
2062
+ // Honour the work-done signal from admin-api.
2063
+ // `outcome === 'replied'` means the turn was a pure text reply
2064
+ // with no delegate XML and no tool call — render it as
2065
+ // "replied" so the operator can tell the difference between
2066
+ // "the orchestrator just talked" and "real work shipped".
2067
+ // Older servers omit the field; default to 'shipped' so the
2068
+ // existing wire stays back-compat.
2069
+ const completedStatus = event.outcome === 'replied' ? 'replied' : 'shipped';
1643
2070
  this.patch({
1644
2071
  agents: this.state.agents.map((a) => a.taskId === event.taskId
1645
- ? { ...a, status: 'shipped', detail: 'shipped' }
2072
+ ? { ...a, status: completedStatus, detail: completedStatus }
1646
2073
  : a),
2074
+ // Mirror the outcome to top-level state so the status-bar
2075
+ // can render `replied` instead of the legacy `shipped`
2076
+ // label when the FSM lands in `completed`. Without this
2077
+ // the bottom-bar would still say "shipped" while the
2078
+ // agent-tree said "replied", restoring the same
2079
+ // contradiction this PR is fixing (Codex triple-review P2).
2080
+ //
2081
+ // r2: gate on the same stale-dispatch check that
2082
+ // advanceFsmOnDispatchEnd applies. If this completion
2083
+ // belongs to a SUPERSEDED dispatch (a newer dispatchBrief
2084
+ // already bumped dispatchSeq before this late terminal
2085
+ // arrived), don't let the status-bar label flip to the
2086
+ // stale outcome — the current turn is the live one.
2087
+ // The agent-tree row patch above is still safe because
2088
+ // it only updates the row keyed by taskId.
2089
+ ...(this.isStaleTaskEvent(event.taskId)
2090
+ ? {}
2091
+ : { lastCompletedOutcome: completedStatus }),
1647
2092
  });
1648
2093
  // α6.9: transition the FSM to `completed` when no other
1649
2094
  // dispatch is still in flight. The check uses the agents list
@@ -1651,6 +2096,12 @@ export class ReplSession {
1651
2096
  // the dispatch alive; the FSM only goes terminal when the last
1652
2097
  // agent ships.
1653
2098
  this.advanceFsmOnDispatchEnd('completed', 'agent_completed', event.taskId);
2099
+ // α7 cost-meter sprint — flush the per-turn delta when the
2100
+ // LAST agent settles. Decoupled from the FSM gate so a test
2101
+ // fixture (or a single-agent dispatch that never reached
2102
+ // `awaiting_response` — happens on instant SSE replay) still
2103
+ // gets the row written into recentTurns + lastTurnDelta.
2104
+ this.maybeFlushTurnOnAgentSettle(event.taskId);
1654
2105
  if (target) {
1655
2106
  // If the persona actually produced a reply via incremental
1656
2107
  // agent.step events, render that reply in the transcript so
@@ -1716,6 +2167,10 @@ export class ReplSession {
1716
2167
  // operator sees the bottom-bar settle back to `idle` after the
1717
2168
  // last block clears.
1718
2169
  this.advanceFsmOnDispatchEnd('completed', 'agent_blocked', event.taskId);
2170
+ // α7 cost-meter sprint — flush the per-turn delta (blocked
2171
+ // still counts as a billable turn — the operator paid for the
2172
+ // tokens that landed before the refusal).
2173
+ this.maybeFlushTurnOnAgentSettle(event.taskId);
1719
2174
  return;
1720
2175
  }
1721
2176
  case 'agent.failed': {
@@ -1739,6 +2194,10 @@ export class ReplSession {
1739
2194
  // `completed` so the bottom-bar surface tracks the dispatch
1740
2195
  // collectively.
1741
2196
  this.advanceFsmOnDispatchEnd('failed', 'agent_failed', event.taskId);
2197
+ // α7 cost-meter sprint — flush the per-turn delta when the
2198
+ // dispatch fails (the operator still paid for whatever tokens
2199
+ // landed before the failure).
2200
+ this.maybeFlushTurnOnAgentSettle(event.taskId);
1742
2201
  return;
1743
2202
  }
1744
2203
  }
@@ -1777,13 +2236,25 @@ export class ReplSession {
1777
2236
  * after a manual `cancel()` finds the FSM already in `aborted` and
1778
2237
  * is silently dropped.
1779
2238
  */
2239
+ /**
2240
+ * 2026-05-26 — shared stale-task check used by both the FSM advance
2241
+ * gate AND the status-bar `lastCompletedOutcome` mirror. Lifts the
2242
+ * R2 dispatchSeq compare out of `advanceFsmOnDispatchEnd` so other
2243
+ * agent.completed-handler side-effects (status-bar label, future
2244
+ * metric counters) can apply the same guard without duplicating it.
2245
+ * Returns true iff the task's stamped dispatchSeq is older than the
2246
+ * current dispatchSeq — i.e. a newer dispatchBrief() superseded it
2247
+ * and the late terminal event must not corrupt live-turn state.
2248
+ */
2249
+ isStaleTaskEvent(taskId) {
2250
+ const taskSeq = this.taskDispatchSeq.get(taskId);
2251
+ return taskSeq !== undefined && taskSeq < this.dispatchSeq;
2252
+ }
1780
2253
  advanceFsmOnDispatchEnd(outcome, reason, taskId) {
1781
2254
  // R2 P1 fix (Codex triple-review 2026-05-25): a terminal event
1782
2255
  // 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.
2256
+ // the live token. Delegates to isStaleTaskEvent so the agent.completed
2257
+ // status-bar mirror in the handler above uses the same gate.
1787
2258
  if (taskId !== undefined) {
1788
2259
  const taskSeq = this.taskDispatchSeq.get(taskId);
1789
2260
  if (taskSeq !== undefined && taskSeq < this.dispatchSeq) {
@@ -1815,6 +2286,63 @@ export class ReplSession {
1815
2286
  this.currentDispatchToken = null;
1816
2287
  this.patch({ briefStartedAtEpochMs: undefined });
1817
2288
  }
2289
+ /**
2290
+ * α7 cost-meter sprint — gate the per-turn flush on "this was the
2291
+ * LAST in-flight agent". Mirrors the `stillActive` guard inside
2292
+ * `advanceFsmOnDispatchEnd` so a multi-agent dispatch only emits a
2293
+ * single recentTurns row + a single lastTurnDelta flash.
2294
+ *
2295
+ * Idempotent: if no tokens have been billed this turn, the inner
2296
+ * `flushTurnAccumulator` short-circuits without pushing an empty row.
2297
+ */
2298
+ maybeFlushTurnOnAgentSettle(taskId) {
2299
+ const stillActive = this.state.agents.some((a) => a.status === 'queued' || a.status === 'thinking');
2300
+ if (stillActive)
2301
+ return;
2302
+ this.flushTurnAccumulator(taskId);
2303
+ }
2304
+ /**
2305
+ * α7 cost-meter sprint — flush the per-turn accumulator into
2306
+ * `recentTurns` + `lastTurnDelta`. Idempotent + safe to call from any
2307
+ * terminal-state branch (`agent.completed` / `agent.blocked` /
2308
+ * `agent.failed`). When no tokens have been billed this turn
2309
+ * (instant abort, cap-warning gate), the helper short-circuits
2310
+ * without pushing an empty row.
2311
+ */
2312
+ flushTurnAccumulator(taskId) {
2313
+ const turnTokensIn = this.state.turnTokensIn;
2314
+ const turnTokensOut = this.state.turnTokensOut;
2315
+ const turnCostUsd = this.state.turnCostUsd;
2316
+ if (turnTokensIn === 0 && turnTokensOut === 0) {
2317
+ // Idempotent zero-flush — never push an empty row into recentTurns.
2318
+ return;
2319
+ }
2320
+ const turnId = taskId !== undefined ? taskId : `turn-${this.dispatchSeq}-${this.now()}`;
2321
+ const newTurn = {
2322
+ id: turnId,
2323
+ tokensIn: turnTokensIn,
2324
+ tokensOut: turnTokensOut,
2325
+ costUsd: turnCostUsd,
2326
+ completedAt: new Date(this.now()).toISOString(),
2327
+ };
2328
+ // Keep the buffer capped at 5 entries (oldest first). The push
2329
+ // order matches the surface contract: `/cost` paginates oldest →
2330
+ // newest so the operator scans top-down chronologically.
2331
+ const recent = [...this.state.recentTurns, newTurn];
2332
+ const trimmed = recent.length > 5 ? recent.slice(-5) : recent;
2333
+ this.patch({
2334
+ recentTurns: trimmed,
2335
+ lastTurnDelta: {
2336
+ tokensIn: turnTokensIn,
2337
+ tokensOut: turnTokensOut,
2338
+ costUsd: turnCostUsd,
2339
+ completedAtEpochMs: this.now(),
2340
+ },
2341
+ turnTokensIn: 0,
2342
+ turnTokensOut: 0,
2343
+ turnCostUsd: 0,
2344
+ });
2345
+ }
1818
2346
  /* ------------- transcript helpers -------------- */
1819
2347
  /**
1820
2348
  * Look up the persona slug for a running task. Used by the tool call
@@ -1890,6 +2418,62 @@ export class ReplSession {
1890
2418
  // persona -> 'persona'
1891
2419
  // system -> 'system'
1892
2420
  this.persistRow(row);
2421
+ // Leak L8 (2026-05-27): evaluate the auto-compact gate after
2422
+ // every appendRow that produces a transcript turn. Wrapped in a
2423
+ // setImmediate so the gate never blocks the input-handling fast
2424
+ // path; if the threshold is tripped, the auto-trigger dispatches
2425
+ // `/compact` in the background while the operator keeps typing.
2426
+ if (row.source === 'operator' || row.source === 'persona') {
2427
+ this.maybeAutoCompact();
2428
+ }
2429
+ }
2430
+ /**
2431
+ * Auto-compact gate. Cheap: builds an in-memory token estimate from
2432
+ * the current transcript and consults `evaluateAutoCompact`. When the
2433
+ * gate fires AND a compaction is not already in flight, we dispatch
2434
+ * `/compact` with `trigger='auto'`. The fire-and-forget shape means
2435
+ * the input box stays responsive while the background round-trip
2436
+ * runs.
2437
+ *
2438
+ * Hysteresis: `compactionInFlight` blocks re-entry. The gate is
2439
+ * cleared when the dispatch promise resolves regardless of outcome
2440
+ * so a transient transport failure does not permanently disable the
2441
+ * auto-trigger.
2442
+ */
2443
+ compactionInFlight = false;
2444
+ maybeAutoCompact() {
2445
+ if (this.compactionInFlight)
2446
+ return;
2447
+ if (!this.store || !this.localSessionId)
2448
+ return;
2449
+ if (process.env['PUGI_AUTOCOMPACT_DISABLED'] === '1')
2450
+ return;
2451
+ // Token estimate from the in-memory transcript. The estimate is a
2452
+ // lower bound on actual context pressure (server-side system
2453
+ // prompts add overhead) but the 4-char/token heuristic plus the
2454
+ // 0.75 default threshold gives generous headroom.
2455
+ const texts = this.state.transcript.map((r) => r.text);
2456
+ const tokenCount = estimateTokensInMany(texts);
2457
+ // Conservative default: assume the smallest commonly-used window
2458
+ // (32k tokens for deepseek-v3.1). Resolving the live model slug
2459
+ // through DispatchFSM + admin-api adds latency on a hot path; the
2460
+ // 0.75 threshold + smallest-window assumption errs toward
2461
+ // EARLY trigger which is the safe direction.
2462
+ const verdict = evaluateAutoCompact({
2463
+ tokenCount,
2464
+ windowSize: 32_000,
2465
+ });
2466
+ if (verdict.kind !== 'fire')
2467
+ return;
2468
+ this.compactionInFlight = true;
2469
+ void (async () => {
2470
+ try {
2471
+ await this.dispatchCompact('auto');
2472
+ }
2473
+ finally {
2474
+ this.compactionInFlight = false;
2475
+ }
2476
+ })();
1893
2477
  }
1894
2478
  /**
1895
2479
  * Best-effort write of one transcript row into the local
@@ -1940,8 +2524,14 @@ export class ReplSession {
1940
2524
  * write the restored events.
1941
2525
  */
1942
2526
  restoreTranscript(events) {
2527
+ // Leak L8 (2026-05-27): apply compact-boundary masking BEFORE the
2528
+ // row conversion. Events strictly before the latest marker are
2529
+ // condensed into the boundary's `keptTailTurns + marker` slice so
2530
+ // the post-resume transcript starts at the most-recent context
2531
+ // floor rather than re-playing the full pre-compaction history.
2532
+ const masked = applyCompactMask(events);
1943
2533
  const rows = [];
1944
- for (const event of events) {
2534
+ for (const event of masked) {
1945
2535
  const row = eventToTranscriptRow(event);
1946
2536
  if (row)
1947
2537
  rows.push(row);
@@ -2129,6 +2719,25 @@ function eventToTranscriptRow(event) {
2129
2719
  timestampEpochMs: event.t,
2130
2720
  };
2131
2721
  }
2722
+ if (event.kind === 'compaction') {
2723
+ // Leak L8: render the marker as a system separator line on
2724
+ // replay. The full summary text is intentionally NOT inlined here
2725
+ // (a 2k-token summary in the transcript would defeat the purpose
2726
+ // of compacting); the operator sees the "context compacted"
2727
+ // banner and can run `/context` to inspect the marker payload
2728
+ // when they want the details.
2729
+ const compactionPayload = (event.payload ?? null);
2730
+ const trigger = compactionPayload?.trigger === 'auto' ? 'auto' : 'manual';
2731
+ const turns = typeof compactionPayload?.summaryTurnsBefore === 'number'
2732
+ ? compactionPayload.summaryTurnsBefore
2733
+ : 0;
2734
+ return {
2735
+ id: randomUUID(),
2736
+ source: 'system',
2737
+ text: `─── context compacted (${turns} turns → 1 summary, ${trigger}) ───`,
2738
+ timestampEpochMs: event.t,
2739
+ };
2740
+ }
2132
2741
  return null;
2133
2742
  }
2134
2743
  /**
@@ -2197,6 +2806,62 @@ function formatAgeSeconds(deltaMs) {
2197
2806
  export function knownRoles() {
2198
2807
  return listRoles();
2199
2808
  }
2809
+ /**
2810
+ * α7 cost-meter sprint — render a session-elapsed ms delta as the
2811
+ * status-row's compact `XmYs` / `XhYm` shape. Distinct from
2812
+ * `formatAgeSeconds` above because `/cost` needs minute-granularity
2813
+ * uniformly (operator wants `2m44s`, not `2m`). Pure / branch-cheap;
2814
+ * the TUI status row + `/cost` both call this on every render.
2815
+ */
2816
+ function formatElapsedShort(elapsedMs) {
2817
+ if (!Number.isFinite(elapsedMs) || elapsedMs <= 0)
2818
+ return '0s';
2819
+ const totalSec = Math.floor(elapsedMs / 1000);
2820
+ if (totalSec < 60)
2821
+ return `${totalSec}s`;
2822
+ const min = Math.floor(totalSec / 60);
2823
+ const sec = totalSec % 60;
2824
+ if (min < 60)
2825
+ return `${min}m${sec.toString().padStart(2, '0')}s`;
2826
+ const hr = Math.floor(min / 60);
2827
+ const restMin = min % 60;
2828
+ return `${hr}h${restMin.toString().padStart(2, '0')}m`;
2829
+ }
2830
+ /**
2831
+ * α7 cost-meter sprint — public-facing tier labels for the `/quota`
2832
+ * slash. Mirrors `TIER_PRICE_LABEL` in `runtime/cli.ts` (kept in sync
2833
+ * via `pricing.spec.ts` gate). Falls through to the raw slug when an
2834
+ * unknown tier ships from a forward-compat admin-api build.
2835
+ */
2836
+ const QUOTA_TIER_LABELS = Object.freeze({
2837
+ free: 'Free',
2838
+ founder: 'Founder ($20/mo)',
2839
+ builder: 'Builder ($99/mo)',
2840
+ team: 'Team ($199/mo)',
2841
+ });
2842
+ /**
2843
+ * α7 cost-meter sprint — render the time-until-reset window for the
2844
+ * `/quota` plan line. `resetAt` is the ISO string admin-api returns;
2845
+ * `now` is the current epoch ms (injected for test determinism). Falls
2846
+ * back to the raw ISO string when parsing fails so the operator never
2847
+ * sees an empty hint.
2848
+ */
2849
+ function formatResetWindow(resetAtIso, nowEpochMs) {
2850
+ const resetMs = Date.parse(resetAtIso);
2851
+ if (!Number.isFinite(resetMs))
2852
+ return resetAtIso;
2853
+ const deltaMs = resetMs - nowEpochMs;
2854
+ if (deltaMs <= 0)
2855
+ return 'now';
2856
+ const days = Math.floor(deltaMs / (24 * 60 * 60 * 1000));
2857
+ if (days >= 2)
2858
+ return `in ${days}d`;
2859
+ const hours = Math.floor(deltaMs / (60 * 60 * 1000));
2860
+ if (hours >= 1)
2861
+ return `in ${hours}h`;
2862
+ const minutes = Math.max(1, Math.floor(deltaMs / (60 * 1000)));
2863
+ return `in ${minutes}m`;
2864
+ }
2200
2865
  /* ------------------------------------------------------------------ */
2201
2866
  /* Tool call synthesiser - α6.12 */
2202
2867
  /* ------------------------------------------------------------------ */
@@ -2230,7 +2895,7 @@ export function synthesiseToolCall(input) {
2230
2895
  // Pattern: ToolName(args) optionally suffixed with a result hint.
2231
2896
  // We allow the canonical Claude Code casing AND the snake_case
2232
2897
  // 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
2898
+ const match = /^(Read|Write|Edit|Bash|Grep|Glob|WebFetch|web_fetch)\s*\(\s*([^)]*)\s*\)\s*(.*)$/i
2234
2899
  .exec(detail);
2235
2900
  if (!match)
2236
2901
  return null;
@@ -2254,6 +2919,8 @@ function normaliseToolName(raw) {
2254
2919
  return 'web_fetch';
2255
2920
  if (lower === 'read')
2256
2921
  return 'read';
2922
+ if (lower === 'write')
2923
+ return 'write';
2257
2924
  if (lower === 'edit')
2258
2925
  return 'edit';
2259
2926
  if (lower === 'bash')
@@ -2479,7 +3146,22 @@ export function stripPersonaPrefixEcho(personaSlug, text) {
2479
3146
  // Escape regex specials in the display name even though THE_TEN
2480
3147
  // names are alpha-only today (forward-defense).
2481
3148
  const escaped = display.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
3149
+ // Match `<DisplayName>` (case-insensitive) followed by EITHER:
3150
+ // - an end-of-string, OR
3151
+ // - a separator (whitespace / comma / colon / dash / period+space).
3152
+ // The `i` flag is needed so a model writing "PUGI:" or "pugi," still
3153
+ // strips. After this match the post-fix `noSepUppercaseRe` handles
3154
+ // the "PugiПринял" / "PugiHello" no-separator emission pattern
3155
+ // (CEO red-alert 2026-05-27) using a SEPARATE regex without the `i`
3156
+ // flag so the lookahead is case-strict (Pugineous must NOT strip).
2482
3157
  const re = new RegExp(`^${escaped}(?:[\\s,:;\\-—–]+|$)`, 'i');
3158
+ // No-separator case-strict matcher. Display name in either of its
3159
+ // canonical casings ("Pugi" / "PUGI") immediately followed by an
3160
+ // uppercase Cyrillic or Latin letter. The strip is intentionally
3161
+ // narrower than the case-insensitive `re` above because a lowercase
3162
+ // continuation ("Pugineous") is a single word, not a display-name
3163
+ // echo - we must not eat real content.
3164
+ const noSepUppercaseRe = new RegExp(`^(?:${escaped}|${escaped.toUpperCase()})(?=[А-ЯЁA-Z])`);
2483
3165
  // Loop the strip so cascading echoes ("Pugi Pugi Pugi, координатор ...")
2484
3166
  // collapse to a single name. The model occasionally emits the display
2485
3167
  // name two or three times back-to-back when the pane prefix also
@@ -2491,10 +3173,18 @@ export function stripPersonaPrefixEcho(personaSlug, text) {
2491
3173
  // matches an empty string (defence-in-depth even though the current
2492
3174
  // pattern guarantees at least one consumed char).
2493
3175
  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();
3176
+ let m = re.exec(working);
3177
+ if (m && m[0].length > 0) {
3178
+ working = working.slice(m[0].length).trimStart();
3179
+ continue;
3180
+ }
3181
+ // Fallback: no-separator match for "PugiПринял" / "PugiHello" shape.
3182
+ m = noSepUppercaseRe.exec(working);
3183
+ if (m && m[0].length > 0) {
3184
+ working = working.slice(m[0].length);
3185
+ continue;
3186
+ }
3187
+ break;
2498
3188
  }
2499
3189
  return working;
2500
3190
  }