@pugi/cli 0.1.0-beta.5 → 0.1.0-beta.50

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 (263) hide show
  1. package/THIRD_PARTY_NOTICES.md +40 -0
  2. package/assets/pugi-mascot.ansi +15 -25
  3. package/assets/pugi-prozr2-mascot.ansi +9 -0
  4. package/bin/run.js +33 -1
  5. package/dist/commands/jobs-watch.js +201 -0
  6. package/dist/commands/jobs.js +15 -0
  7. package/dist/commands/smoke.js +133 -0
  8. package/dist/core/agent-progress/cleanup.js +134 -0
  9. package/dist/core/agent-progress/schema.js +144 -0
  10. package/dist/core/agent-progress/writer.js +101 -0
  11. package/dist/core/artifact-chain/dispatcher.js +148 -0
  12. package/dist/core/artifact-chain/exporter.js +164 -0
  13. package/dist/core/artifact-chain/state.js +243 -0
  14. package/dist/core/artifact-chain/steps.js +169 -0
  15. package/dist/core/auth/ensure-authenticated.js +129 -0
  16. package/dist/core/auth/env-provider.js +238 -0
  17. package/dist/core/auto-update/channels.js +122 -0
  18. package/dist/core/auto-update/checker.js +241 -0
  19. package/dist/core/auto-update/state.js +235 -0
  20. package/dist/core/bare-mode/index.js +107 -0
  21. package/dist/core/bash-classifier.js +400 -4
  22. package/dist/core/checkpoint/resumer.js +149 -0
  23. package/dist/core/checkpoint/rewinder.js +291 -0
  24. package/dist/core/codegraph/decision-store.js +248 -0
  25. package/dist/core/codegraph/detect-repo.js +459 -0
  26. package/dist/core/codegraph/install.js +134 -0
  27. package/dist/core/codegraph/offer-hook.js +220 -0
  28. package/dist/core/compact/auto-trigger.js +96 -0
  29. package/dist/core/compact/buffer-rewriter.js +115 -0
  30. package/dist/core/compact/summarizer.js +208 -0
  31. package/dist/core/compact/token-counter.js +108 -0
  32. package/dist/core/consensus/diff-capture.js +112 -3
  33. package/dist/core/context/index.js +7 -0
  34. package/dist/core/context/markdown-traverse.js +255 -0
  35. package/dist/core/cost/rate-card.js +129 -0
  36. package/dist/core/cost/tracker.js +221 -0
  37. package/dist/core/denial-tracking/index.js +8 -0
  38. package/dist/core/denial-tracking/state.js +264 -0
  39. package/dist/core/diagnostics/probe-runner.js +93 -0
  40. package/dist/core/diagnostics/probes/api.js +46 -0
  41. package/dist/core/diagnostics/probes/auth.js +86 -0
  42. package/dist/core/diagnostics/probes/bare-mode.js +42 -0
  43. package/dist/core/diagnostics/probes/cli-version.js +127 -0
  44. package/dist/core/diagnostics/probes/config.js +72 -0
  45. package/dist/core/diagnostics/probes/denial-tracking.js +57 -0
  46. package/dist/core/diagnostics/probes/disk.js +81 -0
  47. package/dist/core/diagnostics/probes/git.js +65 -0
  48. package/dist/core/diagnostics/probes/hooks.js +118 -0
  49. package/dist/core/diagnostics/probes/mcp.js +75 -0
  50. package/dist/core/diagnostics/probes/node.js +59 -0
  51. package/dist/core/diagnostics/probes/pnpm.js +36 -0
  52. package/dist/core/diagnostics/probes/pugi-md.js +89 -0
  53. package/dist/core/diagnostics/probes/sandbox.js +40 -0
  54. package/dist/core/diagnostics/probes/session.js +74 -0
  55. package/dist/core/diagnostics/probes/status-snapshot.js +488 -0
  56. package/dist/core/diagnostics/probes/workspace.js +63 -0
  57. package/dist/core/diagnostics/types.js +70 -0
  58. package/dist/core/dispatch/cache-cleanup.js +197 -0
  59. package/dist/core/dispatch/cache-handoff.js +295 -0
  60. package/dist/core/edits/dispatch.js +218 -2
  61. package/dist/core/edits/journal.js +199 -0
  62. package/dist/core/edits/layer-d-ast.js +557 -14
  63. package/dist/core/edits/verify-hook.js +273 -0
  64. package/dist/core/edits/worktree.js +322 -0
  65. package/dist/core/engine/anvil-client.js +115 -5
  66. package/dist/core/engine/budgets.js +98 -0
  67. package/dist/core/engine/context-prefix.js +155 -0
  68. package/dist/core/engine/intent.js +260 -0
  69. package/dist/core/engine/native-pugi.js +860 -211
  70. package/dist/core/engine/prompts.js +88 -2
  71. package/dist/core/engine/strip-internal-fields.js +124 -0
  72. package/dist/core/engine/tool-bridge.js +1045 -36
  73. package/dist/core/feedback/queue.js +177 -0
  74. package/dist/core/feedback/submitter.js +145 -0
  75. package/dist/core/file-cache.js +113 -1
  76. package/dist/core/hooks/events.js +44 -0
  77. package/dist/core/hooks/index.js +15 -0
  78. package/dist/core/hooks/registry.js +213 -0
  79. package/dist/core/hooks/runner.js +236 -0
  80. package/dist/core/hooks/v2/event-emitter.js +115 -0
  81. package/dist/core/hooks/v2/executor.js +282 -0
  82. package/dist/core/hooks/v2/index.js +25 -0
  83. package/dist/core/hooks/v2/lifecycle.js +104 -0
  84. package/dist/core/hooks/v2/loader.js +216 -0
  85. package/dist/core/hooks/v2/matcher.js +125 -0
  86. package/dist/core/hooks/v2/trust.js +143 -0
  87. package/dist/core/hooks/v2/types.js +86 -0
  88. package/dist/core/lsp/cache.js +105 -0
  89. package/dist/core/lsp/client.js +776 -0
  90. package/dist/core/lsp/language-detect.js +66 -0
  91. package/dist/core/lsp/post-edit-diagnostics.js +171 -0
  92. package/dist/core/mcp/client.js +75 -6
  93. package/dist/core/mcp/http-server.js +553 -0
  94. package/dist/core/mcp/orchestrator-tools.js +662 -0
  95. package/dist/core/mcp/permission.js +190 -0
  96. package/dist/core/mcp/registry.js +24 -2
  97. package/dist/core/mcp/server-tools.js +219 -0
  98. package/dist/core/mcp/server.js +397 -0
  99. package/dist/core/memory/dual-write.js +416 -0
  100. package/dist/core/memory/phase1-kinds.js +20 -0
  101. package/dist/core/memory-sync/queue.js +158 -0
  102. package/dist/core/onboarding/ensure-initialized.js +133 -0
  103. package/dist/core/onboarding/marker.js +111 -0
  104. package/dist/core/onboarding/telemetry-state.js +108 -0
  105. package/dist/core/output-style/presets.js +176 -0
  106. package/dist/core/output-style/state.js +185 -0
  107. package/dist/core/path-security.js +284 -2
  108. package/dist/core/permissions/auto-classifier.js +124 -0
  109. package/dist/core/permissions/circuit-breaker.js +83 -0
  110. package/dist/core/permissions/gate.js +278 -0
  111. package/dist/core/permissions/index.js +20 -0
  112. package/dist/core/permissions/mode.js +174 -0
  113. package/dist/core/permissions/state.js +241 -0
  114. package/dist/core/permissions/tool-class.js +93 -0
  115. package/dist/core/prd-check/parser.js +215 -0
  116. package/dist/core/prd-check/reporter.js +127 -0
  117. package/dist/core/prd-check/session-review.js +557 -0
  118. package/dist/core/prd-check/verifiers.js +223 -0
  119. package/dist/core/pugi-md/context-injector.js +76 -0
  120. package/dist/core/pugi-md/walk-up.js +207 -0
  121. package/dist/core/release-notes/parser.js +241 -0
  122. package/dist/core/release-notes/state.js +116 -0
  123. package/dist/core/repl/history.js +11 -1
  124. package/dist/core/repl/model-pricing.js +135 -0
  125. package/dist/core/repl/session.js +1897 -37
  126. package/dist/core/repl/slash-commands.js +430 -15
  127. package/dist/core/repl/store/session-store.js +31 -2
  128. package/dist/core/repl/workspace-context.js +22 -0
  129. package/dist/core/repo-map/build.js +125 -0
  130. package/dist/core/repo-map/cache.js +185 -0
  131. package/dist/core/repo-map/extractor.js +254 -0
  132. package/dist/core/repo-map/formatter.js +145 -0
  133. package/dist/core/repo-map/scanner.js +211 -0
  134. package/dist/core/retry-budget/budget.js +284 -0
  135. package/dist/core/retry-budget/index.js +5 -0
  136. package/dist/core/session.js +92 -0
  137. package/dist/core/settings.js +80 -0
  138. package/dist/core/share/formatter.js +271 -0
  139. package/dist/core/share/redactor.js +221 -0
  140. package/dist/core/share/uploader.js +267 -0
  141. package/dist/core/skills/defaults.js +457 -0
  142. package/dist/core/smoke/headless-driver.js +174 -0
  143. package/dist/core/smoke/orchestrator.js +194 -0
  144. package/dist/core/smoke/runner.js +238 -0
  145. package/dist/core/smoke/scenario-parser.js +316 -0
  146. package/dist/core/subagents/dispatcher-real.js +600 -0
  147. package/dist/core/subagents/dispatcher.js +113 -24
  148. package/dist/core/subagents/index.js +18 -5
  149. package/dist/core/subagents/isolation-matrix.js +213 -0
  150. package/dist/core/subagents/spawn.js +19 -4
  151. package/dist/core/telemetry/emitter.js +229 -0
  152. package/dist/core/telemetry/queue.js +251 -0
  153. package/dist/core/theme/context.js +91 -0
  154. package/dist/core/theme/presets.js +228 -0
  155. package/dist/core/theme/state.js +181 -0
  156. package/dist/core/todos/invariant.js +10 -0
  157. package/dist/core/todos/state.js +177 -0
  158. package/dist/core/transport/version-interceptor.js +166 -0
  159. package/dist/core/vim/keymap.js +288 -0
  160. package/dist/core/vim/state.js +92 -0
  161. package/dist/core/worktree-manager/cleanup.js +123 -0
  162. package/dist/core/worktree-manager/manager.js +303 -0
  163. package/dist/index.js +28 -0
  164. package/dist/runtime/bootstrap.js +190 -0
  165. package/dist/runtime/cli.js +3241 -343
  166. package/dist/runtime/commands/cancel.js +231 -0
  167. package/dist/runtime/commands/chain.js +489 -0
  168. package/dist/runtime/commands/codegraph-status.js +227 -0
  169. package/dist/runtime/commands/compact.js +297 -0
  170. package/dist/runtime/commands/cost.js +199 -0
  171. package/dist/runtime/commands/delegate.js +242 -11
  172. package/dist/runtime/commands/dispatch.js +126 -0
  173. package/dist/runtime/commands/doctor.js +412 -0
  174. package/dist/runtime/commands/feedback.js +184 -0
  175. package/dist/runtime/commands/hooks.js +184 -0
  176. package/dist/runtime/commands/lsp.js +368 -0
  177. package/dist/runtime/commands/mcp.js +879 -0
  178. package/dist/runtime/commands/memory.js +508 -0
  179. package/dist/runtime/commands/model.js +237 -0
  180. package/dist/runtime/commands/onboarding.js +275 -0
  181. package/dist/runtime/commands/patch.js +128 -0
  182. package/dist/runtime/commands/permissions.js +112 -0
  183. package/dist/runtime/commands/plan.js +143 -0
  184. package/dist/runtime/commands/prd-check.js +285 -0
  185. package/dist/runtime/commands/redo-blob-store.js +92 -0
  186. package/dist/runtime/commands/redo.js +361 -0
  187. package/dist/runtime/commands/release-notes.js +229 -0
  188. package/dist/runtime/commands/repo-map.js +95 -0
  189. package/dist/runtime/commands/report.js +299 -0
  190. package/dist/runtime/commands/resume.js +118 -0
  191. package/dist/runtime/commands/review-consensus.js +17 -2
  192. package/dist/runtime/commands/rewind.js +333 -0
  193. package/dist/runtime/commands/sessions.js +163 -0
  194. package/dist/runtime/commands/share.js +316 -0
  195. package/dist/runtime/commands/status.js +186 -0
  196. package/dist/runtime/commands/stickers.js +82 -0
  197. package/dist/runtime/commands/style.js +194 -0
  198. package/dist/runtime/commands/theme.js +196 -0
  199. package/dist/runtime/commands/undo.js +32 -0
  200. package/dist/runtime/commands/update.js +289 -0
  201. package/dist/runtime/commands/vim.js +140 -0
  202. package/dist/runtime/commands/worktree.js +177 -0
  203. package/dist/runtime/commands/worktrees.js +155 -0
  204. package/dist/runtime/headless-repl.js +195 -0
  205. package/dist/runtime/headless.js +543 -0
  206. package/dist/runtime/load-hooks-or-exit.js +71 -0
  207. package/dist/runtime/plan-decompose.js +531 -0
  208. package/dist/runtime/version.js +65 -0
  209. package/dist/tools/agent-tool.js +229 -0
  210. package/dist/tools/apply-patch.js +556 -0
  211. package/dist/tools/ask-user-question.js +213 -0
  212. package/dist/tools/ask-user.js +115 -0
  213. package/dist/tools/bash.js +203 -4
  214. package/dist/tools/file-tools.js +85 -14
  215. package/dist/tools/lsp-tools.js +189 -0
  216. package/dist/tools/mcp-tool.js +260 -0
  217. package/dist/tools/multi-edit.js +361 -0
  218. package/dist/tools/powershell.js +268 -0
  219. package/dist/tools/registry.js +51 -0
  220. package/dist/tools/skill-tool.js +96 -0
  221. package/dist/tools/tasks.js +208 -0
  222. package/dist/tools/todo-write.js +184 -0
  223. package/dist/tools/web-fetch.js +147 -2
  224. package/dist/tools/web-search.js +458 -0
  225. package/dist/tui/agent-progress-card.js +111 -0
  226. package/dist/tui/agent-tree.js +10 -0
  227. package/dist/tui/ask-modal.js +2 -2
  228. package/dist/tui/ask-user-question-prompt.js +192 -0
  229. package/dist/tui/compact-banner.js +81 -0
  230. package/dist/tui/conversation-pane.js +82 -8
  231. package/dist/tui/cost-table.js +111 -0
  232. package/dist/tui/doctor-table.js +46 -0
  233. package/dist/tui/feedback-prompt.js +156 -0
  234. package/dist/tui/input-box.js +218 -3
  235. package/dist/tui/markdown-render.js +4 -4
  236. package/dist/tui/onboarding-wizard.js +240 -0
  237. package/dist/tui/permissions-picker.js +86 -0
  238. package/dist/tui/render.js +35 -0
  239. package/dist/tui/repl-render.js +313 -35
  240. package/dist/tui/repl-splash-art.js +1 -1
  241. package/dist/tui/repl-splash-mascot.js +32 -8
  242. package/dist/tui/repl-splash.js +2 -2
  243. package/dist/tui/repl.js +85 -5
  244. package/dist/tui/splash.js +1 -1
  245. package/dist/tui/status-bar.js +94 -16
  246. package/dist/tui/status-table.js +7 -0
  247. package/dist/tui/stickers-art.js +136 -0
  248. package/dist/tui/style-table.js +28 -0
  249. package/dist/tui/theme-table.js +29 -0
  250. package/dist/tui/thinking-spinner.js +123 -0
  251. package/dist/tui/tool-stream-pane.js +52 -3
  252. package/dist/tui/update-banner.js +27 -2
  253. package/dist/tui/vim-input.js +267 -0
  254. package/dist/tui/welcome-banner.js +107 -0
  255. package/dist/tui/welcome-data.js +293 -0
  256. package/docs/examples/codegraph.mcp.json +10 -0
  257. package/package.json +12 -6
  258. package/test/scenarios/codegen-create-file.scenario.txt +13 -0
  259. package/test/scenarios/compact-force.scenario.txt +11 -0
  260. package/test/scenarios/identity.scenario.txt +11 -0
  261. package/test/scenarios/persona-handoff.scenario.txt +11 -0
  262. package/test/scenarios/walkback.scenario.txt +12 -0
  263. package/dist/core/engine/compaction-hook.js +0 -154
@@ -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,13 +35,34 @@ 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;
50
+ /**
51
+ * Wave 6 small-CC-parity batch (2026-05-27): width cap for the inline
52
+ * `streamingDelta` tail rendered next to the args while the call is
53
+ * `running`. Keeps the tool-stream row single-line on an 80-col
54
+ * terminal even when Bash output blasts through stdout. Exported so the
55
+ * spec can pin the truncation behaviour.
56
+ */
57
+ export const STREAMING_DELTA_MAX_CHARS = 80;
58
+ /**
59
+ * Wave 6 small-CC-parity batch (2026-05-27): character cap for the
60
+ * collapsed `resultPreview` on a completed row. The pane shows
61
+ * `✓ Read(file) OK (2ms) "first 50 chars…"` so the operator sees what
62
+ * the tool produced without expanding. Per CEO spec (50 chars).
63
+ * Exported so the spec + the pane share one source of truth.
64
+ */
65
+ export const RESULT_PREVIEW_MAX_CHARS = 50;
44
66
  const MAX_RECONNECT_ATTEMPTS = 10;
45
67
  const RECONNECT_BASE_MS = 250;
46
68
  const RECONNECT_MAX_MS = 5_000;
@@ -315,6 +337,19 @@ export class ReplSession {
315
337
  toolCalls: [],
316
338
  transcript: [],
317
339
  tokensDownstreamTotal: 0,
340
+ // α7 cost-meter sprint — cost accumulators land at zero on boot.
341
+ // `sessionStartedAtEpochMs` is set at construction time (vs the
342
+ // server-side `agent.session.opened` event) so the elapsed slot
343
+ // on the status row starts ticking the moment the REPL mounts.
344
+ sessionTokensIn: 0,
345
+ sessionTokensOut: 0,
346
+ sessionCostUsd: 0,
347
+ sessionStartedAtEpochMs: this.now(),
348
+ recentTurns: [],
349
+ turnTokensIn: 0,
350
+ turnTokensOut: 0,
351
+ turnCostUsd: 0,
352
+ lastTurnDelta: null,
318
353
  briefStartedAtEpochMs: undefined,
319
354
  pendingAsk: null,
320
355
  pendingAskSource: null,
@@ -322,6 +357,7 @@ export class ReplSession {
322
357
  pendingPlanReviewSource: null,
323
358
  dispatchState: 'idle',
324
359
  dispatchToolLabel: null,
360
+ lastCompletedOutcome: null,
325
361
  };
326
362
  // α6.9: mirror every FSM transition into the public state so the
327
363
  // status-bar surface can rerender on the next frame. Local listener
@@ -359,6 +395,7 @@ export class ReplSession {
359
395
  apiUrl: this.options.apiUrl,
360
396
  apiKey: this.options.apiKey,
361
397
  workspace: this.options.workspace,
398
+ cyberZoo: this.options.cyberZoo,
362
399
  });
363
400
  this.patch({ sessionId, connection: 'connecting' });
364
401
  this.openStream();
@@ -371,6 +408,18 @@ export class ReplSession {
371
408
  // admin-api down) is silent - the operator can still type
372
409
  // `/privacy` to see the contract.
373
410
  void this.fetchAndAnnouncePrivacyMode().catch(() => undefined);
411
+ // Leak L21 (2026-05-27): silently drain any feedback envelopes
412
+ // that landed offline during a previous session. Best-effort —
413
+ // a failed flush leaves the queue intact for the next start.
414
+ // Never blocks bootstrap.
415
+ void this.flushFeedbackQueueOnBootstrap().catch(() => undefined);
416
+ // Wave 6 BT 9 Phase 2 (2026-05-27): codegraph cold-start hook.
417
+ // Surfaces ONE of two nudges:
418
+ // - stale-index reminder ("Codegraph index is N days old…")
419
+ // - 30-day post-decline reminder ("Detected medium TS repo…")
420
+ // Skips silently in every other case. Best-effort — a failed
421
+ // detection NEVER blocks bootstrap (the helper itself catches).
422
+ void this.runCodegraphColdStart().catch(() => undefined);
374
423
  }
375
424
  catch (error) {
376
425
  this.appendSystemLine(`Could not open Pugi session: ${this.errorMessage(error)}`);
@@ -414,6 +463,62 @@ export class ReplSession {
414
463
  // Silent fail - offline / DNS / unauth all collapse to no banner.
415
464
  }
416
465
  }
466
+ /**
467
+ * Leak L21 (2026-05-27): on bootstrap, drain the local feedback
468
+ * queue silently. Operators who ran `pugi feedback` while offline
469
+ * see their envelopes flushed on the next online session without
470
+ * any extra command. The drain is best-effort and never blocks
471
+ * the REPL — a failed flush leaves the queue intact for the next
472
+ * bootstrap attempt.
473
+ */
474
+ async flushFeedbackQueueOnBootstrap() {
475
+ const { flushFeedbackQueueSilently } = await import('../../runtime/commands/feedback.js');
476
+ await flushFeedbackQueueSilently(process.cwd(), {
477
+ apiUrl: this.options.apiUrl,
478
+ apiKey: this.options.apiKey,
479
+ });
480
+ }
481
+ /**
482
+ * Wave 6 BT 9 Phase 2 (2026-05-27): codegraph cold-start nudge.
483
+ *
484
+ * Surfaces ONE of two nudges on REPL boot when the gate trips:
485
+ * - 30-day post-decline reminder ("Detected medium TS repo…")
486
+ * - stale-index reminder ("Codegraph index is N days old…")
487
+ *
488
+ * The evaluator is pure; we stamp `lastReindexCheckAt` here so the
489
+ * stale-index nudge throttles к once-per-day. The init-flow first-
490
+ * run prompt is handled separately by `pugi init` to avoid double-
491
+ * prompting в the common "init + then code" boot sequence.
492
+ *
493
+ * Best-effort: any error inside the codegraph module is swallowed —
494
+ * a cold-start nudge that breaks the REPL would be worse than no
495
+ * nudge at all.
496
+ */
497
+ async runCodegraphColdStart() {
498
+ try {
499
+ const workspaceRoot = this.options.workspace?.workspaceCwd ?? process.cwd();
500
+ const { evaluateColdStart } = await import('../codegraph/offer-hook.js');
501
+ const verdict = evaluateColdStart({ workspaceRoot });
502
+ if (verdict.kind === 'silent')
503
+ return;
504
+ if (verdict.kind === 'stale-index') {
505
+ this.appendSystemLine(verdict.message);
506
+ const { markReindexChecked } = await import('../codegraph/decision-store.js');
507
+ markReindexChecked(workspaceRoot);
508
+ return;
509
+ }
510
+ // 'remind' — surface the offer copy as a system line. Operator
511
+ // accepts via `/codegraph-status --install` OR explicitly via
512
+ // `pugi mcp install codegraph codegraph serve --mcp`.
513
+ this.appendSystemLine('');
514
+ this.appendSystemLine(verdict.message);
515
+ this.appendSystemLine(' Accept: run `pugi mcp install codegraph codegraph serve --mcp && pugi mcp trust codegraph`');
516
+ this.appendSystemLine(' Skip: /codegraph-status to inspect the decision; the prompt re-appears in 30 days');
517
+ }
518
+ catch {
519
+ // Codegraph nudge is decoration — failure must NEVER surface.
520
+ }
521
+ }
417
522
  /**
418
523
  * Tear down the SSE stream and stop the reconnect timer. The session
419
524
  * id stays valid server-side; `pugi resume <id>` reopens later.
@@ -539,6 +644,73 @@ export class ReplSession {
539
644
  getDispatchState() {
540
645
  return this.fsm.current;
541
646
  }
647
+ /**
648
+ * Wave 6 BT 8 (Claude Code parity): Esc-Esc walkback. Trim the last
649
+ * operator/persona turn pair from the in-memory transcript so the
650
+ * model's next call sees the conversation as if the most recent
651
+ * turn never happened. The local SessionStore still has the events
652
+ * on disk (append-only); the in-memory mask is advisory and the next
653
+ * `/compact` boundary will fold them naturally.
654
+ *
655
+ * Refusal modes:
656
+ * - `'no-turn'` - transcript has no operator/persona row to pop.
657
+ * - `'in-flight'` - dispatch is mid-flight; popping would race with
658
+ * the streaming persona row. The operator must
659
+ * cancel (Ctrl+C) before walking back.
660
+ *
661
+ * Success mode:
662
+ * - `'walked-back'` - the trailing persona row + the operator row
663
+ * that triggered it are gone from the transcript.
664
+ * A `↩ walked back 1 turn` status row is appended
665
+ * so the operator sees the state change without
666
+ * guessing.
667
+ *
668
+ * The mask is in-memory only on purpose. Disk-side rewind already has
669
+ * a separate first-class command (`/rewind`) with checkpoint
670
+ * semantics — the Esc-Esc shortcut is a one-tap "oops, undo that" for
671
+ * the live transcript, NOT a transactional rollback.
672
+ */
673
+ walkbackLastTurn() {
674
+ // Refuse while a dispatch is running. Popping the operator row that
675
+ // is currently driving the model's response would leave the persona
676
+ // line orphaned on the next streamed chunk; the FSM also lacks a
677
+ // clean teardown path here. The operator gets a one-line refusal
678
+ // and can Ctrl+C first if they really want to walk back.
679
+ const current = this.fsm.current;
680
+ if (current !== 'idle' && current !== 'completed'
681
+ && current !== 'aborted' && current !== 'failed') {
682
+ this.appendSystemLine('Walkback refused: dispatch in flight. Cancel with Ctrl+C, then Esc-Esc again.');
683
+ return 'in-flight';
684
+ }
685
+ // Find the trailing operator row. Walking backwards because the
686
+ // transcript is append-only and the most recent operator turn is
687
+ // by definition the last `source === 'operator'` row.
688
+ const transcript = this.state.transcript;
689
+ let operatorIdx = -1;
690
+ for (let i = transcript.length - 1; i >= 0; i -= 1) {
691
+ const row = transcript[i];
692
+ if (row.source === 'operator') {
693
+ operatorIdx = i;
694
+ break;
695
+ }
696
+ }
697
+ if (operatorIdx === -1) {
698
+ // No operator turn to pop. Quiet refusal — surfacing a "nothing
699
+ // to undo" line on every accidental double-Esc would be noisy.
700
+ return 'no-turn';
701
+ }
702
+ // Trim everything from the operator row onward (its echo + any
703
+ // persona/system rows that landed in response). The slice keeps
704
+ // every row BEFORE the operator turn, which is the conversation
705
+ // exactly as it stood right before the operator pressed Enter.
706
+ const trimmed = transcript.slice(0, operatorIdx);
707
+ this.patch({ transcript: trimmed });
708
+ // Status row so the operator sees the state change without
709
+ // guessing. Brand voice: single ASCII line, return-arrow glyph
710
+ // (U+21A9) which renders across every modern terminal.
711
+ this.appendSystemLine('↩ walked back 1 turn');
712
+ return 'walked-back';
713
+ }
542
714
  /**
543
715
  * Current cancellation token. Returned for the tool execution path
544
716
  * (file-tools.ts) so it can pass the token down into a ToolContext
@@ -577,6 +749,18 @@ export class ReplSession {
577
749
  await this.dispatchStop(verdict.persona);
578
750
  return verdict;
579
751
  }
752
+ case 'delegate': {
753
+ // α7.5 Phase 1: surface the dispatch intent inline. The actual
754
+ // wire shape (POST /api/pugi/sessions/:id/delegate) requires the
755
+ // SDK transport extension that ships alongside this PR; the
756
+ // REPL session module wires the call when the matching transport
757
+ // method lands (paired CLI follow-up). Today we surface the
758
+ // delegation intent in the transcript so the operator sees the
759
+ // verdict echo for muscle-memory before the round-trip lights up.
760
+ this.appendSystemLine(`delegate ${verdict.persona}: ${verdict.brief.length > 80 ? `${verdict.brief.slice(0, 77)}...` : verdict.brief}`);
761
+ this.appendSystemLine('Run `pugi delegate <slug> "<brief>"` from a fresh shell while the REPL transport wiring lands.');
762
+ return verdict;
763
+ }
580
764
  case 'dispatch': {
581
765
  await this.dispatchBrief(verdict.brief);
582
766
  return verdict;
@@ -594,7 +778,43 @@ export class ReplSession {
594
778
  return verdict;
595
779
  }
596
780
  case 'jobs': {
597
- await this.dispatchJobs();
781
+ // Wave 6 cleanup (2026-05-27): `/jobs --watch` mounts the
782
+ // live Ink TUI from inside the REPL. The dispatcher does NOT
783
+ // mount the watcher itself (that would unmount the REPL's
784
+ // own Ink tree) — instead it surfaces the shell command so
785
+ // the operator runs the watcher in a fresh terminal. Bare
786
+ // `/jobs` continues to render the one-shot snapshot.
787
+ if (verdict.watch) {
788
+ this.appendSystemLine('Run `pugi jobs --watch` from a fresh shell — the live TUI cannot share the REPL Ink tree.');
789
+ }
790
+ else {
791
+ await this.dispatchJobs();
792
+ }
793
+ return verdict;
794
+ }
795
+ case 'cancel': {
796
+ // Wave 6 small-CC-parity batch (2026-05-27): forward the parsed
797
+ // mode + dispatchId to `runCancelCommand`. The dispatcher uses
798
+ // a dynamic import so the cancel module's filesystem helpers
799
+ // stay out of the REPL keystroke hot path; same separation as
800
+ // `/redo`, `/prd-check`, `/chain`. The runner writes its
801
+ // output lines through `appendSystemLine` so the verdict
802
+ // lands on the system pane alongside other slash results.
803
+ try {
804
+ const { runCancelCommand } = await import('../../runtime/commands/cancel.js');
805
+ const cancelMode = verdict.mode === 'list'
806
+ ? { kind: 'list' }
807
+ : verdict.mode === 'all'
808
+ ? { kind: 'all' }
809
+ : { kind: 'one', dispatchId: verdict.dispatchId };
810
+ await runCancelCommand(cancelMode, {
811
+ write: (line) => this.appendSystemLine(line),
812
+ });
813
+ }
814
+ catch (err) {
815
+ const message = err instanceof Error ? err.message : String(err);
816
+ this.appendSystemLine(`/cancel failed: ${message}`);
817
+ }
598
818
  return verdict;
599
819
  }
600
820
  case 'diff': {
@@ -602,11 +822,15 @@ export class ReplSession {
602
822
  return verdict;
603
823
  }
604
824
  case 'cost': {
605
- this.dispatchCost();
825
+ await this.dispatchCost();
826
+ return verdict;
827
+ }
828
+ case 'quota': {
829
+ await this.dispatchQuota();
606
830
  return verdict;
607
831
  }
608
832
  case 'status': {
609
- this.dispatchStatus();
833
+ await this.dispatchStatus();
610
834
  return verdict;
611
835
  }
612
836
  case 'consensus': {
@@ -651,12 +875,924 @@ export class ReplSession {
651
875
  await this.dispatchPrivacy();
652
876
  return verdict;
653
877
  }
878
+ case 'init': {
879
+ // β1 Sl11 → β1a r1 (real inline scaffold, 2026-05-26): invoke
880
+ // `scaffoldPugiWorkspace` directly so the operator gets the
881
+ // same .pugi/ setup they would from `pugi init` on a fresh
882
+ // shell. Already-initialised workspaces (every artifact already
883
+ // present) get the "Already initialised" copy; partial / fresh
884
+ // workspaces get the full Created+Skipped breakdown. Default
885
+ // skills install is best-effort — any error from the bundled
886
+ // pack is surfaced as a system line and does not break the
887
+ // REPL session. The dynamic import keeps the slash dispatcher
888
+ // free of a runtime/cli.ts cycle on every keystroke.
889
+ try {
890
+ const { scaffoldPugiWorkspace } = await import('../../runtime/cli.js');
891
+ const lines = [];
892
+ const result = await scaffoldPugiWorkspace({
893
+ cwd: process.cwd(),
894
+ // Slash callers default to the full default-skills pack so
895
+ // the in-REPL experience matches `pugi init`. Operators who
896
+ // want a minimal scaffold still have the shell command.
897
+ noDefaults: false,
898
+ log: (line) => {
899
+ const trimmed = line.replace(/\n+$/u, '');
900
+ if (trimmed.length > 0)
901
+ lines.push(trimmed);
902
+ },
903
+ });
904
+ if (result.alreadyInitialized) {
905
+ this.appendSystemLine(`.pugi/ already initialised at ${result.root}. ${result.skipped.length} artefact(s) verified.`);
906
+ }
907
+ else {
908
+ this.appendSystemLine(`Pugi initialised at ${result.root}. Created ${result.created.length} artefact(s), skipped ${result.skipped.length}.`);
909
+ }
910
+ if (result.defaultSkills.length > 0) {
911
+ const installed = result.defaultSkills.filter((s) => s.status === 'installed').length;
912
+ const skippedSkills = result.defaultSkills.filter((s) => s.status === 'skipped-existing').length;
913
+ this.appendSystemLine(`Default skills: ${installed} installed, ${skippedSkills} already present.`);
914
+ }
915
+ for (const line of lines)
916
+ this.appendSystemLine(line);
917
+ }
918
+ catch (error) {
919
+ const message = error instanceof Error ? error.message : String(error);
920
+ this.appendSystemLine(`/init failed: ${message}`);
921
+ }
922
+ return verdict;
923
+ }
924
+ case 'mcp': {
925
+ // β4 Sl7 (2026-05-26): /mcp [sub] [args...] forwards to the
926
+ // runtime command. We deliberately route through the same
927
+ // entry-point used by `pugi mcp` from a fresh shell so the
928
+ // surface stays single-sourced. `serve` is refused inline —
929
+ // booting an MCP server inside an active REPL would compete
930
+ // with the REPL itself for stdio, which is exactly the wrong
931
+ // thing to do.
932
+ if (verdict.args[0] === 'serve') {
933
+ this.appendSystemLine('/mcp serve is not safe inside the REPL (it competes for stdio). ' +
934
+ 'Run `pugi mcp serve` from a fresh shell instead.');
935
+ return verdict;
936
+ }
937
+ try {
938
+ const { runMcpCommand } = await import('../../runtime/commands/mcp.js');
939
+ const lines = [];
940
+ await runMcpCommand(verdict.args, {
941
+ workspaceRoot: process.cwd(),
942
+ writeOutput: (_payload, text) => {
943
+ const trimmed = text.replace(/\n+$/u, '');
944
+ if (trimmed.length > 0)
945
+ lines.push(trimmed);
946
+ },
947
+ });
948
+ for (const line of lines)
949
+ this.appendSystemLine(line);
950
+ if (lines.length === 0) {
951
+ this.appendSystemLine('/mcp: no output.');
952
+ }
953
+ }
954
+ catch (error) {
955
+ const message = error instanceof Error ? error.message : String(error);
956
+ this.appendSystemLine(`/mcp failed: ${message}`);
957
+ }
958
+ return verdict;
959
+ }
960
+ case 'theme': {
961
+ // Leak L30 (2026-05-27): /theme [name] [--persist|--reset|--list]
962
+ // forwards to the shared `runThemeCommand` runner. Same async
963
+ // buffer-then-flush pattern as `/style` so a future async
964
+ // write path inside the runner cannot drop a tail emission
965
+ // and so multi-line payloads (banner + preview table) land
966
+ // one row per visual line in the conversation pane.
967
+ try {
968
+ const { runThemeCommand } = await import('../../runtime/commands/theme.js');
969
+ const lines = [];
970
+ await runThemeCommand(verdict.args, {
971
+ workspaceRoot: process.cwd(),
972
+ writeOutput: (_payload, text) => {
973
+ for (const raw of text.split('\n')) {
974
+ const trimmed = raw.replace(/\s+$/u, '');
975
+ lines.push(trimmed);
976
+ }
977
+ },
978
+ });
979
+ if (lines.length === 0) {
980
+ this.appendSystemLine('/theme: no output.');
981
+ }
982
+ else {
983
+ for (const line of lines)
984
+ this.appendSystemLine(line);
985
+ }
986
+ }
987
+ catch (error) {
988
+ const message = error instanceof Error ? error.message : String(error);
989
+ this.appendSystemLine(`/theme failed: ${message}`);
990
+ }
991
+ return verdict;
992
+ }
993
+ case 'style': {
994
+ // Leak L18 (2026-05-27): /style [name] [--persist|--reset|--list]
995
+ // forwards to the shared `runStyleCommand` runner so the slash
996
+ // + top-level surfaces share one code path. Dynamic import
997
+ // keeps the dispatcher free of the output-style module graph
998
+ // until the operator first invokes the slash. The runner's
999
+ // exit code is captured but NOT propagated to process.exitCode
1000
+ // — REPL session should not die because a bad preset slug was
1001
+ // typed in the input box.
1002
+ try {
1003
+ const { runStyleCommand } = await import('../../runtime/commands/style.js');
1004
+ // L18 P1 fix (2026-05-27): writeOutput is invoked SYNCHRONOUSLY
1005
+ // by `runStyleCommand` for each emitted block. We buffer every
1006
+ // emission into `lines` and flush after the await resolves so
1007
+ // that:
1008
+ // (1) any future async write path inside the runner cannot
1009
+ // drop a tail emission (callback never references the
1010
+ // Ink frame directly), and
1011
+ // (2) multi-line payloads (e.g. the active-style banner +
1012
+ // catalogue table) render one row per visual line in the
1013
+ // conversation pane, matching the `/stickers` surface.
1014
+ const lines = [];
1015
+ await runStyleCommand(verdict.args, {
1016
+ workspaceRoot: process.cwd(),
1017
+ writeOutput: (_payload, text) => {
1018
+ for (const raw of text.split('\n')) {
1019
+ const trimmed = raw.replace(/\s+$/u, '');
1020
+ lines.push(trimmed);
1021
+ }
1022
+ },
1023
+ });
1024
+ if (lines.length === 0) {
1025
+ this.appendSystemLine('/style: no output.');
1026
+ }
1027
+ else {
1028
+ for (const line of lines)
1029
+ this.appendSystemLine(line);
1030
+ }
1031
+ }
1032
+ catch (error) {
1033
+ const message = error instanceof Error ? error.message : String(error);
1034
+ this.appendSystemLine(`/style failed: ${message}`);
1035
+ }
1036
+ return verdict;
1037
+ }
1038
+ case 'onboarding': {
1039
+ // Leak L25 (2026-05-27): /onboarding forwards to the shared
1040
+ // `runOnboardingCommand` runner. From inside the REPL we ALWAYS
1041
+ // route through the non-interactive snapshot path — the REPL
1042
+ // already owns the Ink tree and mounting a second Ink wizard
1043
+ // on top would conflict over stdin raw mode. Operators who
1044
+ // want the interactive walk exit the REPL and run
1045
+ // `pugi onboarding` from a fresh shell; the slash surface
1046
+ // surfaces the recap card + hints inline so the operator
1047
+ // sees current values without leaving the session.
1048
+ try {
1049
+ const { runOnboardingCommand } = await import('../../runtime/commands/onboarding.js');
1050
+ const { resolveActiveCredential } = await import('../credentials.js');
1051
+ const credential = resolveActiveCredential();
1052
+ const lines = [];
1053
+ await runOnboardingCommand(verdict.args, {
1054
+ workspaceRoot: process.cwd(),
1055
+ env: process.env,
1056
+ authPresent: credential !== null,
1057
+ interactive: false,
1058
+ writeOutput: (_payload, text) => {
1059
+ const trimmed = text.replace(/\n+$/u, '');
1060
+ if (trimmed.length > 0)
1061
+ lines.push(trimmed);
1062
+ },
1063
+ });
1064
+ for (const line of lines)
1065
+ this.appendSystemLine(line);
1066
+ if (lines.length === 0) {
1067
+ this.appendSystemLine('/onboarding: no output.');
1068
+ }
1069
+ }
1070
+ catch (error) {
1071
+ const message = error instanceof Error ? error.message : String(error);
1072
+ this.appendSystemLine(`/onboarding failed: ${message}`);
1073
+ }
1074
+ return verdict;
1075
+ }
1076
+ case 'vim': {
1077
+ // Leak L26 (2026-05-27): /vim forwards to the shared
1078
+ // `runVimCommand` runner so the slash + top-level surfaces
1079
+ // stay single-sourced. Dynamic import mirrors /style so the
1080
+ // dispatcher does not drag the vim module graph into every
1081
+ // keystroke.
1082
+ //
1083
+ // The runner mutates `~/.pugi/config.json::vimMode`; the
1084
+ // active REPL session does NOT live-pick-up the flip (the
1085
+ // VimInput wrapper is mounted once at REPL boot). Operators
1086
+ // get a hint that the next session will reflect the change.
1087
+ // A follow-up sprint can plumb a state-store subscriber so
1088
+ // the flip takes effect mid-session.
1089
+ try {
1090
+ const { runVimCommand } = await import('../../runtime/commands/vim.js');
1091
+ const lines = [];
1092
+ await runVimCommand(verdict.args, {
1093
+ env: process.env,
1094
+ writeOutput: (_payload, text) => {
1095
+ for (const raw of text.split('\n')) {
1096
+ const trimmed = raw.replace(/\s+$/u, '');
1097
+ lines.push(trimmed);
1098
+ }
1099
+ },
1100
+ });
1101
+ if (lines.length === 0) {
1102
+ this.appendSystemLine('/vim: no output.');
1103
+ }
1104
+ else {
1105
+ for (const line of lines)
1106
+ this.appendSystemLine(line);
1107
+ }
1108
+ }
1109
+ catch (error) {
1110
+ const message = error instanceof Error ? error.message : String(error);
1111
+ this.appendSystemLine(`/vim failed: ${message}`);
1112
+ }
1113
+ return verdict;
1114
+ }
1115
+ case 'doctor': {
1116
+ // L17 (2026-05-27): run the doctor probe sweep inline. We
1117
+ // dynamic-import the runtime/commands/doctor module so the
1118
+ // slash dispatcher does not pull the diagnostics graph
1119
+ // (execFileSync + fs probes) into every keystroke. The
1120
+ // module's output is captured into local lines so we can
1121
+ // render it as system entries in the conversation pane;
1122
+ // an Ink-rendered table inside the REPL frame is a follow-up.
1123
+ try {
1124
+ const { runDoctorCommand, defaultHome } = await import('../../runtime/commands/doctor.js');
1125
+ const lines = [];
1126
+ await runDoctorCommand({
1127
+ cwd: process.cwd(),
1128
+ home: defaultHome(),
1129
+ env: process.env,
1130
+ json: false,
1131
+ writeOutput: (_payload, text) => {
1132
+ const trimmed = text.replace(/\n+$/u, '');
1133
+ if (trimmed.length > 0)
1134
+ lines.push(trimmed);
1135
+ },
1136
+ });
1137
+ for (const line of lines)
1138
+ this.appendSystemLine(line);
1139
+ if (lines.length === 0) {
1140
+ this.appendSystemLine('/doctor: no output.');
1141
+ }
1142
+ }
1143
+ catch (error) {
1144
+ const message = error instanceof Error ? error.message : String(error);
1145
+ this.appendSystemLine(`/doctor failed: ${message}`);
1146
+ }
1147
+ return verdict;
1148
+ }
1149
+ case 'prd-check': {
1150
+ // Wave 6 (2026-05-27): forward to the same handler the shell
1151
+ // surface uses so the verdict is identical between
1152
+ // `/prd-check` and `pugi prd-check`. Dynamic-import the
1153
+ // module to keep the parser + verifier graph out of the
1154
+ // REPL hot path.
1155
+ //
1156
+ // Wave 6 final (2026-05-27): the runner now also honours
1157
+ // `--session` mode (orthogonal to the verifier graph — walks
1158
+ // up for PRD.md, reads NDJSON turns, dispatches a cross-
1159
+ // review subagent). We stream the runner's status lines
1160
+ // directly to the system pane so the operator sees
1161
+ // "Locating PRD..." / "Reviewing against PRD..." while the
1162
+ // dispatch is in flight, then the structured Satisfied /
1163
+ // Outstanding lists when it lands.
1164
+ try {
1165
+ const { parsePrdCheckArgs, runPrdCheckCommand } = await import('../../runtime/commands/prd-check.js');
1166
+ const parsed = parsePrdCheckArgs(verdict.args, { jsonDefault: false });
1167
+ if (!parsed.ok) {
1168
+ this.appendSystemLine(`/prd-check: ${parsed.error}`);
1169
+ return verdict;
1170
+ }
1171
+ let sawOutput = false;
1172
+ await runPrdCheckCommand({
1173
+ cwd: process.cwd(),
1174
+ ...(parsed.prdPath !== undefined ? { prdPath: parsed.prdPath } : {}),
1175
+ flags: parsed.flags,
1176
+ // The REPL slash does not have a snapshot of the CLI
1177
+ // command registry, so we pass an empty set; the
1178
+ // command:<name> verifier will report FAIL for now.
1179
+ // This is a deliberate trade-off — the slash surface
1180
+ // primarily exists for quick eyeball checks during a
1181
+ // session; the shell surface (which DOES inject the
1182
+ // full registry) is the canonical gate.
1183
+ knownCommands: new Set(),
1184
+ writeOutput: (_payload, text) => {
1185
+ const trimmed = text.replace(/\n+$/u, '');
1186
+ if (trimmed.length > 0) {
1187
+ this.appendSystemLine(trimmed);
1188
+ sawOutput = true;
1189
+ }
1190
+ },
1191
+ });
1192
+ if (!sawOutput) {
1193
+ this.appendSystemLine('/prd-check: no output.');
1194
+ }
1195
+ }
1196
+ catch (error) {
1197
+ const message = error instanceof Error ? error.message : String(error);
1198
+ this.appendSystemLine(`/prd-check failed: ${message}`);
1199
+ }
1200
+ return verdict;
1201
+ }
1202
+ case 'chain': {
1203
+ // Wave 6 (2026-05-27): forward to the shell-surface runner so
1204
+ // the slash + top-level CLI share one parser + dispatcher.
1205
+ // Dynamic import keeps the chain module out of the REPL hot
1206
+ // path. The slash variant does NOT inject the live delegate
1207
+ // wire-up — operators wanting full dispatch run `pugi chain
1208
+ // next` from a fresh shell. The slash form is best-effort for
1209
+ // status / show / list which are read-only.
1210
+ try {
1211
+ const { runChainCommand } = await import('../../runtime/commands/chain.js');
1212
+ const lines = [];
1213
+ await runChainCommand(verdict.args, {
1214
+ cwd: process.cwd(),
1215
+ json: false,
1216
+ writeOutput: (_payload, text) => {
1217
+ const trimmed = text.replace(/\n+$/u, '');
1218
+ if (trimmed.length > 0)
1219
+ lines.push(trimmed);
1220
+ },
1221
+ });
1222
+ for (const line of lines)
1223
+ this.appendSystemLine(line);
1224
+ if (lines.length === 0) {
1225
+ this.appendSystemLine('/chain: no output.');
1226
+ }
1227
+ }
1228
+ catch (error) {
1229
+ const message = error instanceof Error ? error.message : String(error);
1230
+ this.appendSystemLine(`/chain failed: ${message}`);
1231
+ }
1232
+ return verdict;
1233
+ }
1234
+ case 'codegraph-status': {
1235
+ // Wave 6 BT 9 Phase 2 (2026-05-27): forward to the runner. The
1236
+ // bare form renders the four-row status table; flags handle
1237
+ // install / reindex / offer. Dynamic import keeps the
1238
+ // codegraph module out of the REPL hot path until first use.
1239
+ try {
1240
+ const { runCodegraphStatusCommand } = await import('../../runtime/commands/codegraph-status.js');
1241
+ const lines = [];
1242
+ const workspaceRoot = this.options.workspace?.workspaceCwd ?? process.cwd();
1243
+ await runCodegraphStatusCommand(verdict.args, {
1244
+ workspaceRoot,
1245
+ writeOutput: (_payload, text) => {
1246
+ for (const raw of text.split('\n')) {
1247
+ const trimmed = raw.replace(/\s+$/u, '');
1248
+ lines.push(trimmed);
1249
+ }
1250
+ },
1251
+ });
1252
+ if (lines.length === 0) {
1253
+ this.appendSystemLine('/codegraph-status: no output.');
1254
+ }
1255
+ else {
1256
+ for (const line of lines)
1257
+ this.appendSystemLine(line);
1258
+ }
1259
+ }
1260
+ catch (error) {
1261
+ const message = error instanceof Error ? error.message : String(error);
1262
+ this.appendSystemLine(`/codegraph-status failed: ${message}`);
1263
+ }
1264
+ return verdict;
1265
+ }
1266
+ case 'permissions': {
1267
+ // Leak L6: handle the `/permissions [mode] [--persist]` flow.
1268
+ // The session module forwards to the runtime helper so the
1269
+ // workspace + global-config writes share one code path with
1270
+ // the CLI's top-level `--mode` resolution. The dynamic import
1271
+ // keeps the dispatcher free of a session.ts -> runtime/cli.ts
1272
+ // cycle.
1273
+ try {
1274
+ const { runPermissionsCommand } = await import('../../runtime/commands/permissions.js');
1275
+ const lines = [];
1276
+ await runPermissionsCommand(verdict, {
1277
+ workspaceRoot: process.cwd(),
1278
+ writeOutput: (line) => {
1279
+ const trimmed = line.replace(/\n+$/u, '');
1280
+ if (trimmed.length > 0)
1281
+ lines.push(trimmed);
1282
+ },
1283
+ });
1284
+ for (const line of lines)
1285
+ this.appendSystemLine(line);
1286
+ }
1287
+ catch (error) {
1288
+ const message = error instanceof Error ? error.message : String(error);
1289
+ this.appendSystemLine(`/permissions failed: ${message}`);
1290
+ }
1291
+ return verdict;
1292
+ }
1293
+ case 'compact': {
1294
+ // Leak L8 (2026-05-27): /compact summarises older turns and
1295
+ // appends a boundary marker. We forward to the same runner the
1296
+ // top-level `pugi compact` command uses so the surface stays
1297
+ // single-sourced. The session module owns the in-memory
1298
+ // transcript echo (system line + banner row) so the operator
1299
+ // sees the marker land without a fresh REPL bootstrap.
1300
+ //
1301
+ // Wave 6 BT 8 (Claude Code parity): `--force` bypasses the
1302
+ // noop-empty guard so the operator can compact even short
1303
+ // sessions (useful before a manual checkpoint).
1304
+ await this.dispatchCompact('manual', { force: verdict.force });
1305
+ return verdict;
1306
+ }
1307
+ case 'model': {
1308
+ // Wave 6 BT 8 (Claude Code parity): /model lists OR selects the
1309
+ // active model. Slash + top-level CLI share `runModelCommand`.
1310
+ // The session module forwards writeOutput → appendSystemLine so
1311
+ // the menu + the confirmation line land inline in the
1312
+ // transcript. Tier override is undefined at the slash surface;
1313
+ // the runner defaults to 'team' so unauthenticated operators
1314
+ // see every model. Server-side calls enforce the real tier cap.
1315
+ try {
1316
+ const { runModelCommand } = await import('../../runtime/commands/model.js');
1317
+ await runModelCommand({ slug: verdict.slug }, {
1318
+ workspaceRoot: process.cwd(),
1319
+ writeOutput: (line) => {
1320
+ const trimmed = line.replace(/\n+$/u, '');
1321
+ if (trimmed.length > 0)
1322
+ this.appendSystemLine(trimmed);
1323
+ else
1324
+ this.appendSystemLine('');
1325
+ },
1326
+ });
1327
+ }
1328
+ catch (error) {
1329
+ const message = error instanceof Error ? error.message : String(error);
1330
+ this.appendSystemLine(`/model failed: ${message}`);
1331
+ }
1332
+ return verdict;
1333
+ }
1334
+ case 'rewind': {
1335
+ // Leak L9 (2026-05-27): /rewind appends an append-only
1336
+ // tombstone marker that rolls the conversation back to a
1337
+ // checkpoint. The actual replay-mask is advisory — the on-disk
1338
+ // events stay durable so `pugi sessions undo-rewind` can
1339
+ // reverse the operation. We forward to the same runner the
1340
+ // top-level `pugi rewind` command uses to keep the surface
1341
+ // single-sourced. Dynamic import avoids pulling the checkpoint
1342
+ // graph into the dispatcher at module load.
1343
+ if (!this.store || !this.localSessionId) {
1344
+ this.appendSystemLine('Local session store is disabled — /rewind is unavailable.');
1345
+ return verdict;
1346
+ }
1347
+ try {
1348
+ const { runRewindCommand } = await import('../../runtime/commands/rewind.js');
1349
+ await runRewindCommand(verdict.args, {
1350
+ workspaceRoot: process.cwd(),
1351
+ sessionId: this.localSessionId,
1352
+ store: this.store,
1353
+ writeOutput: (_payload, text) => {
1354
+ if (text.length > 0)
1355
+ this.appendSystemLine(text);
1356
+ },
1357
+ });
1358
+ }
1359
+ catch (error) {
1360
+ const message = error instanceof Error ? error.message : String(error);
1361
+ this.appendSystemLine(`/rewind failed: ${message}`);
1362
+ }
1363
+ return verdict;
1364
+ }
1365
+ case 'share': {
1366
+ // Leak L20 (2026-05-27): /share forwards to the same runner the
1367
+ // top-level `pugi share` command uses. The session module
1368
+ // wires writeOutput to appendSystemLine so the upload result +
1369
+ // privacy gate banner land in the REPL transcript inline.
1370
+ // Confirmation prompt + readline still use stdio because the
1371
+ // Ink frame is held by the input box; operators wanting fully
1372
+ // scripted shares pass `--yes` so no prompt fires.
1373
+ try {
1374
+ const { runShareCommand } = await import('../../runtime/commands/share.js');
1375
+ const lines = [];
1376
+ await runShareCommand(verdict.args, {
1377
+ workspaceRoot: process.cwd(),
1378
+ cliVersion: this.options.cliVersion,
1379
+ sessionId: this.localSessionId ?? undefined,
1380
+ writeOutput: (_payload, text) => {
1381
+ const trimmed = text.replace(/\n+$/u, '');
1382
+ if (trimmed.length > 0)
1383
+ lines.push(trimmed);
1384
+ },
1385
+ });
1386
+ for (const line of lines)
1387
+ this.appendSystemLine(line);
1388
+ if (lines.length === 0) {
1389
+ this.appendSystemLine('/share: no output.');
1390
+ }
1391
+ }
1392
+ catch (error) {
1393
+ const message = error instanceof Error ? error.message : String(error);
1394
+ this.appendSystemLine(`/share failed: ${message}`);
1395
+ }
1396
+ return verdict;
1397
+ }
1398
+ case 'plan': {
1399
+ // Leak L7: handle `/plan [--back | --persist] [<prompt>]`.
1400
+ // The session module forwards the mode-switch portion to the
1401
+ // shared runtime helper so the workspace + global-config writes
1402
+ // share one code path with `pugi plan`. When the operator
1403
+ // typed a prompt alongside (`/plan write me X`), the prompt is
1404
+ // forwarded through the dispatch FSM exactly as if they had
1405
+ // typed it directly — the only difference is the gate now
1406
+ // refuses write/dispatch tools because the workspace mode flipped
1407
+ // to plan first. Same dynamic-import trick as /permissions to
1408
+ // avoid pulling the engine adapter graph into the dispatcher.
1409
+ try {
1410
+ const { runPlanCommand } = await import('../../runtime/commands/plan.js');
1411
+ const lines = [];
1412
+ await runPlanCommand({ back: verdict.back, persist: verdict.persist }, {
1413
+ workspaceRoot: process.cwd(),
1414
+ writeOutput: (line) => {
1415
+ const trimmed = line.replace(/\n+$/u, '');
1416
+ if (trimmed.length > 0)
1417
+ lines.push(trimmed);
1418
+ },
1419
+ });
1420
+ for (const line of lines)
1421
+ this.appendSystemLine(line);
1422
+ // Optional one-shot engine dispatch: when the operator typed
1423
+ // a prompt alongside the slash, route it through the existing
1424
+ // dispatch path. We rewrite the verdict into a synthetic
1425
+ // `dispatch` result so the engine sees the user's prompt with
1426
+ // the plan-mode gate already in place. `--auto-back` is NOT
1427
+ // honoured in the slash surface today — operators stay in
1428
+ // plan mode and revert manually with `/plan --back`. The CLI
1429
+ // top-level `pugi plan --auto-back` exists for scripted use.
1430
+ if (verdict.prompt.length > 0 && !verdict.back) {
1431
+ return { kind: 'dispatch', brief: verdict.prompt };
1432
+ }
1433
+ }
1434
+ catch (error) {
1435
+ const message = error instanceof Error ? error.message : String(error);
1436
+ this.appendSystemLine(`/plan failed: ${message}`);
1437
+ }
1438
+ return verdict;
1439
+ }
1440
+ case 'release-notes': {
1441
+ // Leak L24 (2026-05-27): changelog diff between the operator's
1442
+ // last-seen + installed CLI versions. Delegate к the shared
1443
+ // `runReleaseNotesCommand` runner so the slash + top-level
1444
+ // paths stay single-sourced. The renderer collects each line
1445
+ // into the system pane via `appendSystemLine` — no fresh Ink
1446
+ // mount, no boxed render. `--reset` is honoured via the
1447
+ // `verdict.reset` field parsed in slash-commands.ts.
1448
+ try {
1449
+ const { runReleaseNotesCommand, defaultReleaseNotesHome } = await import('../../runtime/commands/release-notes.js');
1450
+ const lines = [];
1451
+ runReleaseNotesCommand({
1452
+ home: defaultReleaseNotesHome(),
1453
+ json: false,
1454
+ reset: verdict.reset,
1455
+ writeOutput: (_payload, text) => {
1456
+ for (const line of text.split('\n')) {
1457
+ lines.push(line.replace(/\s+$/u, ''));
1458
+ }
1459
+ },
1460
+ });
1461
+ if (lines.length === 0) {
1462
+ this.appendSystemLine('/release-notes: no output.');
1463
+ }
1464
+ else {
1465
+ for (const line of lines)
1466
+ this.appendSystemLine(line);
1467
+ }
1468
+ }
1469
+ catch (error) {
1470
+ const message = error instanceof Error ? error.message : String(error);
1471
+ this.appendSystemLine(`/release-notes failed: ${message}`);
1472
+ }
1473
+ return verdict;
1474
+ }
1475
+ case 'stickers': {
1476
+ // Leak L33 (2026-05-27): brand-personality gimmick. Delegate to
1477
+ // the shared `runStickersCommand` so the slash + top-level
1478
+ // paths stay single-sourced. The renderer routes the text
1479
+ // through the system pane line-buffer (ascii-only — no fresh
1480
+ // Ink mount) so the gimmick lands as a single contiguous
1481
+ // block в the conversation transcript.
1482
+ try {
1483
+ const { runStickersCommand } = await import('../../runtime/commands/stickers.js');
1484
+ // L33 P1 fix (2026-05-27): await the runner even though the
1485
+ // current implementation is synchronous. Two reasons:
1486
+ // (1) future-proofs the call site against the runner growing
1487
+ // an async path (e.g. remote stickerpack fetch) — without
1488
+ // this await, a returned promise would resolve AFTER we
1489
+ // flushed `lines` and the gimmick would render blank, and
1490
+ // (2) keeps the slash dispatcher uniform with the other
1491
+ // command runners (style, doctor, permissions, plan), all
1492
+ // of which are awaited.
1493
+ const lines = [];
1494
+ await runStickersCommand({
1495
+ json: false,
1496
+ asciiOnly: true,
1497
+ writeOutput: (_payload, text) => {
1498
+ for (const line of text.split('\n')) {
1499
+ const trimmed = line.replace(/\s+$/u, '');
1500
+ lines.push(trimmed);
1501
+ }
1502
+ },
1503
+ });
1504
+ if (lines.length === 0) {
1505
+ this.appendSystemLine('/stickers: no output.');
1506
+ }
1507
+ else {
1508
+ for (const line of lines)
1509
+ this.appendSystemLine(line);
1510
+ }
1511
+ }
1512
+ catch (error) {
1513
+ const message = error instanceof Error ? error.message : String(error);
1514
+ this.appendSystemLine(`/stickers failed: ${message}`);
1515
+ }
1516
+ return verdict;
1517
+ }
1518
+ case 'update': {
1519
+ // Leak L27 (2026-05-27): /update probes the npm registry for a
1520
+ // newer @pugi/cli version on the configured channel and prints
1521
+ // the install command. The slash form NEVER spawns `npm install
1522
+ // -g` — that would corrupt the binary we are currently running.
1523
+ // Operators see the install command + run it manually (or run
1524
+ // `pugi update --apply` from a fresh shell after the REPL
1525
+ // exits). The slash + top-level paths share the dispatcher so
1526
+ // channel resolution + last-check persistence stay single-
1527
+ // sourced.
1528
+ try {
1529
+ const { parseUpdateArgs, runUpdateCommand } = await import('../../runtime/commands/update.js');
1530
+ const parsed = parseUpdateArgs(verdict.args);
1531
+ if ('error' in parsed) {
1532
+ this.appendSystemLine(parsed.error);
1533
+ return verdict;
1534
+ }
1535
+ // Force `apply=false` on the slash path — see comment above.
1536
+ const slashFlags = { ...parsed, apply: false };
1537
+ const lines = [];
1538
+ await runUpdateCommand({
1539
+ cwd: process.cwd(),
1540
+ home: homedir(),
1541
+ env: process.env,
1542
+ flags: slashFlags,
1543
+ promptConfirm: async () => false,
1544
+ writeOutput: (_payload, text) => {
1545
+ for (const line of text.split('\n')) {
1546
+ const trimmed = line.replace(/\s+$/u, '');
1547
+ if (trimmed.length > 0)
1548
+ lines.push(trimmed);
1549
+ }
1550
+ },
1551
+ });
1552
+ if (lines.length === 0) {
1553
+ this.appendSystemLine('/update: no output.');
1554
+ }
1555
+ else {
1556
+ for (const line of lines)
1557
+ this.appendSystemLine(line);
1558
+ }
1559
+ }
1560
+ catch (error) {
1561
+ const message = error instanceof Error ? error.message : String(error);
1562
+ this.appendSystemLine(`/update failed: ${message}`);
1563
+ }
1564
+ return verdict;
1565
+ }
1566
+ case 'feedback': {
1567
+ // Leak L21 (2026-05-27): in-CLI feedback collector. The wizard
1568
+ // mounts a fresh Ink tree (renderFeedbackPrompt) outside the
1569
+ // live REPL input box so the operator can step through
1570
+ // category / rating / comment / context / confirm without
1571
+ // interleaving with persona output. The session module owns
1572
+ // the submit + queue wiring so the slash + top-level CLI
1573
+ // surfaces stay single-sourced through `runFeedbackCommand`.
1574
+ try {
1575
+ await this.runFeedbackSlash();
1576
+ }
1577
+ catch (error) {
1578
+ const message = error instanceof Error ? error.message : String(error);
1579
+ this.appendSystemLine(`/feedback failed: ${message}`);
1580
+ }
1581
+ return verdict;
1582
+ }
1583
+ case 'repo-map': {
1584
+ // Leak L28 (2026-05-27): AST-light workspace summary. Delegate
1585
+ // к the shared `runRepoMapCommand` so the slash + top-level
1586
+ // paths stay single-sourced. The rendered text lands on the
1587
+ // system pane via `appendSystemLine` (no fresh Ink mount) so
1588
+ // the listing flows into the conversation transcript like
1589
+ // any other command output.
1590
+ try {
1591
+ const { runRepoMapCommand } = await import('../../runtime/commands/repo-map.js');
1592
+ const lines = [];
1593
+ await runRepoMapCommand({
1594
+ cwd: process.cwd(),
1595
+ refresh: verdict.refresh,
1596
+ json: false,
1597
+ writeOutput: (_payload, text) => {
1598
+ for (const line of text.split('\n')) {
1599
+ const trimmed = line.replace(/\s+$/u, '');
1600
+ lines.push(trimmed);
1601
+ }
1602
+ },
1603
+ });
1604
+ if (lines.length === 0) {
1605
+ this.appendSystemLine('/repo-map: no output.');
1606
+ }
1607
+ else {
1608
+ for (const line of lines)
1609
+ this.appendSystemLine(line);
1610
+ }
1611
+ }
1612
+ catch (error) {
1613
+ const message = error instanceof Error ? error.message : String(error);
1614
+ this.appendSystemLine(`/repo-map failed: ${message}`);
1615
+ }
1616
+ return verdict;
1617
+ }
1618
+ case 'undo': {
1619
+ // Wave 6 final (2026-05-27): graduated from stub. The runtime
1620
+ // command `runUndoCommand` already exists with full Aider walk-
1621
+ // back semantics — single-step revert of the most recent
1622
+ // successful `write` / `edit` / `multi_edit` tool result, with
1623
+ // an mtime+hash gate that refuses to overwrite uncommitted
1624
+ // operator work. We open a fresh PugiSession against the cwd
1625
+ // so the inverse-mutation audit lands on the same NDJSON
1626
+ // events stream the REPL writes to; dynamic-import keeps the
1627
+ // runner + git plumbing out of the REPL hot path.
1628
+ try {
1629
+ const [{ runUndoCommand }, { openSession }] = await Promise.all([
1630
+ import('../../runtime/commands/undo.js'),
1631
+ import('../session.js'),
1632
+ ]);
1633
+ const workspaceRoot = process.cwd();
1634
+ const session = openSession(workspaceRoot);
1635
+ this.appendSystemLine('Reverting last write...');
1636
+ await runUndoCommand([], {
1637
+ workspaceRoot,
1638
+ session,
1639
+ writeOutput: (_payload, text) => {
1640
+ const trimmed = text.replace(/\n+$/u, '');
1641
+ if (trimmed.length > 0)
1642
+ this.appendSystemLine(trimmed);
1643
+ },
1644
+ });
1645
+ }
1646
+ catch (error) {
1647
+ const message = error instanceof Error ? error.message : String(error);
1648
+ this.appendSystemLine(`/undo failed: ${message}`);
1649
+ }
1650
+ return verdict;
1651
+ }
1652
+ case 'redo': {
1653
+ // Wave 6 cleanup (2026-05-27): counterpart к /undo. The runtime
1654
+ // command `runRedoCommand` consumes one entry from the LIFO
1655
+ // undo stack (most recent unconsumed `tool=undo` result), reads
1656
+ // the captured AFTER content from `.pugi/undo-blobs/`, and
1657
+ // re-applies the mutations under the same mtime+hash external-
1658
+ // modification gate the undo runner uses. Same dynamic-import
1659
+ // posture as /undo so the redo + blob-store + git plumbing
1660
+ // stays out of the REPL hot path.
1661
+ try {
1662
+ const [{ runRedoCommand }, { openSession }] = await Promise.all([
1663
+ import('../../runtime/commands/redo.js'),
1664
+ import('../session.js'),
1665
+ ]);
1666
+ const workspaceRoot = process.cwd();
1667
+ const session = openSession(workspaceRoot);
1668
+ this.appendSystemLine('Reapplying last undo...');
1669
+ await runRedoCommand([], {
1670
+ workspaceRoot,
1671
+ session,
1672
+ writeOutput: (_payload, text) => {
1673
+ const trimmed = text.replace(/\n+$/u, '');
1674
+ if (trimmed.length > 0)
1675
+ this.appendSystemLine(trimmed);
1676
+ },
1677
+ });
1678
+ }
1679
+ catch (error) {
1680
+ const message = error instanceof Error ? error.message : String(error);
1681
+ this.appendSystemLine(`/redo failed: ${message}`);
1682
+ }
1683
+ return verdict;
1684
+ }
654
1685
  case 'stub': {
655
1686
  this.appendSystemLine(verdict.message);
656
1687
  return verdict;
657
1688
  }
658
1689
  }
659
1690
  }
1691
+ /**
1692
+ * Leak L21 (2026-05-27): drive the `/feedback` wizard from inside
1693
+ * the REPL. Mounts the Ink prompt, collects the draft, hands it to
1694
+ * `runFeedbackCommand` (which routes to submit-now or
1695
+ * queue-locally), then writes the operator-facing toast to the
1696
+ * conversation system pane.
1697
+ *
1698
+ * The session module owns the wiring (cwd, cliVersion, apiUrl,
1699
+ * apiKey, transcript provider) so the slash + top-level CLI paths
1700
+ * stay single-sourced through `runFeedbackCommand`.
1701
+ */
1702
+ async runFeedbackSlash() {
1703
+ const { renderFeedbackPrompt } = await import('../../tui/feedback-prompt.js');
1704
+ const { runFeedbackCommand, renderFeedbackToast } = await import('../../runtime/commands/feedback.js');
1705
+ const { submitFeedback, redactSessionContext } = await import('../feedback/submitter.js');
1706
+ const verdict = await renderFeedbackPrompt();
1707
+ if (verdict.cancelled || !verdict.draft) {
1708
+ this.appendSystemLine('Feedback cancelled. Nothing was sent.');
1709
+ return;
1710
+ }
1711
+ // Build a session-context provider that reads the LAST 5 turns
1712
+ // from the live transcript + applies the redactor. Only invoked
1713
+ // when the operator opted in on step 4.
1714
+ const sessionContextProvider = () => {
1715
+ const last5 = this.state.transcript
1716
+ .filter((row) => row.source !== 'system')
1717
+ .slice(-5)
1718
+ .map((row) => ({
1719
+ role: row.source === 'operator' ? 'user' : 'assistant',
1720
+ text: row.text,
1721
+ }));
1722
+ // The workspace context exposed to the session does not carry
1723
+ // a git branch field today, so we omit `gitBranch` here. When
1724
+ // `ReplWorkspaceContext` gains the field we can forward it via
1725
+ // an extra options entry without changing the redactor contract.
1726
+ return redactSessionContext(last5);
1727
+ };
1728
+ const result = await runFeedbackCommand({
1729
+ cwd: process.cwd(),
1730
+ cliVersion: this.options.cliVersion,
1731
+ submit: async (env) => submitFeedback(env, {
1732
+ apiUrl: this.options.apiUrl,
1733
+ apiKey: this.options.apiKey,
1734
+ }),
1735
+ draft: verdict.draft,
1736
+ sessionContext: sessionContextProvider,
1737
+ });
1738
+ this.appendSystemLine(renderFeedbackToast(result));
1739
+ }
1740
+ /**
1741
+ * Leak L8 (2026-05-27): drive the `/compact` flow from inside the
1742
+ * REPL. Reuses the standalone runner so the wire shape + reason
1743
+ * codes stay single-sourced. The result is echoed into the
1744
+ * transcript as a system line; on success the operator sees the
1745
+ * banner sentinel on next render.
1746
+ *
1747
+ * `trigger='manual'` for explicit `/compact` invocations;
1748
+ * `trigger='auto'` for the threshold gate. The runner records the
1749
+ * trigger in the marker payload so the banner can distinguish them.
1750
+ */
1751
+ async dispatchCompact(trigger, options = {}) {
1752
+ if (!this.store || !this.localSessionId) {
1753
+ this.appendSystemLine('Local session store is disabled — /compact is unavailable.');
1754
+ return;
1755
+ }
1756
+ try {
1757
+ const { runCompactCommand } = await import('../../runtime/commands/compact.js');
1758
+ const result = await runCompactCommand([], {
1759
+ workspaceRoot: process.cwd(),
1760
+ sessionId: this.localSessionId,
1761
+ store: this.store,
1762
+ trigger,
1763
+ force: options.force === true,
1764
+ writeOutput: (_payload, text) => {
1765
+ if (text.length > 0)
1766
+ this.appendSystemLine(text);
1767
+ },
1768
+ });
1769
+ if (result.status === 'compacted') {
1770
+ // L29 (2026-05-27): emit a structured `compact-boundary` row so
1771
+ // the conversation pane routes the marker through the dedicated
1772
+ // `<CompactBanner />` Ink component (gray, terminal-width
1773
+ // separator) instead of leaking the raw text into a `system`
1774
+ // row. The plain-text body is kept as a deterministic fallback
1775
+ // for non-Ink consumers (snapshot tests, JSON-mode exports).
1776
+ const turnsBefore = result.turnsBefore ?? 0;
1777
+ this.appendRow({
1778
+ source: 'compact-boundary',
1779
+ text: `─── context compacted (${turnsBefore} turns → 1 summary, ${trigger}) ───`,
1780
+ compaction: {
1781
+ turnsBefore,
1782
+ trigger,
1783
+ summaryTokenCount: result.tokensSummarised,
1784
+ // Fresh in-REPL compaction lands at the head of the
1785
+ // transcript — no turns have followed it yet.
1786
+ turnsAgo: 0,
1787
+ },
1788
+ });
1789
+ }
1790
+ }
1791
+ catch (error) {
1792
+ const message = error instanceof Error ? error.message : String(error);
1793
+ this.appendSystemLine(`/compact failed: ${message}`);
1794
+ }
1795
+ }
660
1796
  /**
661
1797
  * In-REPL `/privacy` - alpha 6.13. Prints the full 3-mode contract
662
1798
  * doc + the current mode banner inline. The current mode is fetched
@@ -892,22 +2028,87 @@ export class ReplSession {
892
2028
  try {
893
2029
  const registry = getJobRegistry();
894
2030
  const entries = await registry.list();
895
- if (entries.length === 0) {
2031
+ // Wave 6 cleanup (2026-05-27): also scan `.pugi/agent-progress/*.json`
2032
+ // so long-running external agents (the JSON pattern from
2033
+ // `feedback_agent_progress_tracking_pattern.md`) show up next к
2034
+ // background-bash entries. The two surfaces are orthogonal — bash
2035
+ // jobs come from the in-process registry, agent-progress comes from
2036
+ // sidecar JSON written by any agent (Pugi-spawned or external) — so
2037
+ // we render both, sorted with running first.
2038
+ const agentProgressRows = await this.collectAgentProgressRows();
2039
+ if (entries.length === 0 && agentProgressRows.length === 0) {
896
2040
  this.appendSystemLine('No background jobs tracked.');
897
2041
  return;
898
2042
  }
899
- this.appendSystemLine(`Background jobs (${entries.length}):`);
900
- for (const entry of entries) {
901
- const id = entry.id.replace(/^pj-/, '').slice(0, 8);
902
- const status = entry.status;
903
- const cmd = entry.command.length > 48 ? `${entry.command.slice(0, 47)}…` : entry.command;
904
- this.appendSystemLine(` ${id} ${status.padEnd(10)} ${cmd}`);
2043
+ if (entries.length > 0) {
2044
+ this.appendSystemLine(`Background jobs (${entries.length}):`);
2045
+ for (const entry of entries) {
2046
+ const id = entry.id.replace(/^pj-/, '').slice(0, 8);
2047
+ const status = entry.status;
2048
+ const cmd = entry.command.length > 48 ? `${entry.command.slice(0, 47)}…` : entry.command;
2049
+ this.appendSystemLine(` ${id} ${status.padEnd(10)} ${cmd}`);
2050
+ }
2051
+ }
2052
+ if (agentProgressRows.length > 0) {
2053
+ this.appendSystemLine(`Agent progress (${agentProgressRows.length}):`);
2054
+ for (const row of agentProgressRows) {
2055
+ this.appendSystemLine(` ${row}`);
2056
+ }
2057
+ this.appendSystemLine('Tip: run `pugi jobs --watch` for the live Ink TUI.');
905
2058
  }
906
2059
  }
907
2060
  catch (error) {
908
2061
  this.appendSystemLine(`/jobs failed: ${this.errorMessage(error)}`);
909
2062
  }
910
2063
  }
2064
+ /**
2065
+ * Wave 6 cleanup (2026-05-27): scan `.pugi/agent-progress/*.json`
2066
+ * for in-flight long-running agent tasks and emit a one-line per
2067
+ * agent for the `/jobs` snapshot. Sorting matches the live TUI's
2068
+ * `sortProgressEntries` (running first, then by lastUpdate desc).
2069
+ *
2070
+ * Best-effort: a missing dir, malformed JSON, or bad permissions
2071
+ * yields an empty list and a swallowed error — the in-process
2072
+ * registry view is the older well-tested surface and must never be
2073
+ * gated behind a sidecar dir's health.
2074
+ */
2075
+ async collectAgentProgressRows() {
2076
+ try {
2077
+ const [{ resolveProgressDir }, { readProgressFile, sortProgressEntries }, fs, path] = await Promise.all([
2078
+ import('../agent-progress/writer.js'),
2079
+ import('../../commands/jobs-watch.js'),
2080
+ import('node:fs'),
2081
+ import('node:path'),
2082
+ ]);
2083
+ const dir = resolveProgressDir();
2084
+ if (!fs.existsSync(dir))
2085
+ return [];
2086
+ const files = fs
2087
+ .readdirSync(dir)
2088
+ .filter((f) => f.endsWith('.json'))
2089
+ .map((f) => path.join(dir, f));
2090
+ const progress = files
2091
+ .map((p) => readProgressFile(p))
2092
+ .filter((p) => p !== undefined);
2093
+ const sorted = sortProgressEntries(progress);
2094
+ return sorted.map((p) => {
2095
+ const id = p.agentId.length > 24 ? `${p.agentId.slice(0, 23)}…` : p.agentId;
2096
+ const pct = `${String(Math.round(p.percentComplete)).padStart(3, ' ')}%`;
2097
+ const elapsedSec = Math.max(0, Math.floor(p.elapsedMs / 1000));
2098
+ const elapsed = elapsedSec >= 60
2099
+ ? `${Math.floor(elapsedSec / 60)}m${String(elapsedSec % 60).padStart(2, '0')}s`
2100
+ : `${elapsedSec}s`;
2101
+ const status = p.status.padEnd(9, ' ');
2102
+ const step = p.stepDescription.length > 36
2103
+ ? `${p.stepDescription.slice(0, 35)}…`
2104
+ : p.stepDescription;
2105
+ return `${id.padEnd(24, ' ')} ${status} ${pct} ${elapsed.padStart(6, ' ')} ${step}`;
2106
+ });
2107
+ }
2108
+ catch {
2109
+ return [];
2110
+ }
2111
+ }
911
2112
  dispatchDiff() {
912
2113
  try {
913
2114
  const artifactsRoot = resolvePath(process.cwd(), '.pugi', 'artifacts');
@@ -938,22 +2139,217 @@ export class ReplSession {
938
2139
  this.appendSystemLine(`/diff failed: ${this.errorMessage(error)}`);
939
2140
  }
940
2141
  }
941
- dispatchCost() {
942
- const { tokensDownstreamTotal, agents } = this.state;
2142
+ async dispatchCost() {
2143
+ // α7 cost-meter sprint full breakdown matching the TUI status row
2144
+ // footer. The session totals line mirrors the footer format
2145
+ // (`↑ <in> ↓ <out> · $X.XX · <elapsed>`) so the operator scans the
2146
+ // same numbers in two places. Per-turn list shows the last 5 turns
2147
+ // oldest → newest; an empty list renders one system line so the
2148
+ // operator knows the surface is wired (`No completed turns yet.`).
2149
+ //
2150
+ // L19 (2026-05-27) — after the in-memory recap, also render the
2151
+ // persisted per-model table from `.pugi/cost.json`. That surface
2152
+ // survives a REPL restart and answers the "what did I spend on
2153
+ // claude-opus vs qwen this week?" question the in-memory recap can
2154
+ // not. Errors loading the file collapse to a single warning line so
2155
+ // the in-memory recap (the older, well-tested surface) is never
2156
+ // gated behind a fresh dependency.
2157
+ const { sessionTokensIn, sessionTokensOut, sessionCostUsd, sessionStartedAtEpochMs, recentTurns, agents, } = this.state;
943
2158
  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.');
2159
+ const elapsedMs = Math.max(0, this.now() - sessionStartedAtEpochMs);
2160
+ const elapsedLabel = formatElapsedShort(elapsedMs);
2161
+ this.appendSystemLine(`Session: ↑ ${formatTokens(sessionTokensIn)} ↓ ${formatTokens(sessionTokensOut)} · ${formatCostUsd(sessionCostUsd)} · ${elapsedLabel}`);
2162
+ this.appendSystemLine(`Active dispatches: ${active} of cap.`);
2163
+ if (recentTurns.length === 0) {
2164
+ this.appendSystemLine('No completed turns yet — brief the workforce to charge the meter.');
2165
+ }
2166
+ else {
2167
+ this.appendSystemLine(`Recent turns (last ${recentTurns.length}):`);
2168
+ for (let i = 0; i < recentTurns.length; i += 1) {
2169
+ const turn = recentTurns[i];
2170
+ const idx = (i + 1).toString().padStart(2, ' ');
2171
+ this.appendSystemLine(` ${idx}. ↑ ${formatTokens(turn.tokensIn)} ↓ ${formatTokens(turn.tokensOut)} · ${formatCostUsd(turn.costUsd)}`);
2172
+ }
2173
+ }
2174
+ // L19: append the persisted per-model table from .pugi/cost.json.
2175
+ try {
2176
+ const [{ createCostTracker }, { renderCostForSlash }] = await Promise.all([
2177
+ import('../cost/tracker.js'),
2178
+ import('../../runtime/commands/cost.js'),
2179
+ ]);
2180
+ const workspaceRoot = this.options.workspace?.workspaceCwd ?? process.cwd();
2181
+ const sessionId = this.state.sessionId ?? 'no-session';
2182
+ const tracker = createCostTracker({
2183
+ workspaceRoot,
2184
+ sessionIdProvider: () => sessionId,
2185
+ now: () => this.now(),
2186
+ });
2187
+ const current = tracker.current();
2188
+ if (current && Object.keys(current.models).length > 0) {
2189
+ this.appendSystemLine('');
2190
+ const { lines } = renderCostForSlash({
2191
+ tracker,
2192
+ allSessions: false,
2193
+ windowDays: 30,
2194
+ now: () => this.now(),
2195
+ });
2196
+ for (const line of lines)
2197
+ this.appendSystemLine(line);
2198
+ }
2199
+ }
2200
+ catch {
2201
+ // best-effort — the persisted view is additive; failure never
2202
+ // breaks the in-memory recap above
2203
+ }
949
2204
  }
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}.`);
2205
+ /**
2206
+ * α7 cost-meter sprint — `/quota` slash handler. Fetches the live
2207
+ * `/api/pugi/usage` snapshot and renders three lines: plan tier,
2208
+ * monthly window, and per-counter `used/cap (pct%)`. Failure modes
2209
+ * (offline, unauth, older admin-api) collapse to a single one-line
2210
+ * `Could not fetch quota…` system message so the surface never throws
2211
+ * from a keystroke handler.
2212
+ *
2213
+ * The fetch is best-effort with a 4s timeout — mirrors the `whoami`
2214
+ * pattern in `runtime/cli.ts` so the operator gets the same UX on the
2215
+ * REPL slash and the CLI command.
2216
+ */
2217
+ async dispatchQuota() {
2218
+ const controller = new AbortController();
2219
+ const timer = setTimeout(() => controller.abort(), 4000);
2220
+ try {
2221
+ const url = `${this.options.apiUrl.replace(/\/+$/, '')}/api/pugi/usage`;
2222
+ const res = await fetch(url, {
2223
+ method: 'GET',
2224
+ headers: {
2225
+ authorization: `Bearer ${this.options.apiKey}`,
2226
+ accept: 'application/json',
2227
+ },
2228
+ signal: controller.signal,
2229
+ });
2230
+ if (!res.ok) {
2231
+ this.appendSystemLine(`Could not fetch quota: HTTP ${res.status}.`);
2232
+ return;
2233
+ }
2234
+ const body = (await res.json());
2235
+ const tier = typeof body.tier === 'string' ? body.tier : '(unknown)';
2236
+ const tierLabel = QUOTA_TIER_LABELS[tier] ?? tier;
2237
+ const month = typeof body.billingMonth === 'string' ? body.billingMonth : '(unknown month)';
2238
+ const resetAt = typeof body.resetAt === 'string' ? body.resetAt : null;
2239
+ const resetLine = resetAt ? ` · resets ${formatResetWindow(resetAt, this.now())}` : '';
2240
+ this.appendSystemLine(`Plan: ${tierLabel} · ${month}${resetLine}`);
2241
+ const used = body.used ?? {};
2242
+ const caps = body.quotas ?? {};
2243
+ const counters = [
2244
+ ['sync', used.sync, caps.sync],
2245
+ ['review', used.review, caps.review],
2246
+ ['engine', used.engine, caps.engine],
2247
+ ];
2248
+ // Wave 6 cleanup (2026-05-27): color-code each counter row by
2249
+ // utilisation. The thresholds match Claude Code's tier-meter
2250
+ // convention so operators trained on that surface read the same
2251
+ // signal here. ANSI codes wrap the WHOLE row (not just the
2252
+ // percent) so the line wraps as one visual unit; the cost-quota
2253
+ // spec regex still matches because anchors are inside the
2254
+ // wrapped substring.
2255
+ for (const [name, value, cap] of counters) {
2256
+ const v = typeof value === 'number' ? value : 0;
2257
+ if (cap === null || cap === undefined) {
2258
+ // Unlimited counters never trip the gauge — leave them
2259
+ // uncolored so the eye does not register an alarm signal
2260
+ // where there is no cap к exhaust.
2261
+ this.appendSystemLine(` ${name.padEnd(7, ' ')} ${v.toLocaleString()} / unlimited`);
2262
+ }
2263
+ else {
2264
+ const pct = cap > 0 ? Math.round((v / cap) * 100) : 0;
2265
+ const row = ` ${name.padEnd(7, ' ')} ${v.toLocaleString()} / ${cap.toLocaleString()} (${pct}%)`;
2266
+ this.appendSystemLine(colorizeQuotaRow(row, pct));
2267
+ }
2268
+ }
2269
+ }
2270
+ catch (error) {
2271
+ const msg = error instanceof Error ? error.message : String(error);
2272
+ this.appendSystemLine(`Could not fetch quota: ${msg}.`);
2273
+ }
2274
+ finally {
2275
+ clearTimeout(timer);
2276
+ }
2277
+ }
2278
+ /**
2279
+ * In-REPL `/status` — Leak L34 (2026-05-27). Surfaces the full
2280
+ * session snapshot (id + age, cwd, permission mode, CLI version,
2281
+ * tokens, dispatches, last cmd, compact boundaries, auth identity,
2282
+ * connection) by delegating к the same `runStatusCommand` the
2283
+ * top-level `pugi status` shell uses. Live REPL state (session
2284
+ * id, token totals, last operator command) flows in through the
2285
+ * context so the slash variant shows MORE than the shell path.
2286
+ *
2287
+ * The renderer routes к the system pane via `appendSystemLine`
2288
+ * so the snapshot lands as a single contiguous block в the
2289
+ * conversation transcript. Migrating к the Ink `<StatusTable>`
2290
+ * mounted directly в the REPL frame is a follow-up sprint —
2291
+ * keeping the line-buffered path here avoids cycling the
2292
+ * conversation pane's render model mid-α7.
2293
+ */
2294
+ async dispatchStatus() {
2295
+ try {
2296
+ const { runStatusCommand, defaultStatusHome } = await import('../../runtime/commands/status.js');
2297
+ // Find the most-recent operator transcript row + its timestamp
2298
+ // so the snapshot's `Last cmd` field has real content в REPL
2299
+ // mode. Walking от newest end is O(transcript) worst case but
2300
+ // bounded by MAX_TRANSCRIPT_ROWS so this stays cheap.
2301
+ let lastCommand = null;
2302
+ let lastCommandAtEpochMs = null;
2303
+ for (let i = this.state.transcript.length - 1; i >= 0; i -= 1) {
2304
+ const row = this.state.transcript[i];
2305
+ if (row.source === 'operator') {
2306
+ lastCommand = row.text;
2307
+ lastCommandAtEpochMs = row.timestampEpochMs;
2308
+ break;
2309
+ }
2310
+ }
2311
+ const liveTokens = this.state.sessionTokensIn + this.state.sessionTokensOut;
2312
+ const lines = [];
2313
+ await runStatusCommand({
2314
+ cwd: process.cwd(),
2315
+ home: defaultStatusHome(),
2316
+ env: process.env,
2317
+ json: false,
2318
+ liveSessionId: this.state.sessionId ?? null,
2319
+ sessionStartedAtEpochMs: this.state.sessionStartedAtEpochMs,
2320
+ liveTokensUsed: liveTokens >= 0 ? liveTokens : 0,
2321
+ lastCommand,
2322
+ lastCommandAtEpochMs,
2323
+ // Repl-mode context: the session knows both the live
2324
+ // transport URL and the operator's workspace label, so we
2325
+ // forward them as authoritative inputs к the snapshot.
2326
+ // The status snapshot used к infer these from the
2327
+ // credentials file, which was wrong in two cases:
2328
+ // (a) the operator was inside a REPL talking к Anvil dev
2329
+ // (port 4100) but credentials still pointed к
2330
+ // api.pugi.io — the `Backend` row mis-reported;
2331
+ // (b) `workspaceLabel` was никогда rendered at all.
2332
+ liveApiUrl: this.options.apiUrl,
2333
+ workspaceLabel: this.options.workspaceLabel,
2334
+ writeOutput: (_payload, text) => {
2335
+ for (const line of text.split('\n')) {
2336
+ const trimmed = line.replace(/\s+$/u, '');
2337
+ if (trimmed.length > 0)
2338
+ lines.push(trimmed);
2339
+ }
2340
+ },
2341
+ });
2342
+ if (lines.length === 0) {
2343
+ this.appendSystemLine('/status: no output.');
2344
+ return;
2345
+ }
2346
+ for (const line of lines)
2347
+ this.appendSystemLine(line);
2348
+ }
2349
+ catch (error) {
2350
+ const message = error instanceof Error ? error.message : String(error);
2351
+ this.appendSystemLine(`/status failed: ${message}`);
2352
+ }
957
2353
  }
958
2354
  /**
959
2355
  * α6.5 `/context` slash handler. Surfaces the three-tier context
@@ -1136,7 +2532,10 @@ export class ReplSession {
1136
2532
  this.appendSystemLine(capLine);
1137
2533
  }
1138
2534
  this.appendOperatorLine(brief);
1139
- this.patch({ briefStartedAtEpochMs: this.now() });
2535
+ // Reset `lastCompletedOutcome` so a fresh dispatch does not
2536
+ // inherit the prior turn's status-bar label (e.g. a stale
2537
+ // "replied" sticking around while the next dispatch is in flight).
2538
+ this.patch({ briefStartedAtEpochMs: this.now(), lastCompletedOutcome: null });
1140
2539
  // α6.9 + R3 P1 (Codex triple-review 2026-05-25): supersede the
1141
2540
  // prior dispatch when one is in flight. Steps in order:
1142
2541
  //
@@ -1463,6 +2862,7 @@ export class ReplSession {
1463
2862
  apiUrl: this.options.apiUrl,
1464
2863
  apiKey: this.options.apiKey,
1465
2864
  workspace: this.options.workspace,
2865
+ cyberZoo: this.options.cyberZoo,
1466
2866
  });
1467
2867
  this.patch({ sessionId, connection: 'connecting' });
1468
2868
  this.openStream();
@@ -1619,8 +3019,22 @@ export class ReplSession {
1619
3019
  }
1620
3020
  case 'agent.tokens': {
1621
3021
  const delta = event.tokensIn + event.tokensOut;
3022
+ // α7 cost-meter sprint — bind a client-side USD figure to this
3023
+ // frame. The model slug rides on the event (optional for back-
3024
+ // compat); the price ladder in `model-pricing.ts` falls back to
3025
+ // a Sonnet-tier rate when the slug is missing, so the meter is
3026
+ // always populated. Negative / NaN values are clamped to zero
3027
+ // inside `computeCostUsd` so a buggy upstream never credits the
3028
+ // meter.
3029
+ const deltaCostUsd = computeCostUsd(event.tokensIn, event.tokensOut, event.model);
1622
3030
  this.patch({
1623
3031
  tokensDownstreamTotal: this.state.tokensDownstreamTotal + delta,
3032
+ sessionTokensIn: this.state.sessionTokensIn + event.tokensIn,
3033
+ sessionTokensOut: this.state.sessionTokensOut + event.tokensOut,
3034
+ sessionCostUsd: this.state.sessionCostUsd + deltaCostUsd,
3035
+ turnTokensIn: this.state.turnTokensIn + event.tokensIn,
3036
+ turnTokensOut: this.state.turnTokensOut + event.tokensOut,
3037
+ turnCostUsd: this.state.turnCostUsd + deltaCostUsd,
1624
3038
  agents: this.state.agents.map((a) => a.taskId === event.taskId
1625
3039
  ? {
1626
3040
  ...a,
@@ -1640,10 +3054,36 @@ export class ReplSession {
1640
3054
  }
1641
3055
  this.askBuffer.delete(event.taskId);
1642
3056
  this.askBufferPending.delete(event.taskId);
3057
+ // Honour the work-done signal from admin-api.
3058
+ // `outcome === 'replied'` means the turn was a pure text reply
3059
+ // with no delegate XML and no tool call — render it as
3060
+ // "replied" so the operator can tell the difference between
3061
+ // "the orchestrator just talked" and "real work shipped".
3062
+ // Older servers omit the field; default to 'shipped' so the
3063
+ // existing wire stays back-compat.
3064
+ const completedStatus = event.outcome === 'replied' ? 'replied' : 'shipped';
1643
3065
  this.patch({
1644
3066
  agents: this.state.agents.map((a) => a.taskId === event.taskId
1645
- ? { ...a, status: 'shipped', detail: 'shipped' }
3067
+ ? { ...a, status: completedStatus, detail: completedStatus }
1646
3068
  : a),
3069
+ // Mirror the outcome to top-level state so the status-bar
3070
+ // can render `replied` instead of the legacy `shipped`
3071
+ // label when the FSM lands in `completed`. Without this
3072
+ // the bottom-bar would still say "shipped" while the
3073
+ // agent-tree said "replied", restoring the same
3074
+ // contradiction this PR is fixing (Codex triple-review P2).
3075
+ //
3076
+ // r2: gate on the same stale-dispatch check that
3077
+ // advanceFsmOnDispatchEnd applies. If this completion
3078
+ // belongs to a SUPERSEDED dispatch (a newer dispatchBrief
3079
+ // already bumped dispatchSeq before this late terminal
3080
+ // arrived), don't let the status-bar label flip to the
3081
+ // stale outcome — the current turn is the live one.
3082
+ // The agent-tree row patch above is still safe because
3083
+ // it only updates the row keyed by taskId.
3084
+ ...(this.isStaleTaskEvent(event.taskId)
3085
+ ? {}
3086
+ : { lastCompletedOutcome: completedStatus }),
1647
3087
  });
1648
3088
  // α6.9: transition the FSM to `completed` when no other
1649
3089
  // dispatch is still in flight. The check uses the agents list
@@ -1651,6 +3091,12 @@ export class ReplSession {
1651
3091
  // the dispatch alive; the FSM only goes terminal when the last
1652
3092
  // agent ships.
1653
3093
  this.advanceFsmOnDispatchEnd('completed', 'agent_completed', event.taskId);
3094
+ // α7 cost-meter sprint — flush the per-turn delta when the
3095
+ // LAST agent settles. Decoupled from the FSM gate so a test
3096
+ // fixture (or a single-agent dispatch that never reached
3097
+ // `awaiting_response` — happens on instant SSE replay) still
3098
+ // gets the row written into recentTurns + lastTurnDelta.
3099
+ this.maybeFlushTurnOnAgentSettle(event.taskId);
1654
3100
  if (target) {
1655
3101
  // If the persona actually produced a reply via incremental
1656
3102
  // agent.step events, render that reply in the transcript so
@@ -1716,6 +3162,10 @@ export class ReplSession {
1716
3162
  // operator sees the bottom-bar settle back to `idle` after the
1717
3163
  // last block clears.
1718
3164
  this.advanceFsmOnDispatchEnd('completed', 'agent_blocked', event.taskId);
3165
+ // α7 cost-meter sprint — flush the per-turn delta (blocked
3166
+ // still counts as a billable turn — the operator paid for the
3167
+ // tokens that landed before the refusal).
3168
+ this.maybeFlushTurnOnAgentSettle(event.taskId);
1719
3169
  return;
1720
3170
  }
1721
3171
  case 'agent.failed': {
@@ -1739,6 +3189,10 @@ export class ReplSession {
1739
3189
  // `completed` so the bottom-bar surface tracks the dispatch
1740
3190
  // collectively.
1741
3191
  this.advanceFsmOnDispatchEnd('failed', 'agent_failed', event.taskId);
3192
+ // α7 cost-meter sprint — flush the per-turn delta when the
3193
+ // dispatch fails (the operator still paid for whatever tokens
3194
+ // landed before the failure).
3195
+ this.maybeFlushTurnOnAgentSettle(event.taskId);
1742
3196
  return;
1743
3197
  }
1744
3198
  }
@@ -1777,13 +3231,25 @@ export class ReplSession {
1777
3231
  * after a manual `cancel()` finds the FSM already in `aborted` and
1778
3232
  * is silently dropped.
1779
3233
  */
3234
+ /**
3235
+ * 2026-05-26 — shared stale-task check used by both the FSM advance
3236
+ * gate AND the status-bar `lastCompletedOutcome` mirror. Lifts the
3237
+ * R2 dispatchSeq compare out of `advanceFsmOnDispatchEnd` so other
3238
+ * agent.completed-handler side-effects (status-bar label, future
3239
+ * metric counters) can apply the same guard without duplicating it.
3240
+ * Returns true iff the task's stamped dispatchSeq is older than the
3241
+ * current dispatchSeq — i.e. a newer dispatchBrief() superseded it
3242
+ * and the late terminal event must not corrupt live-turn state.
3243
+ */
3244
+ isStaleTaskEvent(taskId) {
3245
+ const taskSeq = this.taskDispatchSeq.get(taskId);
3246
+ return taskSeq !== undefined && taskSeq < this.dispatchSeq;
3247
+ }
1780
3248
  advanceFsmOnDispatchEnd(outcome, reason, taskId) {
1781
3249
  // R2 P1 fix (Codex triple-review 2026-05-25): a terminal event
1782
3250
  // 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.
3251
+ // the live token. Delegates to isStaleTaskEvent so the agent.completed
3252
+ // status-bar mirror in the handler above uses the same gate.
1787
3253
  if (taskId !== undefined) {
1788
3254
  const taskSeq = this.taskDispatchSeq.get(taskId);
1789
3255
  if (taskSeq !== undefined && taskSeq < this.dispatchSeq) {
@@ -1815,6 +3281,63 @@ export class ReplSession {
1815
3281
  this.currentDispatchToken = null;
1816
3282
  this.patch({ briefStartedAtEpochMs: undefined });
1817
3283
  }
3284
+ /**
3285
+ * α7 cost-meter sprint — gate the per-turn flush on "this was the
3286
+ * LAST in-flight agent". Mirrors the `stillActive` guard inside
3287
+ * `advanceFsmOnDispatchEnd` so a multi-agent dispatch only emits a
3288
+ * single recentTurns row + a single lastTurnDelta flash.
3289
+ *
3290
+ * Idempotent: if no tokens have been billed this turn, the inner
3291
+ * `flushTurnAccumulator` short-circuits without pushing an empty row.
3292
+ */
3293
+ maybeFlushTurnOnAgentSettle(taskId) {
3294
+ const stillActive = this.state.agents.some((a) => a.status === 'queued' || a.status === 'thinking');
3295
+ if (stillActive)
3296
+ return;
3297
+ this.flushTurnAccumulator(taskId);
3298
+ }
3299
+ /**
3300
+ * α7 cost-meter sprint — flush the per-turn accumulator into
3301
+ * `recentTurns` + `lastTurnDelta`. Idempotent + safe to call from any
3302
+ * terminal-state branch (`agent.completed` / `agent.blocked` /
3303
+ * `agent.failed`). When no tokens have been billed this turn
3304
+ * (instant abort, cap-warning gate), the helper short-circuits
3305
+ * without pushing an empty row.
3306
+ */
3307
+ flushTurnAccumulator(taskId) {
3308
+ const turnTokensIn = this.state.turnTokensIn;
3309
+ const turnTokensOut = this.state.turnTokensOut;
3310
+ const turnCostUsd = this.state.turnCostUsd;
3311
+ if (turnTokensIn === 0 && turnTokensOut === 0) {
3312
+ // Idempotent zero-flush — never push an empty row into recentTurns.
3313
+ return;
3314
+ }
3315
+ const turnId = taskId !== undefined ? taskId : `turn-${this.dispatchSeq}-${this.now()}`;
3316
+ const newTurn = {
3317
+ id: turnId,
3318
+ tokensIn: turnTokensIn,
3319
+ tokensOut: turnTokensOut,
3320
+ costUsd: turnCostUsd,
3321
+ completedAt: new Date(this.now()).toISOString(),
3322
+ };
3323
+ // Keep the buffer capped at 5 entries (oldest first). The push
3324
+ // order matches the surface contract: `/cost` paginates oldest →
3325
+ // newest so the operator scans top-down chronologically.
3326
+ const recent = [...this.state.recentTurns, newTurn];
3327
+ const trimmed = recent.length > 5 ? recent.slice(-5) : recent;
3328
+ this.patch({
3329
+ recentTurns: trimmed,
3330
+ lastTurnDelta: {
3331
+ tokensIn: turnTokensIn,
3332
+ tokensOut: turnTokensOut,
3333
+ costUsd: turnCostUsd,
3334
+ completedAtEpochMs: this.now(),
3335
+ },
3336
+ turnTokensIn: 0,
3337
+ turnTokensOut: 0,
3338
+ turnCostUsd: 0,
3339
+ });
3340
+ }
1818
3341
  /* ------------- transcript helpers -------------- */
1819
3342
  /**
1820
3343
  * Look up the persona slug for a running task. Used by the tool call
@@ -1827,6 +3350,73 @@ export class ReplSession {
1827
3350
  const agent = this.state.agents.find((a) => a.taskId === taskId);
1828
3351
  return agent?.personaSlug ?? 'unknown';
1829
3352
  }
3353
+ /**
3354
+ * Wave 6 small-CC-parity batch (2026-05-27): public ingest path for
3355
+ * a backend-driven `tool.call.delta` event. Appends the delta tail
3356
+ * onto the row's `streamingDelta` (capped at
3357
+ * `STREAMING_DELTA_MAX_CHARS` so the row stays single-line) when the
3358
+ * id matches a `running` row. No-op when the id is unknown OR when
3359
+ * the row already transitioned to a terminal status — late deltas
3360
+ * from a completed call must not overwrite the final detail.
3361
+ *
3362
+ * The renderer in `tool-stream-pane.tsx` reads `streamingDelta` to
3363
+ * paint the inline preview after the canonical args. This method is
3364
+ * the seam the future admin-api SSE consumer hooks into; until then
3365
+ * the spec drives it directly so the delta-append branch is locked
3366
+ * down behaviourally.
3367
+ */
3368
+ appendToolCallDelta(id, deltaChunk) {
3369
+ if (!id || !deltaChunk)
3370
+ return;
3371
+ const idx = this.state.toolCalls.findIndex((c) => c.id === id);
3372
+ if (idx < 0)
3373
+ return;
3374
+ const existing = this.state.toolCalls[idx];
3375
+ if (existing.status !== 'running')
3376
+ return;
3377
+ const current = existing.streamingDelta ?? '';
3378
+ let combined = current + deltaChunk;
3379
+ if (combined.length > STREAMING_DELTA_MAX_CHARS) {
3380
+ // Keep the TAIL — the operator wants the freshest bytes (the
3381
+ // line being written right now), not the stale head. The leading
3382
+ // ellipsis signals truncation.
3383
+ combined = `…${combined.slice(combined.length - STREAMING_DELTA_MAX_CHARS + 1)}`;
3384
+ }
3385
+ const next = this.state.toolCalls.slice();
3386
+ next[idx] = { ...existing, streamingDelta: combined };
3387
+ this.patch({ toolCalls: next });
3388
+ }
3389
+ /**
3390
+ * Wave 6 small-CC-parity batch (2026-05-27): public ingest path for
3391
+ * the terminal `tool.call.end` event. Flips the row to `ok` / `error`
3392
+ * with the resolved duration + optional result preview. Cleans up the
3393
+ * transient `streamingDelta` so the completed row renders cleanly
3394
+ * without the live tail. No-op when the id is unknown.
3395
+ */
3396
+ endToolCall(input) {
3397
+ if (!input.id)
3398
+ return;
3399
+ const idx = this.state.toolCalls.findIndex((c) => c.id === input.id);
3400
+ if (idx < 0)
3401
+ return;
3402
+ const existing = this.state.toolCalls[idx];
3403
+ const endedAt = input.endedAtEpochMs ?? Date.now();
3404
+ const durationMs = Math.max(0, endedAt - existing.startedAtEpochMs);
3405
+ const preview = input.resultPreview
3406
+ ? truncatePreview(input.resultPreview, RESULT_PREVIEW_MAX_CHARS)
3407
+ : undefined;
3408
+ const next = this.state.toolCalls.slice();
3409
+ next[idx] = {
3410
+ ...existing,
3411
+ status: input.status,
3412
+ detail: input.detail ?? existing.detail,
3413
+ resultLines: input.resultLines ?? existing.resultLines,
3414
+ durationMs,
3415
+ resultPreview: preview,
3416
+ streamingDelta: undefined,
3417
+ };
3418
+ this.patch({ toolCalls: next });
3419
+ }
1830
3420
  /**
1831
3421
  * Fold a tool call entry into the rolling list. If the entry id
1832
3422
  * already exists, replace it in-place (so a synthesised `running` →
@@ -1871,13 +3461,14 @@ export class ReplSession {
1871
3461
  this.appendRow({ source: 'persona', text: stripped, personaSlug });
1872
3462
  }
1873
3463
  appendRow(input) {
1874
- if (input.text.length === 0)
3464
+ if (input.text.length === 0 && input.source !== 'compact-boundary')
1875
3465
  return;
1876
3466
  const row = {
1877
3467
  id: randomUUID(),
1878
3468
  source: input.source,
1879
3469
  text: input.text,
1880
3470
  personaSlug: input.personaSlug,
3471
+ compaction: input.compaction,
1881
3472
  timestampEpochMs: this.now(),
1882
3473
  };
1883
3474
  const next = this.state.transcript.concat(row).slice(-MAX_TRANSCRIPT_ROWS);
@@ -1890,6 +3481,62 @@ export class ReplSession {
1890
3481
  // persona -> 'persona'
1891
3482
  // system -> 'system'
1892
3483
  this.persistRow(row);
3484
+ // Leak L8 (2026-05-27): evaluate the auto-compact gate after
3485
+ // every appendRow that produces a transcript turn. Wrapped in a
3486
+ // setImmediate so the gate never blocks the input-handling fast
3487
+ // path; if the threshold is tripped, the auto-trigger dispatches
3488
+ // `/compact` in the background while the operator keeps typing.
3489
+ if (row.source === 'operator' || row.source === 'persona') {
3490
+ this.maybeAutoCompact();
3491
+ }
3492
+ }
3493
+ /**
3494
+ * Auto-compact gate. Cheap: builds an in-memory token estimate from
3495
+ * the current transcript and consults `evaluateAutoCompact`. When the
3496
+ * gate fires AND a compaction is not already in flight, we dispatch
3497
+ * `/compact` with `trigger='auto'`. The fire-and-forget shape means
3498
+ * the input box stays responsive while the background round-trip
3499
+ * runs.
3500
+ *
3501
+ * Hysteresis: `compactionInFlight` blocks re-entry. The gate is
3502
+ * cleared when the dispatch promise resolves regardless of outcome
3503
+ * so a transient transport failure does not permanently disable the
3504
+ * auto-trigger.
3505
+ */
3506
+ compactionInFlight = false;
3507
+ maybeAutoCompact() {
3508
+ if (this.compactionInFlight)
3509
+ return;
3510
+ if (!this.store || !this.localSessionId)
3511
+ return;
3512
+ if (process.env['PUGI_AUTOCOMPACT_DISABLED'] === '1')
3513
+ return;
3514
+ // Token estimate from the in-memory transcript. The estimate is a
3515
+ // lower bound on actual context pressure (server-side system
3516
+ // prompts add overhead) but the 4-char/token heuristic plus the
3517
+ // 0.75 default threshold gives generous headroom.
3518
+ const texts = this.state.transcript.map((r) => r.text);
3519
+ const tokenCount = estimateTokensInMany(texts);
3520
+ // Conservative default: assume the smallest commonly-used window
3521
+ // (32k tokens for deepseek-v3.1). Resolving the live model slug
3522
+ // through DispatchFSM + admin-api adds latency on a hot path; the
3523
+ // 0.75 threshold + smallest-window assumption errs toward
3524
+ // EARLY trigger which is the safe direction.
3525
+ const verdict = evaluateAutoCompact({
3526
+ tokenCount,
3527
+ windowSize: 32_000,
3528
+ });
3529
+ if (verdict.kind !== 'fire')
3530
+ return;
3531
+ this.compactionInFlight = true;
3532
+ void (async () => {
3533
+ try {
3534
+ await this.dispatchCompact('auto');
3535
+ }
3536
+ finally {
3537
+ this.compactionInFlight = false;
3538
+ }
3539
+ })();
1893
3540
  }
1894
3541
  /**
1895
3542
  * Best-effort write of one transcript row into the local
@@ -1900,6 +3547,15 @@ export class ReplSession {
1900
3547
  persistRow(row) {
1901
3548
  if (!this.store)
1902
3549
  return;
3550
+ // L29 (2026-05-27): `compact-boundary` transcript rows are echoes of
3551
+ // the JSONL `compaction` event the compact runner already appended
3552
+ // via `appendCompactBoundary`. Persisting them here would double-
3553
+ // write the marker (and worse, with a stripped payload that lacks
3554
+ // `summary` / `coversUntilOffset`) — `isCompactBoundary` would
3555
+ // reject the duplicate but `applyCompactMask` would still index off
3556
+ // the wrong offset. Skip the write.
3557
+ if (row.source === 'compact-boundary')
3558
+ return;
1903
3559
  const kind = row.source === 'operator' ? 'user'
1904
3560
  : row.source === 'persona' ? 'persona'
1905
3561
  : 'system';
@@ -1940,12 +3596,30 @@ export class ReplSession {
1940
3596
  * write the restored events.
1941
3597
  */
1942
3598
  restoreTranscript(events) {
3599
+ // Leak L8 (2026-05-27): apply compact-boundary masking BEFORE the
3600
+ // row conversion. Events strictly before the latest marker are
3601
+ // condensed into the boundary's `keptTailTurns + marker` slice so
3602
+ // the post-resume transcript starts at the most-recent context
3603
+ // floor rather than re-playing the full pre-compaction history.
3604
+ //
3605
+ // Leak L9 (2026-05-27): then apply rewind-marker masking. Any
3606
+ // event inside an active rewind range is stripped from the
3607
+ // visible transcript; the on-disk events stay durable so a
3608
+ // follow-up `pugi sessions undo-rewind` can restore them.
3609
+ const masked = applyRewindMask(applyCompactMask(events));
1943
3610
  const rows = [];
1944
- for (const event of events) {
3611
+ for (const event of masked) {
1945
3612
  const row = eventToTranscriptRow(event);
1946
3613
  if (row)
1947
3614
  rows.push(row);
1948
3615
  }
3616
+ // L29 (2026-05-27): tag each compact-boundary row with the count of
3617
+ // operator + persona turns that landed AFTER it in the replay
3618
+ // window. The banner reads `turnsAgo` to render the "N turns ago"
3619
+ // suffix so a long session that resumes across multiple compactions
3620
+ // stays self-orienting. System rows + sibling boundaries are NOT
3621
+ // counted — they are chrome, not operator-visible turns.
3622
+ annotateBoundaryTurnsAgo(rows);
1949
3623
  // Cap at MAX_TRANSCRIPT_ROWS - the same cap appendRow uses so the
1950
3624
  // window math stays consistent post-restore.
1951
3625
  const capped = rows.slice(-MAX_TRANSCRIPT_ROWS);
@@ -2129,8 +3803,71 @@ function eventToTranscriptRow(event) {
2129
3803
  timestampEpochMs: event.t,
2130
3804
  };
2131
3805
  }
3806
+ if (event.kind === 'compaction') {
3807
+ // L8 + L29 (2026-05-27): render the marker as a structured
3808
+ // `compact-boundary` row so the renderer can route it to the
3809
+ // dedicated <CompactBanner /> Ink component. The full summary text
3810
+ // is intentionally NOT inlined here (a 2k-token summary in the
3811
+ // transcript would defeat the purpose of compacting); the operator
3812
+ // sees the "context compacted" banner and can run `/context` to
3813
+ // inspect the marker payload when they want the details. The plain
3814
+ // text fallback stays in place for non-Ink consumers (snapshot
3815
+ // tests, future JSON exports).
3816
+ const compactionPayload = (event.payload ?? null);
3817
+ const trigger = compactionPayload?.trigger === 'auto' ? 'auto' : 'manual';
3818
+ const turns = typeof compactionPayload?.summaryTurnsBefore === 'number'
3819
+ ? compactionPayload.summaryTurnsBefore
3820
+ : 0;
3821
+ const tokens = typeof compactionPayload?.summaryTokenCount === 'number'
3822
+ ? compactionPayload.summaryTokenCount
3823
+ : undefined;
3824
+ return {
3825
+ id: randomUUID(),
3826
+ source: 'compact-boundary',
3827
+ text: `─── context compacted (${turns} turns → 1 summary, ${trigger}) ───`,
3828
+ compaction: {
3829
+ turnsBefore: turns,
3830
+ trigger,
3831
+ summaryTokenCount: tokens,
3832
+ },
3833
+ timestampEpochMs: event.t,
3834
+ };
3835
+ }
2132
3836
  return null;
2133
3837
  }
3838
+ /**
3839
+ * L29 (2026-05-27): walk a chronological transcript window and stamp
3840
+ * every `compact-boundary` row's `compaction.turnsAgo` with the count of
3841
+ * operator + persona rows that land AFTER it. The annotation runs in
3842
+ * place on the array — boundaries earlier in time get larger `turnsAgo`
3843
+ * values, the boundary at the head of the window gets zero. System rows
3844
+ * and sibling boundaries are excluded from the count (they are chrome,
3845
+ * not operator-visible turns).
3846
+ *
3847
+ * Exported so a future spec can lock the contract and so the in-REPL
3848
+ * `/compact` path can reuse the same counter on live appends if it ever
3849
+ * needs to. Pure function (mutates only the input slice).
3850
+ */
3851
+ export function annotateBoundaryTurnsAgo(rows) {
3852
+ let trailingTurns = 0;
3853
+ for (let i = rows.length - 1; i >= 0; i -= 1) {
3854
+ const row = rows[i];
3855
+ if (row.source === 'operator' || row.source === 'persona') {
3856
+ trailingTurns += 1;
3857
+ continue;
3858
+ }
3859
+ if (row.source === 'compact-boundary') {
3860
+ // Re-assign with the live `turnsAgo`. Carry forward the existing
3861
+ // structured payload so we never lose the trigger / token-count
3862
+ // data the renderer needs.
3863
+ const compaction = row.compaction ?? { turnsBefore: 0, trigger: 'manual' };
3864
+ rows[i] = {
3865
+ ...row,
3866
+ compaction: { ...compaction, turnsAgo: trailingTurns },
3867
+ };
3868
+ }
3869
+ }
3870
+ }
2134
3871
  /**
2135
3872
  * Heuristic: does this text contain Markdown structures that benefit
2136
3873
  * from atomic grouping? Code fences, bullet lists, numbered lists,
@@ -2197,6 +3934,86 @@ function formatAgeSeconds(deltaMs) {
2197
3934
  export function knownRoles() {
2198
3935
  return listRoles();
2199
3936
  }
3937
+ /**
3938
+ * α7 cost-meter sprint — render a session-elapsed ms delta as the
3939
+ * status-row's compact `XmYs` / `XhYm` shape. Distinct from
3940
+ * `formatAgeSeconds` above because `/cost` needs minute-granularity
3941
+ * uniformly (operator wants `2m44s`, not `2m`). Pure / branch-cheap;
3942
+ * the TUI status row + `/cost` both call this on every render.
3943
+ */
3944
+ function formatElapsedShort(elapsedMs) {
3945
+ if (!Number.isFinite(elapsedMs) || elapsedMs <= 0)
3946
+ return '0s';
3947
+ const totalSec = Math.floor(elapsedMs / 1000);
3948
+ if (totalSec < 60)
3949
+ return `${totalSec}s`;
3950
+ const min = Math.floor(totalSec / 60);
3951
+ const sec = totalSec % 60;
3952
+ if (min < 60)
3953
+ return `${min}m${sec.toString().padStart(2, '0')}s`;
3954
+ const hr = Math.floor(min / 60);
3955
+ const restMin = min % 60;
3956
+ return `${hr}h${restMin.toString().padStart(2, '0')}m`;
3957
+ }
3958
+ /**
3959
+ * α7 cost-meter sprint — public-facing tier labels for the `/quota`
3960
+ * slash. Mirrors `TIER_PRICE_LABEL` in `runtime/cli.ts` (kept in sync
3961
+ * via `pricing.spec.ts` gate). Falls through to the raw slug when an
3962
+ * unknown tier ships from a forward-compat admin-api build.
3963
+ */
3964
+ const QUOTA_TIER_LABELS = Object.freeze({
3965
+ free: 'Free',
3966
+ founder: 'Founder ($20/mo)',
3967
+ builder: 'Builder ($99/mo)',
3968
+ team: 'Team ($199/mo)',
3969
+ });
3970
+ /**
3971
+ * α7 cost-meter sprint — render the time-until-reset window for the
3972
+ * `/quota` plan line. `resetAt` is the ISO string admin-api returns;
3973
+ * `now` is the current epoch ms (injected for test determinism). Falls
3974
+ * back to the raw ISO string when parsing fails so the operator never
3975
+ * sees an empty hint.
3976
+ */
3977
+ function formatResetWindow(resetAtIso, nowEpochMs) {
3978
+ const resetMs = Date.parse(resetAtIso);
3979
+ if (!Number.isFinite(resetMs))
3980
+ return resetAtIso;
3981
+ const deltaMs = resetMs - nowEpochMs;
3982
+ if (deltaMs <= 0)
3983
+ return 'now';
3984
+ const days = Math.floor(deltaMs / (24 * 60 * 60 * 1000));
3985
+ if (days >= 2)
3986
+ return `in ${days}d`;
3987
+ const hours = Math.floor(deltaMs / (60 * 60 * 1000));
3988
+ if (hours >= 1)
3989
+ return `in ${hours}h`;
3990
+ const minutes = Math.max(1, Math.floor(deltaMs / (60 * 1000)));
3991
+ return `in ${minutes}m`;
3992
+ }
3993
+ /**
3994
+ * Wave 6 cleanup (2026-05-27): wrap a `/quota` counter row in ANSI
3995
+ * color codes by utilisation percent. Thresholds match Claude Code's
3996
+ * tier-meter convention so operators trained on that surface read the
3997
+ * same signal here:
3998
+ *
3999
+ * - 0..70% → green (32m) — comfortable headroom
4000
+ * - 70..90% → yellow (33m) — approaching cap, plan ahead
4001
+ * - 90..100% → red (31m) — burn rate alarm, throttle now
4002
+ *
4003
+ * The wrap is whole-row (not just the percent) so the eye registers
4004
+ * the level on the line, not just the trailing parenthesis. Tests
4005
+ * that match the inner row text via regex are unaffected because the
4006
+ * regex anchors live inside the wrapped substring; the ANSI codes
4007
+ * sit at the boundaries.
4008
+ */
4009
+ export function colorizeQuotaRow(row, pct) {
4010
+ const RESET = '\x1b[0m';
4011
+ if (pct >= 90)
4012
+ return `\x1b[31m${row}${RESET}`;
4013
+ if (pct >= 70)
4014
+ return `\x1b[33m${row}${RESET}`;
4015
+ return `\x1b[32m${row}${RESET}`;
4016
+ }
2200
4017
  /* ------------------------------------------------------------------ */
2201
4018
  /* Tool call synthesiser - α6.12 */
2202
4019
  /* ------------------------------------------------------------------ */
@@ -2230,7 +4047,7 @@ export function synthesiseToolCall(input) {
2230
4047
  // Pattern: ToolName(args) optionally suffixed with a result hint.
2231
4048
  // We allow the canonical Claude Code casing AND the snake_case
2232
4049
  // 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
4050
+ const match = /^(Read|Write|Edit|Bash|Grep|Glob|WebFetch|web_fetch)\s*\(\s*([^)]*)\s*\)\s*(.*)$/i
2234
4051
  .exec(detail);
2235
4052
  if (!match)
2236
4053
  return null;
@@ -2248,12 +4065,32 @@ export function synthesiseToolCall(input) {
2248
4065
  startedAtEpochMs: input.now,
2249
4066
  };
2250
4067
  }
4068
+ /**
4069
+ * Wave 6 small-CC-parity batch (2026-05-27): collapse a multi-line
4070
+ * result preview down to a single-line head capped at `max` chars. The
4071
+ * collapsed-result row on a completed tool call uses this so the
4072
+ * preview never expands the row vertically. Exported для the spec so
4073
+ * the truncation behaviour is locked down.
4074
+ */
4075
+ export function truncatePreview(value, max) {
4076
+ if (!value)
4077
+ return '';
4078
+ // Strip CR/LF + tab so the preview stays single-line. Multiple
4079
+ // whitespace runs collapse to single space — operator wants signal,
4080
+ // not formatting noise.
4081
+ const single = value.replace(/[\r\n\t]+/g, ' ').replace(/\s{2,}/g, ' ').trim();
4082
+ if (single.length <= max)
4083
+ return single;
4084
+ return `${single.slice(0, Math.max(0, max - 1))}…`;
4085
+ }
2251
4086
  function normaliseToolName(raw) {
2252
4087
  const lower = raw.toLowerCase();
2253
4088
  if (lower === 'webfetch' || lower === 'web_fetch')
2254
4089
  return 'web_fetch';
2255
4090
  if (lower === 'read')
2256
4091
  return 'read';
4092
+ if (lower === 'write')
4093
+ return 'write';
2257
4094
  if (lower === 'edit')
2258
4095
  return 'edit';
2259
4096
  if (lower === 'bash')
@@ -2479,7 +4316,22 @@ export function stripPersonaPrefixEcho(personaSlug, text) {
2479
4316
  // Escape regex specials in the display name even though THE_TEN
2480
4317
  // names are alpha-only today (forward-defense).
2481
4318
  const escaped = display.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
4319
+ // Match `<DisplayName>` (case-insensitive) followed by EITHER:
4320
+ // - an end-of-string, OR
4321
+ // - a separator (whitespace / comma / colon / dash / period+space).
4322
+ // The `i` flag is needed so a model writing "PUGI:" or "pugi," still
4323
+ // strips. After this match the post-fix `noSepUppercaseRe` handles
4324
+ // the "PugiПринял" / "PugiHello" no-separator emission pattern
4325
+ // (CEO red-alert 2026-05-27) using a SEPARATE regex without the `i`
4326
+ // flag so the lookahead is case-strict (Pugineous must NOT strip).
2482
4327
  const re = new RegExp(`^${escaped}(?:[\\s,:;\\-—–]+|$)`, 'i');
4328
+ // No-separator case-strict matcher. Display name in either of its
4329
+ // canonical casings ("Pugi" / "PUGI") immediately followed by an
4330
+ // uppercase Cyrillic or Latin letter. The strip is intentionally
4331
+ // narrower than the case-insensitive `re` above because a lowercase
4332
+ // continuation ("Pugineous") is a single word, not a display-name
4333
+ // echo - we must not eat real content.
4334
+ const noSepUppercaseRe = new RegExp(`^(?:${escaped}|${escaped.toUpperCase()})(?=[А-ЯЁA-Z])`);
2483
4335
  // Loop the strip so cascading echoes ("Pugi Pugi Pugi, координатор ...")
2484
4336
  // collapse to a single name. The model occasionally emits the display
2485
4337
  // name two or three times back-to-back when the pane prefix also
@@ -2491,10 +4343,18 @@ export function stripPersonaPrefixEcho(personaSlug, text) {
2491
4343
  // matches an empty string (defence-in-depth even though the current
2492
4344
  // pattern guarantees at least one consumed char).
2493
4345
  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();
4346
+ let m = re.exec(working);
4347
+ if (m && m[0].length > 0) {
4348
+ working = working.slice(m[0].length).trimStart();
4349
+ continue;
4350
+ }
4351
+ // Fallback: no-separator match for "PugiПринял" / "PugiHello" shape.
4352
+ m = noSepUppercaseRe.exec(working);
4353
+ if (m && m[0].length > 0) {
4354
+ working = working.slice(m[0].length);
4355
+ continue;
4356
+ }
4357
+ break;
2498
4358
  }
2499
4359
  return working;
2500
4360
  }