@pugi/cli 0.1.0-beta.10 → 0.1.0-beta.100

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 (445) hide show
  1. package/CHANGELOG.md +132 -0
  2. package/LICENSE +1 -1
  3. package/README.md +53 -11
  4. package/assets/pugi-prozr2-mascot.ansi +9 -0
  5. package/bin/run.js +33 -1
  6. package/dist/commands/deploy.js +40 -40
  7. package/dist/commands/flatten.js +191 -0
  8. package/dist/commands/jobs-watch.js +201 -0
  9. package/dist/commands/jobs.js +42 -27
  10. package/dist/commands/retro.js +210 -0
  11. package/dist/commands/smoke.js +133 -0
  12. package/dist/core/agent-progress/cleanup.js +134 -0
  13. package/dist/core/agent-progress/schema.js +144 -0
  14. package/dist/core/agent-progress/writer.js +101 -0
  15. package/dist/core/agents/adaptive-router.js +330 -0
  16. package/dist/core/agents/query-decomposer.js +297 -0
  17. package/dist/core/agents/registry.js +3 -3
  18. package/dist/core/approvals/shortcut-resolver.js +98 -0
  19. package/dist/core/artifact-chain/dispatcher.js +148 -0
  20. package/dist/core/artifact-chain/exporter.js +164 -0
  21. package/dist/core/artifact-chain/state.js +243 -0
  22. package/dist/core/artifact-chain/steps.js +169 -0
  23. package/dist/core/ask-user/question.js +92 -0
  24. package/dist/core/audit/audit-trail.js +275 -0
  25. package/dist/core/auth/ensure-authenticated.js +129 -0
  26. package/dist/core/auth/env-provider.js +238 -0
  27. package/dist/core/auto-open-browser.js +4 -4
  28. package/dist/core/auto-update/channels.js +122 -0
  29. package/dist/core/auto-update/checker.js +241 -0
  30. package/dist/core/auto-update/state.js +235 -0
  31. package/dist/core/bare-mode/index.js +107 -0
  32. package/dist/core/bash/redirect.js +281 -0
  33. package/dist/core/bash-classifier.js +436 -40
  34. package/dist/core/checkpoint/resumer.js +149 -0
  35. package/dist/core/checkpoint/rewinder.js +291 -0
  36. package/dist/core/checkpoints/shadow-git.js +670 -0
  37. package/dist/core/citations/parser.js +109 -0
  38. package/dist/core/classifier/yolo-classifier.js +88 -0
  39. package/dist/core/codegraph/db.js +506 -0
  40. package/dist/core/codegraph/decision-store.js +248 -0
  41. package/dist/core/codegraph/detect-repo.js +459 -0
  42. package/dist/core/codegraph/install.js +134 -0
  43. package/dist/core/codegraph/offer-hook.js +220 -0
  44. package/dist/core/codegraph/parser.js +71 -0
  45. package/dist/core/codegraph/types.js +34 -0
  46. package/dist/core/compact/auto-trigger.js +96 -0
  47. package/dist/core/compact/buffer-rewriter.js +115 -0
  48. package/dist/core/compact/summarizer.js +208 -0
  49. package/dist/core/compact/token-counter.js +108 -0
  50. package/dist/core/consensus/anvil-fanout.js +25 -25
  51. package/dist/core/consensus/diff-capture.js +121 -12
  52. package/dist/core/consensus/rubric.js +21 -21
  53. package/dist/core/context/builder.js +6 -6
  54. package/dist/core/context/compaction-events.js +8 -8
  55. package/dist/core/context/compaction.js +31 -31
  56. package/dist/core/context/index.js +15 -8
  57. package/dist/core/context/invariants.js +51 -51
  58. package/dist/core/context/markdown-loader.js +28 -10
  59. package/dist/core/context/markdown-traverse.js +255 -0
  60. package/dist/core/context/pugiignore.js +41 -41
  61. package/dist/core/context/repo-skeleton.js +37 -37
  62. package/dist/core/context/tool-eviction.js +55 -0
  63. package/dist/core/context/watcher.js +32 -32
  64. package/dist/core/context/working-set.js +23 -23
  65. package/dist/core/coordinator/agent-tools.js +77 -0
  66. package/dist/core/coordinator/agent-toolset.js +65 -0
  67. package/dist/core/coordinator/fsm.js +73 -0
  68. package/dist/core/coordinator/mode-fsm.js +70 -0
  69. package/dist/core/cost/rate-card.js +129 -0
  70. package/dist/core/cost/tracker.js +221 -0
  71. package/dist/core/credentials.js +13 -13
  72. package/dist/core/cron/scheduler.js +138 -0
  73. package/dist/core/denial-tracking/index.js +8 -0
  74. package/dist/core/denial-tracking/state.js +264 -0
  75. package/dist/core/diagnostics/probe-runner.js +93 -0
  76. package/dist/core/diagnostics/probes/api.js +46 -0
  77. package/dist/core/diagnostics/probes/auth.js +93 -0
  78. package/dist/core/diagnostics/probes/bare-mode.js +42 -0
  79. package/dist/core/diagnostics/probes/cli-version.js +127 -0
  80. package/dist/core/diagnostics/probes/config.js +72 -0
  81. package/dist/core/diagnostics/probes/denial-tracking.js +57 -0
  82. package/dist/core/diagnostics/probes/disk.js +81 -0
  83. package/dist/core/diagnostics/probes/engine-live.js +46 -0
  84. package/dist/core/diagnostics/probes/git.js +65 -0
  85. package/dist/core/diagnostics/probes/hooks.js +118 -0
  86. package/dist/core/diagnostics/probes/mcp.js +75 -0
  87. package/dist/core/diagnostics/probes/node.js +59 -0
  88. package/dist/core/diagnostics/probes/pnpm.js +36 -0
  89. package/dist/core/diagnostics/probes/pugi-md.js +89 -0
  90. package/dist/core/diagnostics/probes/sandbox.js +72 -0
  91. package/dist/core/diagnostics/probes/session.js +74 -0
  92. package/dist/core/diagnostics/probes/status-snapshot.js +488 -0
  93. package/dist/core/diagnostics/probes/workspace.js +63 -0
  94. package/dist/core/diagnostics/types.js +70 -0
  95. package/dist/core/dispatch/cache-cleanup.js +197 -0
  96. package/dist/core/dispatch/cache-handoff.js +295 -0
  97. package/dist/core/edits/apply-patch-layer-e.js +189 -0
  98. package/dist/core/edits/dispatch.js +333 -7
  99. package/dist/core/edits/format-detector.js +260 -0
  100. package/dist/core/edits/format-matrix.js +26 -0
  101. package/dist/core/edits/fuzzy-ladder.js +650 -0
  102. package/dist/core/edits/index.js +5 -1
  103. package/dist/core/edits/journal.js +199 -0
  104. package/dist/core/edits/layer-a-apply.js +15 -15
  105. package/dist/core/edits/layer-a-fuzzy-apply.js +198 -0
  106. package/dist/core/edits/layer-b-apply.js +9 -9
  107. package/dist/core/edits/layer-c-apply.js +6 -6
  108. package/dist/core/edits/layer-d-ast.js +557 -14
  109. package/dist/core/edits/marker-parser.js +12 -12
  110. package/dist/core/edits/security-gate.js +27 -27
  111. package/dist/core/edits/verify-hook.js +273 -0
  112. package/dist/core/edits/worktree.js +29 -29
  113. package/dist/core/engine/anvil-client.js +214 -26
  114. package/dist/core/engine/auto-compact.js +247 -0
  115. package/dist/core/engine/budgets.js +220 -0
  116. package/dist/core/engine/compact-llm-summarizer.js +124 -0
  117. package/dist/core/engine/context-prefix.js +155 -0
  118. package/dist/core/engine/index.js +1 -1
  119. package/dist/core/engine/intensity.js +163 -0
  120. package/dist/core/engine/intent.js +260 -0
  121. package/dist/core/engine/native-pugi.js +1559 -227
  122. package/dist/core/engine/prompts.js +187 -19
  123. package/dist/core/engine/strip-internal-fields.js +124 -0
  124. package/dist/core/engine/tool-bridge.js +1887 -59
  125. package/dist/core/engine/verification-patterns.js +195 -0
  126. package/dist/core/evaluation/golden-dataset.js +293 -0
  127. package/dist/core/feedback/queue.js +177 -0
  128. package/dist/core/feedback/submitter.js +145 -0
  129. package/dist/core/file-cache.js +113 -1
  130. package/dist/core/flatten/flatten-repo.js +439 -0
  131. package/dist/core/format/osc8-link.js +28 -0
  132. package/dist/core/hook-chains.js +392 -0
  133. package/dist/core/hooks/citation-verify-hook.js +138 -0
  134. package/dist/core/hooks/citation-verify.js +112 -0
  135. package/dist/core/hooks/events.js +46 -0
  136. package/dist/core/hooks/index.js +15 -0
  137. package/dist/core/hooks/registry.js +216 -0
  138. package/dist/core/hooks/runner.js +236 -0
  139. package/dist/core/hooks/v2/event-emitter.js +115 -0
  140. package/dist/core/hooks/v2/executor.js +282 -0
  141. package/dist/core/hooks/v2/index.js +25 -0
  142. package/dist/core/hooks/v2/lifecycle.js +104 -0
  143. package/dist/core/hooks/v2/loader.js +216 -0
  144. package/dist/core/hooks/v2/matcher.js +125 -0
  145. package/dist/core/hooks/v2/trust.js +143 -0
  146. package/dist/core/hooks/v2/types.js +86 -0
  147. package/dist/core/hooks/worktree-events.js +158 -0
  148. package/dist/core/image/renderer.js +71 -0
  149. package/dist/core/init/detector.js +582 -0
  150. package/dist/core/init/template-renderer.js +242 -0
  151. package/dist/core/jobs/registry.js +18 -18
  152. package/dist/core/ledger/results-tsv.js +142 -0
  153. package/dist/core/log-discipline/stdout-redirect.js +51 -0
  154. package/dist/core/lsp/cache.js +105 -0
  155. package/dist/core/lsp/client.js +551 -41
  156. package/dist/core/lsp/language-detect.js +66 -0
  157. package/dist/core/lsp/post-edit-diagnostics.js +171 -0
  158. package/dist/core/lsp/server-detect.js +173 -0
  159. package/dist/core/lsp/symbol-cache.js +162 -0
  160. package/dist/core/lsp/symbol-tools.js +664 -0
  161. package/dist/core/mcp/client.js +97 -28
  162. package/dist/core/mcp/http-server.js +553 -0
  163. package/dist/core/mcp/orchestrator-config.js +192 -0
  164. package/dist/core/mcp/orchestrator-tools.js +806 -0
  165. package/dist/core/mcp/permission.js +190 -0
  166. package/dist/core/mcp/registry.js +39 -17
  167. package/dist/core/mcp/server-tools.js +219 -0
  168. package/dist/core/mcp/server.js +397 -0
  169. package/dist/core/mcp/trust.js +10 -10
  170. package/dist/core/memory/dual-write.js +416 -0
  171. package/dist/core/memory/passive-extract.js +130 -0
  172. package/dist/core/memory/phase1-kinds.js +20 -0
  173. package/dist/core/memory/secret-scanner.js +304 -0
  174. package/dist/core/memory-sync/queue.js +170 -0
  175. package/dist/core/metrics/extract.js +113 -0
  176. package/dist/core/modes/roo-modes.js +68 -0
  177. package/dist/core/notes/notes-paths.js +113 -0
  178. package/dist/core/notes/notes-recorder.js +140 -0
  179. package/dist/core/notes/notes-writer.js +53 -0
  180. package/dist/core/notes/renderers.js +0 -0
  181. package/dist/core/notes/slug.js +105 -0
  182. package/dist/core/onboarding/ensure-initialized.js +133 -0
  183. package/dist/core/onboarding/marker.js +111 -0
  184. package/dist/core/onboarding/telemetry-state.js +108 -0
  185. package/dist/core/output-style/presets.js +176 -0
  186. package/dist/core/output-style/state.js +185 -0
  187. package/dist/core/path-security.js +287 -5
  188. package/dist/core/permission.js +82 -22
  189. package/dist/core/permissions/auto-classifier.js +124 -0
  190. package/dist/core/permissions/bash-parser.js +371 -0
  191. package/dist/core/permissions/circuit-breaker.js +83 -0
  192. package/dist/core/permissions/constrained-edit.js +91 -0
  193. package/dist/core/permissions/gate.js +278 -0
  194. package/dist/core/permissions/index.js +20 -0
  195. package/dist/core/permissions/mode.js +174 -0
  196. package/dist/core/permissions/network-egress.js +137 -0
  197. package/dist/core/permissions/state.js +241 -0
  198. package/dist/core/permissions/tool-class.js +107 -0
  199. package/dist/core/plan-mode/ui-state.js +51 -0
  200. package/dist/core/plans/plan-artifact.js +721 -0
  201. package/dist/core/policy-limits/etag-store.js +122 -0
  202. package/dist/core/prd-check/parser.js +215 -0
  203. package/dist/core/prd-check/reporter.js +127 -0
  204. package/dist/core/prd-check/session-review.js +557 -0
  205. package/dist/core/prd-check/verifiers.js +223 -0
  206. package/dist/core/prompt-cache/client-cache.js +99 -0
  207. package/dist/core/prompts/assembly.js +29 -0
  208. package/dist/core/prompts/registry.js +364 -0
  209. package/dist/core/pugi-gitignore.js +52 -0
  210. package/dist/core/pugi-md/cc-compat-rules.js +735 -0
  211. package/dist/core/pugi-md/context-injector.js +76 -0
  212. package/dist/core/pugi-md/walk-up.js +207 -0
  213. package/dist/core/python/uv-installer.js +270 -0
  214. package/dist/core/python/uv-resolver.js +83 -0
  215. package/dist/core/rate-limit/narrator.js +146 -0
  216. package/dist/core/recipes/cli-types.js +20 -0
  217. package/dist/core/recipes/loader.js +103 -0
  218. package/dist/core/recipes/runner.js +345 -0
  219. package/dist/core/recipes/schema.js +587 -0
  220. package/dist/core/release-notes/parser.js +241 -0
  221. package/dist/core/release-notes/state.js +116 -0
  222. package/dist/core/repl/ask.js +37 -37
  223. package/dist/core/repl/cancellation.js +26 -26
  224. package/dist/core/repl/cap-warning.js +4 -4
  225. package/dist/core/repl/clipboard-read.js +11 -11
  226. package/dist/core/repl/dispatch-fsm.js +12 -12
  227. package/dist/core/repl/engine-bridge.js +303 -0
  228. package/dist/core/repl/history-search.js +15 -15
  229. package/dist/core/repl/history.js +28 -18
  230. package/dist/core/repl/kill-ring.js +5 -5
  231. package/dist/core/repl/model-pricing.js +135 -0
  232. package/dist/core/repl/privacy-banner.js +22 -22
  233. package/dist/core/repl/session.js +2690 -229
  234. package/dist/core/repl/slash-commands.js +540 -41
  235. package/dist/core/repl/store/index.js +1 -1
  236. package/dist/core/repl/store/jsonl-log.js +22 -22
  237. package/dist/core/repl/store/lockfile.js +10 -10
  238. package/dist/core/repl/store/session-store.js +136 -107
  239. package/dist/core/repl/store/types.js +15 -15
  240. package/dist/core/repl/store/uuid-v7.js +12 -12
  241. package/dist/core/repl/tool-route.js +382 -0
  242. package/dist/core/repl/workspace-context.js +43 -21
  243. package/dist/core/repo-map/build.js +125 -0
  244. package/dist/core/repo-map/cache.js +185 -0
  245. package/dist/core/repo-map/extractor.js +254 -0
  246. package/dist/core/repo-map/formatter.js +145 -0
  247. package/dist/core/repo-map/page-rank.js +105 -0
  248. package/dist/core/repo-map/scanner.js +211 -0
  249. package/dist/core/retro/git-collector.js +251 -0
  250. package/dist/core/retro/health-card.js +25 -0
  251. package/dist/core/retro/metrics.js +342 -0
  252. package/dist/core/retro/narrative.js +249 -0
  253. package/dist/core/retro/plane-collector.js +274 -0
  254. package/dist/core/retro/pr-issue-link.js +65 -0
  255. package/dist/core/retro/types.js +16 -0
  256. package/dist/core/retry-budget/budget.js +284 -0
  257. package/dist/core/retry-budget/index.js +5 -0
  258. package/dist/core/retry-budget/retry-cap.js +74 -0
  259. package/dist/core/routing/lead-worker.js +43 -0
  260. package/dist/core/routing/pre-flight-estimator.js +108 -0
  261. package/dist/core/runs/run-tree.js +103 -0
  262. package/dist/core/sandboxing/adapter.js +29 -0
  263. package/dist/core/sandboxing/index.js +49 -0
  264. package/dist/core/sandboxing/none.js +19 -0
  265. package/dist/core/sandboxing/seatbelt.js +183 -0
  266. package/dist/core/security/injection-scanner.js +367 -0
  267. package/dist/core/security/output-filter.js +418 -0
  268. package/dist/core/session/env-file.js +105 -0
  269. package/dist/core/session/section-budgets.js +140 -0
  270. package/dist/core/session.js +119 -0
  271. package/dist/core/settings.js +378 -5
  272. package/dist/core/share/formatter.js +271 -0
  273. package/dist/core/share/redactor.js +221 -0
  274. package/dist/core/share/uploader.js +267 -0
  275. package/dist/core/skills/defaults.js +30 -30
  276. package/dist/core/skills/loader.js +22 -22
  277. package/dist/core/skills/sources.js +27 -27
  278. package/dist/core/smoke/headless-driver.js +174 -0
  279. package/dist/core/smoke/orchestrator.js +194 -0
  280. package/dist/core/smoke/runner.js +238 -0
  281. package/dist/core/smoke/scenario-parser.js +316 -0
  282. package/dist/core/statusline.js +99 -0
  283. package/dist/core/subagents/dispatcher-real.js +600 -0
  284. package/dist/core/subagents/dispatcher.js +146 -52
  285. package/dist/core/subagents/index.js +19 -6
  286. package/dist/core/subagents/isolation-matrix.js +213 -0
  287. package/dist/core/subagents/spawn.js +19 -4
  288. package/dist/core/telemetry/emitter.js +229 -0
  289. package/dist/core/telemetry/queue.js +251 -0
  290. package/dist/core/theme/context.js +91 -0
  291. package/dist/core/theme/presets.js +228 -0
  292. package/dist/core/theme/state.js +181 -0
  293. package/dist/core/todos/invariant.js +10 -0
  294. package/dist/core/todos/state.js +177 -0
  295. package/dist/core/tool-schema/compressor.js +89 -0
  296. package/dist/core/transport/version-interceptor.js +166 -0
  297. package/dist/core/trust.js +2 -2
  298. package/dist/core/tui/thinking-block.js +64 -0
  299. package/dist/core/vim/keymap.js +288 -0
  300. package/dist/core/vim/state.js +92 -0
  301. package/dist/core/watch-markers/marker-watcher.js +133 -0
  302. package/dist/core/worktree/include-parser.js +249 -0
  303. package/dist/core/worktree-manager/cleanup.js +123 -0
  304. package/dist/core/worktree-manager/manager.js +303 -0
  305. package/dist/index.js +36 -0
  306. package/dist/runtime/bootstrap.js +190 -0
  307. package/dist/runtime/cli.js +4345 -561
  308. package/dist/runtime/commands/agents.js +31 -31
  309. package/dist/runtime/commands/budget.js +5 -5
  310. package/dist/runtime/commands/cancel.js +231 -0
  311. package/dist/runtime/commands/chain.js +489 -0
  312. package/dist/runtime/commands/codegraph-status.js +227 -0
  313. package/dist/runtime/commands/compact.js +297 -0
  314. package/dist/runtime/commands/config.js +74 -40
  315. package/dist/runtime/commands/cost.js +199 -0
  316. package/dist/runtime/commands/delegate.js +27 -4
  317. package/dist/runtime/commands/dispatch.js +126 -0
  318. package/dist/runtime/commands/doctor.js +579 -0
  319. package/dist/runtime/commands/feedback.js +184 -0
  320. package/dist/runtime/commands/hooks.js +187 -0
  321. package/dist/runtime/commands/index-cmd.js +353 -0
  322. package/dist/runtime/commands/init.js +254 -0
  323. package/dist/runtime/commands/lsp.js +200 -38
  324. package/dist/runtime/commands/mcp.js +935 -0
  325. package/dist/runtime/commands/memory.js +582 -0
  326. package/dist/runtime/commands/model.js +237 -0
  327. package/dist/runtime/commands/onboarding.js +275 -0
  328. package/dist/runtime/commands/patch.js +12 -12
  329. package/dist/runtime/commands/permissions.js +112 -0
  330. package/dist/runtime/commands/plan.js +143 -0
  331. package/dist/runtime/commands/prd-check.js +285 -0
  332. package/dist/runtime/commands/privacy.js +17 -17
  333. package/dist/runtime/commands/recipe.js +325 -0
  334. package/dist/runtime/commands/redo-blob-store.js +92 -0
  335. package/dist/runtime/commands/redo.js +361 -0
  336. package/dist/runtime/commands/release-notes.js +229 -0
  337. package/dist/runtime/commands/repo-map.js +95 -0
  338. package/dist/runtime/commands/report.js +299 -0
  339. package/dist/runtime/commands/resume.js +118 -0
  340. package/dist/runtime/commands/review-consensus.js +68 -53
  341. package/dist/runtime/commands/rewind.js +333 -0
  342. package/dist/runtime/commands/roster.js +14 -14
  343. package/dist/runtime/commands/servers.js +236 -0
  344. package/dist/runtime/commands/sessions.js +163 -0
  345. package/dist/runtime/commands/share.js +316 -0
  346. package/dist/runtime/commands/skills.js +31 -31
  347. package/dist/runtime/commands/status.js +186 -0
  348. package/dist/runtime/commands/stickers.js +82 -0
  349. package/dist/runtime/commands/style.js +194 -0
  350. package/dist/runtime/commands/theme.js +196 -0
  351. package/dist/runtime/commands/undo.js +54 -22
  352. package/dist/runtime/commands/update.js +289 -0
  353. package/dist/runtime/commands/vim.js +140 -0
  354. package/dist/runtime/commands/worktree.js +8 -8
  355. package/dist/runtime/commands/worktrees.js +155 -0
  356. package/dist/runtime/deprecation-warning.js +69 -0
  357. package/dist/runtime/engine-exit-code.js +50 -0
  358. package/dist/runtime/headless-repl.js +195 -0
  359. package/dist/runtime/headless.js +548 -0
  360. package/dist/runtime/load-hooks-or-exit.js +71 -0
  361. package/dist/runtime/plan-decompose.js +22 -22
  362. package/dist/runtime/sigint-guard.js +272 -0
  363. package/dist/runtime/stream-renderer.js +195 -0
  364. package/dist/runtime/update-check.js +28 -28
  365. package/dist/runtime/version.js +65 -0
  366. package/dist/runtime/worktree-bootstrap.js +579 -0
  367. package/dist/skills/bundled/batch.js +617 -0
  368. package/dist/skills/bundled/index.js +45 -0
  369. package/dist/skills/bundled/loop.js +358 -0
  370. package/dist/skills/bundled/remember.js +383 -0
  371. package/dist/skills/bundled/simplify.js +289 -0
  372. package/dist/skills/bundled/skillify.js +373 -0
  373. package/dist/skills/bundled/stuck.js +558 -0
  374. package/dist/skills/bundled/verify.js +439 -0
  375. package/dist/testing/vcr.js +486 -0
  376. package/dist/tools/agent-tool.js +229 -0
  377. package/dist/tools/apply-patch.js +89 -28
  378. package/dist/tools/ask-user-question.js +337 -0
  379. package/dist/tools/ask-user.js +115 -0
  380. package/dist/tools/bash.js +624 -46
  381. package/dist/tools/brief.js +224 -0
  382. package/dist/tools/cron.js +433 -0
  383. package/dist/tools/enter-worktree.js +250 -0
  384. package/dist/tools/exit-worktree.js +147 -0
  385. package/dist/tools/file-tools.js +161 -44
  386. package/dist/tools/http-request.js +336 -0
  387. package/dist/tools/lsp-tools.js +377 -1
  388. package/dist/tools/mcp-tool.js +260 -0
  389. package/dist/tools/multi-edit.js +361 -0
  390. package/dist/tools/powershell.js +268 -0
  391. package/dist/tools/registry.js +120 -5
  392. package/dist/tools/server-tools.js +892 -0
  393. package/dist/tools/skill-tool.js +96 -0
  394. package/dist/tools/sleep.js +99 -0
  395. package/dist/tools/synthetic-output.js +133 -0
  396. package/dist/tools/tasks.js +208 -0
  397. package/dist/tools/todo-write.js +184 -0
  398. package/dist/tools/verify-plan-execution.js +295 -0
  399. package/dist/tools/web-fetch-injection-scanner.js +207 -0
  400. package/dist/tools/web-fetch.js +195 -10
  401. package/dist/tools/web-search.js +458 -0
  402. package/dist/tui/agent-progress-card.js +111 -0
  403. package/dist/tui/agent-tree.js +22 -1
  404. package/dist/tui/ask-modal.js +14 -14
  405. package/dist/tui/ask-user-question-chips.js +315 -0
  406. package/dist/tui/ask-user-question-prompt.js +203 -0
  407. package/dist/tui/compact-banner.js +81 -0
  408. package/dist/tui/conversation-pane.js +85 -11
  409. package/dist/tui/cost-table.js +111 -0
  410. package/dist/tui/device-flow.js +2 -2
  411. package/dist/tui/doctor-table.js +46 -0
  412. package/dist/tui/feedback-prompt.js +156 -0
  413. package/dist/tui/input-box.js +247 -32
  414. package/dist/tui/login-picker.js +3 -3
  415. package/dist/tui/markdown-render.js +6 -6
  416. package/dist/tui/multi-file-diff-approval.js +375 -0
  417. package/dist/tui/onboarding-wizard.js +240 -0
  418. package/dist/tui/permissions-picker.js +86 -0
  419. package/dist/tui/render.js +36 -1
  420. package/dist/tui/repl-render.js +239 -25
  421. package/dist/tui/repl-splash-art.js +16 -16
  422. package/dist/tui/repl-splash-mascot.js +48 -24
  423. package/dist/tui/repl-splash.js +22 -22
  424. package/dist/tui/repl.js +125 -45
  425. package/dist/tui/slash-palette.js +6 -6
  426. package/dist/tui/splash.js +2 -2
  427. package/dist/tui/status-bar.js +109 -31
  428. package/dist/tui/status-table.js +7 -0
  429. package/dist/tui/stickers-art.js +136 -0
  430. package/dist/tui/style-table.js +28 -0
  431. package/dist/tui/theme-table.js +29 -0
  432. package/dist/tui/thinking-spinner.js +123 -0
  433. package/dist/tui/tool-stream-pane.js +53 -4
  434. package/dist/tui/update-banner.js +27 -2
  435. package/dist/tui/vim-input.js +267 -0
  436. package/dist/tui/welcome-banner.js +107 -0
  437. package/dist/tui/welcome-data.js +293 -0
  438. package/dist/tui/workspace-context.js +2 -2
  439. package/package.json +21 -5
  440. package/test/scenarios/codegen-create-file.scenario.txt +13 -0
  441. package/test/scenarios/compact-force.scenario.txt +12 -0
  442. package/test/scenarios/identity.scenario.txt +11 -0
  443. package/test/scenarios/persona-handoff.scenario.txt +12 -0
  444. package/test/scenarios/walkback.scenario.txt +12 -0
  445. package/dist/core/engine/compaction-hook.js +0 -154
@@ -1,18 +1,18 @@
1
1
  /**
2
- * REPL session lifecycle - Sprint α5.7 (ADR-0056 PR-PUGI-CLI-REPL-DEFAULT).
2
+ * REPL session lifecycle - Sprint .
3
3
  *
4
4
  * Owns the state machine that the REPL UI subscribes to:
5
5
  *
6
- * 1. Open a server-side Pugi session via POST /api/pugi/sessions.
7
- * The CLI keeps a sessionId; reconnect uses it.
8
- * 2. Subscribe to GET /api/pugi/sessions/:id/stream (SSE). Each event
9
- * pushes one of: agent.spawned, agent.step, agent.tokens,
10
- * agent.completed, agent.blocked, agent.failed.
11
- * 3. Dispatch a brief via POST /api/pugi/sessions/:id/brief.
12
- * 4. Track active dispatches so the cap-warning gate has a number.
13
- * 5. Reconnect with Last-Event-ID on transient failure (10 retries,
14
- * exponential backoff capped at 5s) so the operator sees a stable
15
- * stream even on flaky connections.
6
+ * 1. Open a server-side Pugi session via POST /api/pugi/sessions.
7
+ * The CLI keeps a sessionId; reconnect uses it.
8
+ * 2. Subscribe to GET /api/pugi/sessions/:id/stream (SSE). Each event
9
+ * pushes one of: agent.spawned, agent.step, agent.tokens,
10
+ * agent.completed, agent.blocked, agent.failed.
11
+ * 3. Dispatch a brief via POST /api/pugi/sessions/:id/brief.
12
+ * 4. Track active dispatches so the cap-warning gate has a number.
13
+ * 5. Reconnect with Last-Event-ID on transient failure (10 retries,
14
+ * exponential backoff capped at 5s) so the operator sees a stable
15
+ * stream even on flaky connections.
16
16
  *
17
17
  * The module is environment-agnostic: callers inject `fetch` (Node 22
18
18
  * native or a stub from a test) and `EventSource` (a polyfill or
@@ -21,12 +21,13 @@
21
21
  * surface is exercisable without a network.
22
22
  *
23
23
  * Brand voice: the conversation transcript is line-based, persona-
24
- * prefixed (Mira / Marcus / Hiroshi / Vera / Anika / Olivia / Diego /
24
+ * prefixed (Pugi / Marcus / Hiroshi / Vera / Anika / Olivia / Diego /
25
25
  * Sofia per @pugi/personas). Forbidden words gate applies to every
26
26
  * line we synthesize client-side; server-side events are passed through
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,18 +35,41 @@ 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';
43
+ import { extractToolRouteTags, signatureForToolRoute, } from './tool-route.js';
44
+ import { personaSlugFor } from '../engine/prompts.js';
38
45
  import { existsSync, readdirSync, statSync } from 'node:fs';
39
46
  import { resolve as resolvePath } from 'node:path';
40
47
  import { CancellationToken } from './cancellation.js';
41
48
  import { DispatchFSM } from './dispatch-fsm.js';
49
+ import { computeCostUsd, formatCostUsd, formatTokens } from './model-pricing.js';
42
50
  const MAX_TRANSCRIPT_ROWS = 500;
43
51
  const MAX_TOOL_CALLS = 200;
52
+ /**
53
+ * small-CC-parity batch : width cap for the inline
54
+ * `streamingDelta` tail rendered next to the args while the call is
55
+ * `running`. Keeps the tool-stream row single-line on an 80-col
56
+ * terminal even when Bash output blasts through stdout. Exported so the
57
+ * spec can pin the truncation behaviour.
58
+ */
59
+ export const STREAMING_DELTA_MAX_CHARS = 80;
60
+ /**
61
+ * small-CC-parity batch : character cap for the
62
+ * collapsed `resultPreview` on a completed row. The pane shows
63
+ * `✓ Read(file) OK (2ms) "first 50 chars…"` so the operator sees what
64
+ * the tool produced without expanding. Per CEO spec (50 chars).
65
+ * Exported so the spec + the pane share one source of truth.
66
+ */
67
+ export const RESULT_PREVIEW_MAX_CHARS = 50;
44
68
  const MAX_RECONNECT_ATTEMPTS = 10;
45
69
  const RECONNECT_BASE_MS = 250;
46
70
  const RECONNECT_MAX_MS = 5_000;
47
71
  /**
48
- * α6.5 filewatch throttle: minimum gap between two file-change
72
+ * filewatch throttle: minimum gap between two file-change
49
73
  * system lines surfaced in the conversation pane. Per the sprint
50
74
  * spec, a noisy save burst should not flood the transcript - we
51
75
  * coalesce all chokidar batches that arrive inside the window into
@@ -60,7 +84,7 @@ const FILEWATCH_SYSTEM_LINE_GAP_MS = 5_000;
60
84
  * would accumulate forever, holding refs to thousands of FilewatchBatch
61
85
  * objects (each carrying its own events array). On overflow we drop
62
86
  * the OLDEST batch and surface a one-shot system warning so the
63
- * operator knows the buffer is shedding. triple-review P1 (PR #380).
87
+ * operator knows the buffer is shedding. triple-review P1 (PR).
64
88
  */
65
89
  const PENDING_FILEWATCH_BATCH_CAP = 100;
66
90
  /**
@@ -70,7 +94,7 @@ const PENDING_FILEWATCH_BATCH_CAP = 100;
70
94
  * CLI mints a fresh server session, swaps the consumer over, and
71
95
  * keeps running - but we cap the recovery to 3 attempts inside 60s
72
96
  * so a truly down admin-api fails loud instead of spinning forever.
73
- * (α6.14.2 wave 5 - CEO dogfood fix.)
97
+ *
74
98
  */
75
99
  const MAX_SESSION_RECREATE_ATTEMPTS = 3;
76
100
  const SESSION_RECREATE_WINDOW_MS = 60_000;
@@ -90,13 +114,13 @@ export class ReplSession {
90
114
  * with "Stream interrupted (HTTP 404)" loops, we mint a fresh
91
115
  * session and swap the consumer. Capped at MAX_SESSION_RECREATE_*
92
116
  * inside SESSION_RECREATE_WINDOW_MS so a permanently down admin-api
93
- * fails loud instead of looping silently. (α6.14.2 wave 5.)
117
+ * fails loud instead of looping silently.
94
118
  */
95
119
  recentRecreateAtMs = [];
96
120
  /**
97
121
  * True while a session-recreate POST is in flight. Guards against
98
122
  * the SSE stream firing multiple `onError(404)` callbacks racing
99
- * the in-flight createSession promise. (α6.14.2 wave 5.)
123
+ * the in-flight createSession promise.
100
124
  */
101
125
  recreatingSession = false;
102
126
  /**
@@ -108,11 +132,11 @@ export class ReplSession {
108
132
  * `shipped.` - the actual reply text was lost. By caching the last
109
133
  * non-trivial detail here, we can flush it into the transcript when
110
134
  * the agent completes so the operator sees what the persona actually
111
- * said. CEO wave-2 fix 2026-05-25.
135
+ * said. CEO wave-2 fix.
112
136
  */
113
137
  lastStepDetail = new Map();
114
138
  /**
115
- * Optional local SessionStore - α6.4. When non-null, every
139
+ * Optional local SessionStore - . When non-null, every
116
140
  * appendRow() call mirrors the row into the JSONL log so the
117
141
  * conversation can be restored via `/resume`. Errors from the store
118
142
  * are swallowed to a single system line (degradation, not crash).
@@ -146,20 +170,20 @@ export class ReplSession {
146
170
  * `/privacy` slash falls back to the contract doc with an "unknown"
147
171
  * banner when null.
148
172
  *
149
- * Triple-review P1 fix (2026-05-25): the prior build defined
173
+ * Triple-review P1 fix : the prior build defined
150
174
  * `renderPrivacyBanner` but never called it, and `/privacy` always
151
175
  * rendered with `null` mode. The contract was advertised but the
152
176
  * operator had no mode visibility.
153
177
  */
154
178
  privacyMode = null;
155
179
  /**
156
- * α6.5 Tier 0 / Tier 1 / chokidar wiring. The bootstrap builds the
180
+ * Tier 0 / Tier 1 / chokidar wiring. The bootstrap builds the
157
181
  * skeleton + working set + watcher once and hands them to the
158
182
  * session. The session uses them to:
159
183
  *
160
- * - render `/context` (count + cap + total bytes + skeleton size).
161
- * - emit throttled "file changed" system lines on watcher batches.
162
- * - forget removed files from the working set on `unlink`.
184
+ * - render `/context` (count + cap + total bytes + skeleton size).
185
+ * - emit throttled "file changed" system lines on watcher batches.
186
+ * - forget removed files from the working set on `unlink`.
163
187
  *
164
188
  * All three are optional - tests and minimal callers pass null /
165
189
  * undefined and the session degrades to "no three-tier integration"
@@ -181,7 +205,7 @@ export class ReplSession {
181
205
  * a summary that mentions how many additional files were touched.
182
206
  * Capped at PENDING_FILEWATCH_BATCH_CAP to bound memory growth
183
207
  * under long-running noisy filewatch sources (tsc --watch on a
184
- * 200-file project hammering for hours). triple-review P1 (PR #380).
208
+ * 200-file project hammering for hours). triple-review P1 (PR).
185
209
  */
186
210
  pendingFilewatchBatches = [];
187
211
  /**
@@ -198,7 +222,7 @@ export class ReplSession {
198
222
  * session.close() and watcher.close() does not run handlers on a
199
223
  * dead session. Without detachment, recordFilewatchBatch would
200
224
  * touch this.workingSet / this.transcript on a closed session.
201
- * triple-review P1 (PR #380).
225
+ * triple-review P1 (PR).
202
226
  */
203
227
  filewatchBatchHandler = (batch) => {
204
228
  this.recordFilewatchBatch(batch);
@@ -211,7 +235,7 @@ export class ReplSession {
211
235
  * signatures. The persona may emit the same envelope twice on network
212
236
  * retry; we suppress the duplicate so the operator does not see two
213
237
  * stacked modals. Capped at 32 entries - generous for a real session,
214
- * defensive against a hostile flood. (α6.3.)
238
+ * defensive against a hostile flood.
215
239
  */
216
240
  seenTagSignatures = [];
217
241
  /**
@@ -219,11 +243,11 @@ export class ReplSession {
219
243
  * `<pugi-ask>` open and close tags may arrive in separate
220
244
  * `agent.step` events when the upstream LLM token-streams output
221
245
  * char-by-char. We accumulate the running detail per taskId until a
222
- * complete envelope lands OR the turn ends. (α6.3.)
246
+ * complete envelope lands OR the turn ends.
223
247
  */
224
248
  askBuffer = new Map();
225
249
  /**
226
- * α6.9 dispatch FSM. One instance owned by the session; transitions
250
+ * dispatch FSM. One instance owned by the session; transitions
227
251
  * are mirrored into `state.dispatchState` via an onEnter listener so
228
252
  * subscribers see every change. Resets to `idle` after a terminal
229
253
  * transition (`completed` / `failed` / `aborted`) so the next brief
@@ -236,7 +260,7 @@ export class ReplSession {
236
260
  // accessor - callers cannot reach into this private field.
237
261
  fsm = new DispatchFSM();
238
262
  /**
239
- * α6.9 cancellation token for the currently in-flight dispatch.
263
+ * cancellation token for the currently in-flight dispatch.
240
264
  * Minted on `dispatchBrief()` and released on terminal transitions.
241
265
  * When non-null, calling `cancel()` aborts the token, closes the SSE
242
266
  * stream, and transitions the FSM to `aborting` → `aborted`.
@@ -245,7 +269,7 @@ export class ReplSession {
245
269
  */
246
270
  currentDispatchToken = null;
247
271
  /**
248
- * R2 P1 fix (Codex triple-review 2026-05-25): monotonic dispatch
272
+ * R2 P1 fix (Codex triple-review): monotonic dispatch
249
273
  * sequence id. Incremented on every `dispatchBrief()`. The
250
274
  * agent.spawned handler stamps the current value into
251
275
  * `taskDispatchSeq[event.taskId]`. Terminal handlers
@@ -268,7 +292,7 @@ export class ReplSession {
268
292
  */
269
293
  taskDispatchSeq = new Map();
270
294
  /**
271
- * R3 P1 fix (Codex triple-review 2026-05-25): wall-clock guard used to
295
+ * R3 P1 fix (Codex triple-review): wall-clock guard used to
272
296
  * drop SSE events whose `event.timestamp` predates the current
273
297
  * dispatch. The R2 seq gate alone fails when a LATE `agent.spawned`
274
298
  * from brief #1 arrives AFTER brief #2 mints a new dispatch token:
@@ -289,9 +313,33 @@ export class ReplSession {
289
313
  * the turn ends with this flag still set, we emit a system-line
290
314
  * warning that the persona produced an incomplete tag - the partial
291
315
  * XML is silently dropped (the parser already withheld it from the
292
- * cleaned body). Codex triple-review P2 (PR #375).
316
+ * cleaned body). Codex triple-review P2 (PR).
293
317
  */
294
318
  askBufferPending = new Set();
319
+ /**
320
+ * PUGI-538b () — pending `<pugi-tool-route>` envelope per
321
+ * coordinator taskId. Captured by `consumePugiToolRouteTag` when the
322
+ * envelope's close (or self-close) arrives in the running
323
+ * `agent.step.detail` buffer. The `agent.completed` handler reads
324
+ * the entry to decide whether to fire the engine bridge — firing
325
+ * mid-stream would race with the still-streaming coordinator turn.
326
+ * Cleared on terminal events (`completed` / `blocked` / `failed`).
327
+ *
328
+ * Only one envelope per coordinator turn is honoured (the prompt
329
+ * grammar refuses more than one); a second envelope on the same
330
+ * turn is dropped via the seen-signature rolling set so the dedupe
331
+ * lives in one place.
332
+ */
333
+ pendingToolRoutes = new Map();
334
+ /**
335
+ * PUGI-538b () — abort controllers for in-flight engine
336
+ * bridges, keyed by `bridgeId`. When the REPL operator hits stop,
337
+ * `cancel()` walks this map and aborts every active bridge so the
338
+ * engine HTTP request closes promptly (the engine loop already
339
+ * honours `AbortSignal` via `EngineContext.signal`). Entries are
340
+ * deleted on bridge completion regardless of outcome.
341
+ */
342
+ bridgeAborts = new Map();
295
343
  constructor(options) {
296
344
  this.options = options;
297
345
  this.store = options.store ?? null;
@@ -315,6 +363,19 @@ export class ReplSession {
315
363
  toolCalls: [],
316
364
  transcript: [],
317
365
  tokensDownstreamTotal: 0,
366
+ // cost-meter sprint — cost accumulators land at zero on boot.
367
+ // `sessionStartedAtEpochMs` is set at construction time (vs the
368
+ // server-side `agent.session.opened` event) so the elapsed slot
369
+ // on the status row starts ticking the moment the REPL mounts.
370
+ sessionTokensIn: 0,
371
+ sessionTokensOut: 0,
372
+ sessionCostUsd: 0,
373
+ sessionStartedAtEpochMs: this.now(),
374
+ recentTurns: [],
375
+ turnTokensIn: 0,
376
+ turnTokensOut: 0,
377
+ turnCostUsd: 0,
378
+ lastTurnDelta: null,
318
379
  briefStartedAtEpochMs: undefined,
319
380
  pendingAsk: null,
320
381
  pendingAskSource: null,
@@ -322,8 +383,9 @@ export class ReplSession {
322
383
  pendingPlanReviewSource: null,
323
384
  dispatchState: 'idle',
324
385
  dispatchToolLabel: null,
386
+ lastCompletedOutcome: null,
325
387
  };
326
- // α6.9: mirror every FSM transition into the public state so the
388
+ // : mirror every FSM transition into the public state so the
327
389
  // status-bar surface can rerender on the next frame. Local listener
328
390
  // is intentionally cheap — just a patch + clear the per-state tool
329
391
  // label when leaving `tool_running`.
@@ -359,6 +421,7 @@ export class ReplSession {
359
421
  apiUrl: this.options.apiUrl,
360
422
  apiKey: this.options.apiKey,
361
423
  workspace: this.options.workspace,
424
+ cyberZoo: this.options.cyberZoo,
362
425
  });
363
426
  this.patch({ sessionId, connection: 'connecting' });
364
427
  this.openStream();
@@ -371,6 +434,18 @@ export class ReplSession {
371
434
  // admin-api down) is silent - the operator can still type
372
435
  // `/privacy` to see the contract.
373
436
  void this.fetchAndAnnouncePrivacyMode().catch(() => undefined);
437
+ // silently drain any feedback envelopes
438
+ // that landed offline during a previous session. Best-effort —
439
+ // a failed flush leaves the queue intact for the next start.
440
+ // Never blocks bootstrap.
441
+ void this.flushFeedbackQueueOnBootstrap().catch(() => undefined);
442
+ // BT 9 Phase 2 : codegraph cold-start hook.
443
+ // Surfaces ONE of two nudges:
444
+ // - stale-index reminder ("Codegraph index is N days old…")
445
+ // - 30-day post-decline reminder ("Detected medium TS repo…")
446
+ // Skips silently in every other case. Best-effort — a failed
447
+ // detection NEVER blocks bootstrap (the helper itself catches).
448
+ void this.runCodegraphColdStart().catch(() => undefined);
374
449
  }
375
450
  catch (error) {
376
451
  this.appendSystemLine(`Could not open Pugi session: ${this.errorMessage(error)}`);
@@ -385,7 +460,7 @@ export class ReplSession {
385
460
  * banner is preferable to a noisy "could not fetch privacy mode"
386
461
  * line on every login.
387
462
  *
388
- * Triple-review P1 fix (2026-05-25): without this call,
463
+ * Triple-review P1 fix : without this call,
389
464
  * `renderPrivacyBanner` was defined but never reached the wire, and
390
465
  * `/privacy` always rendered with `null` mode.
391
466
  */
@@ -414,13 +489,69 @@ export class ReplSession {
414
489
  // Silent fail - offline / DNS / unauth all collapse to no banner.
415
490
  }
416
491
  }
492
+ /**
493
+ * on bootstrap, drain the local feedback
494
+ * queue silently. Operators who ran `pugi feedback` while offline
495
+ * see their envelopes flushed on the next online session without
496
+ * any extra command. The drain is best-effort and never blocks
497
+ * the REPL — a failed flush leaves the queue intact for the next
498
+ * bootstrap attempt.
499
+ */
500
+ async flushFeedbackQueueOnBootstrap() {
501
+ const { flushFeedbackQueueSilently } = await import('../../runtime/commands/feedback.js');
502
+ await flushFeedbackQueueSilently(process.cwd(), {
503
+ apiUrl: this.options.apiUrl,
504
+ apiKey: this.options.apiKey,
505
+ });
506
+ }
507
+ /**
508
+ * BT 9 Phase 2 : codegraph cold-start nudge.
509
+ *
510
+ * Surfaces ONE of two nudges on REPL boot when the gate trips:
511
+ * - 30-day post-decline reminder ("Detected medium TS repo…")
512
+ * - stale-index reminder ("Codegraph index is N days old…")
513
+ *
514
+ * The evaluator is pure; we stamp `lastReindexCheckAt` here so the
515
+ * stale-index nudge throttles к once-per-day. The init-flow first-
516
+ * run prompt is handled separately by `pugi init` to avoid double-
517
+ * prompting в the common "init + then code" boot sequence.
518
+ *
519
+ * Best-effort: any error inside the codegraph module is swallowed —
520
+ * a cold-start nudge that breaks the REPL would be worse than no
521
+ * nudge at all.
522
+ */
523
+ async runCodegraphColdStart() {
524
+ try {
525
+ const workspaceRoot = this.options.workspace?.workspaceCwd ?? process.cwd();
526
+ const { evaluateColdStart } = await import('../codegraph/offer-hook.js');
527
+ const verdict = evaluateColdStart({ workspaceRoot });
528
+ if (verdict.kind === 'silent')
529
+ return;
530
+ if (verdict.kind === 'stale-index') {
531
+ this.appendSystemLine(verdict.message);
532
+ const { markReindexChecked } = await import('../codegraph/decision-store.js');
533
+ markReindexChecked(workspaceRoot);
534
+ return;
535
+ }
536
+ // 'remind' — surface the offer copy as a system line. Operator
537
+ // accepts via `/codegraph-status --install` OR explicitly via
538
+ // `pugi mcp install codegraph codegraph serve --mcp`.
539
+ this.appendSystemLine('');
540
+ this.appendSystemLine(verdict.message);
541
+ this.appendSystemLine(' Accept: run `pugi mcp install codegraph codegraph serve --mcp && pugi mcp trust codegraph`');
542
+ this.appendSystemLine(' Skip: /codegraph-status to inspect the decision; the prompt re-appears in 30 days');
543
+ }
544
+ catch {
545
+ // Codegraph nudge is decoration — failure must NEVER surface.
546
+ }
547
+ }
417
548
  /**
418
549
  * Tear down the SSE stream and stop the reconnect timer. The session
419
550
  * id stays valid server-side; `pugi resume <id>` reopens later.
420
551
  */
421
552
  close() {
422
553
  this.closed = true;
423
- // α6.9: fire the cancellation token before tearing down the stream
554
+ // : fire the cancellation token before tearing down the stream
424
555
  // so any in-flight tool sees the abort signal AND any pending
425
556
  // PostBrief promise can short-circuit. Idempotent — token.abort()
426
557
  // is a no-op when already aborted.
@@ -428,6 +559,21 @@ export class ReplSession {
428
559
  this.currentDispatchToken.abort();
429
560
  this.currentDispatchToken = null;
430
561
  }
562
+ // PUGI-538b () — abort every in-flight engine bridge on
563
+ // close() so the engine HTTP request closes promptly when the
564
+ // REPL itself shuts down (operator quit, process exit). Same
565
+ // defensive-try block as cancel() above; already-aborted
566
+ // controllers throw on some Node builds and close() must never
567
+ // crash the caller.
568
+ for (const controller of this.bridgeAborts.values()) {
569
+ try {
570
+ controller.abort();
571
+ }
572
+ catch {
573
+ // Best-effort.
574
+ }
575
+ }
576
+ this.bridgeAborts.clear();
431
577
  if (this.streamHandle) {
432
578
  this.streamHandle.close();
433
579
  this.streamHandle = undefined;
@@ -446,45 +592,71 @@ export class ReplSession {
446
592
  // run a handler on a dead session. The handlers themselves also
447
593
  // hard-guard on `this.closed`, but detaching is the load-bearing
448
594
  // fix - it severs the strong reference the watcher held on the
449
- // session callback, which otherwise blocks GC. triple-review P1 (PR #380).
595
+ // session callback, which otherwise blocks GC. triple-review P1 (PR).
450
596
  if (this.watcher) {
451
597
  this.watcher.off('batch', this.filewatchBatchHandler);
452
598
  this.watcher.off('capExceeded', this.filewatchCapHandler);
453
599
  }
454
600
  }
455
- /* ------------- α6.9 cancellation surface -------------- */
601
+ /* ------------- cancellation surface -------------- */
456
602
  /**
457
603
  * Operator-driven abort for the in-flight dispatch. Idempotent — a
458
604
  * second call while already in `aborting` / `aborted` is a no-op.
459
605
  *
460
606
  * Steps (in order):
461
607
  *
462
- * 1. Snapshot the current state. If terminal or idle, no-op.
463
- * 2. Transition the FSM to `aborting` so the bottom-bar shows the
464
- * pending shutdown immediately (the operator gets feedback
465
- * before any IO completes).
466
- * 3. Abort the cancellation token. This fans out to every listener
467
- * that was attached during the dispatch — chiefly the SSE
468
- * stream wrapper (which calls `streamHandle.close()`) and any
469
- * mid-flight tool executor that polled `isAborted`.
470
- * 4. Append a system line so the conversation reads "Aborted." at
471
- * the operator's last input position.
472
- * 5. Transition to `aborted` (terminal). The next operator brief
473
- * mints a fresh token + transitions back to
474
- * `awaiting_response`.
608
+ * 1. Snapshot the current state. If terminal or idle, no-op.
609
+ * 2. Transition the FSM to `aborting` so the bottom-bar shows the
610
+ * pending shutdown immediately (the operator gets feedback
611
+ * before any IO completes).
612
+ * 3. Abort the cancellation token. This fans out to every listener
613
+ * that was attached during the dispatch — chiefly the SSE
614
+ * stream wrapper (which calls `streamHandle.close()`) and any
615
+ * mid-flight tool executor that polled `isAborted`.
616
+ * 4. Append a system line so the conversation reads "Aborted." at
617
+ * the operator's last input position.
618
+ * 5. Transition to `aborted` (terminal). The next operator brief
619
+ * mints a fresh token + transitions back to
620
+ * `awaiting_response`.
475
621
  *
476
622
  * Returns `true` when an abort was actually issued (state was
477
623
  * non-terminal + non-idle), `false` otherwise.
478
624
  */
479
625
  cancel() {
480
626
  const current = this.fsm.current;
481
- if (this.fsm.isTerminal || current === 'idle')
482
- return false;
627
+ const hasActiveBridge = this.bridgeAborts.size > 0;
628
+ // PUGI-538b () step 4 — bridge cancellation is allowed even
629
+ // when the FSM is terminal. The bridge runs ASYNC after the
630
+ // coordinator turn completes, so by the time the operator hits stop
631
+ // the FSM has already transitioned to `completed` and the legacy
632
+ // guard would short-circuit before the bridge-abort fan-out ran.
633
+ // Without this branch, Esc after the coordinator turn settles would
634
+ // not cancel an in-flight engine call.
635
+ if (this.fsm.isTerminal || current === 'idle') {
636
+ if (!hasActiveBridge)
637
+ return false;
638
+ // Fan out abort to every active bridge, then short-circuit before
639
+ // the FSM transitions (the FSM is already terminal; there is no
640
+ // dispatch token to fire, no SSE stream to tear down for the
641
+ // current dispatch). Map entries are cleared by the bridge
642
+ // promise's then/catch handlers when they unwind.
643
+ for (const controller of this.bridgeAborts.values()) {
644
+ try {
645
+ controller.abort();
646
+ }
647
+ catch {
648
+ // Defensive: already-aborted controllers throw on some Node
649
+ // builds. cancel() must never crash the caller.
650
+ }
651
+ }
652
+ this.appendSystemLine('Bridge aborted.');
653
+ return true;
654
+ }
483
655
  // Step 2: transient state (UI sees `aborting` between abort signal
484
656
  // and full shutdown).
485
657
  this.fsm.transition('aborting', 'operator_abort');
486
658
  // Step 3: fire the token so any mid-flight tool executor that
487
- // polled `isAborted` shuts down. Token is single-use clear the
659
+ // polled `isAborted` shuts down. Token is single-use; clear the
488
660
  // ref AFTER both the abort fan-out AND the stream teardown so any
489
661
  // onAbort listener calling getCurrentDispatchToken() during the
490
662
  // teardown observes the (now-aborted) token rather than null.
@@ -513,6 +685,23 @@ export class ReplSession {
513
685
  this.lastEventId = undefined;
514
686
  // Null the token AFTER stream teardown (see step 3 comment).
515
687
  this.currentDispatchToken = null;
688
+ // PUGI-538b () step 4 — abort every in-flight engine
689
+ // bridge. The bridge's AbortController is threaded through into
690
+ // the engine loop via EngineContext.signal (runEngineLoop already
691
+ // honours AbortSignal; see packages/pugi-sdk/src/engine-loop.ts
692
+ // around line 405). Aborting closes the engine HTTP request
693
+ // promptly so REPL stop cancels the bridge end-to-end, not just
694
+ // the local coordinator turn. Map entries are cleared by the
695
+ // bridge promise's then/catch handlers when they unwind.
696
+ for (const controller of this.bridgeAborts.values()) {
697
+ try {
698
+ controller.abort();
699
+ }
700
+ catch {
701
+ // Defensive: already-aborted controllers throw on some Node
702
+ // builds. cancel() must never crash the caller.
703
+ }
704
+ }
516
705
  // Mark any agents that are still "running" as failed/aborted so
517
706
  // the agent-tree pane reflects reality. We use the existing
518
707
  // `failed` status (the tree pane already knows how to render it)
@@ -539,6 +728,73 @@ export class ReplSession {
539
728
  getDispatchState() {
540
729
  return this.fsm.current;
541
730
  }
731
+ /**
732
+ * BT 8 (the upstream tool parity): Esc-Esc walkback. Trim the last
733
+ * operator/persona turn pair from the in-memory transcript so the
734
+ * model's next call sees the conversation as if the most recent
735
+ * turn never happened. The local SessionStore still has the events
736
+ * on disk (append-only); the in-memory mask is advisory and the next
737
+ * `/compact` boundary will fold them naturally.
738
+ *
739
+ * Refusal modes:
740
+ * - `'no-turn'` - transcript has no operator/persona row to pop.
741
+ * - `'in-flight'` - dispatch is mid-flight; popping would race with
742
+ * the streaming persona row. The operator must
743
+ * cancel (Ctrl+C) before walking back.
744
+ *
745
+ * Success mode:
746
+ * - `'walked-back'` - the trailing persona row + the operator row
747
+ * that triggered it are gone from the transcript.
748
+ * A `↩ walked back 1 turn` status row is appended
749
+ * so the operator sees the state change without
750
+ * guessing.
751
+ *
752
+ * The mask is in-memory only on purpose. Disk-side rewind already has
753
+ * a separate first-class command (`/rewind`) with checkpoint
754
+ * semantics — the Esc-Esc shortcut is a one-tap "oops, undo that" for
755
+ * the live transcript, NOT a transactional rollback.
756
+ */
757
+ walkbackLastTurn() {
758
+ // Refuse while a dispatch is running. Popping the operator row that
759
+ // is currently driving the model's response would leave the persona
760
+ // line orphaned on the next streamed chunk; the FSM also lacks a
761
+ // clean teardown path here. The operator gets a one-line refusal
762
+ // and can Ctrl+C first if they really want to walk back.
763
+ const current = this.fsm.current;
764
+ if (current !== 'idle' && current !== 'completed'
765
+ && current !== 'aborted' && current !== 'failed') {
766
+ this.appendSystemLine('Walkback refused: dispatch in flight. Cancel with Ctrl+C, then Esc-Esc again.');
767
+ return 'in-flight';
768
+ }
769
+ // Find the trailing operator row. Walking backwards because the
770
+ // transcript is append-only and the most recent operator turn is
771
+ // by definition the last `source === 'operator'` row.
772
+ const transcript = this.state.transcript;
773
+ let operatorIdx = -1;
774
+ for (let i = transcript.length - 1; i >= 0; i -= 1) {
775
+ const row = transcript[i];
776
+ if (row.source === 'operator') {
777
+ operatorIdx = i;
778
+ break;
779
+ }
780
+ }
781
+ if (operatorIdx === -1) {
782
+ // No operator turn to pop. Quiet refusal — surfacing a "nothing
783
+ // to undo" line on every accidental double-Esc would be noisy.
784
+ return 'no-turn';
785
+ }
786
+ // Trim everything from the operator row onward (its echo + any
787
+ // persona/system rows that landed in response). The slice keeps
788
+ // every row BEFORE the operator turn, which is the conversation
789
+ // exactly as it stood right before the operator pressed Enter.
790
+ const trimmed = transcript.slice(0, operatorIdx);
791
+ this.patch({ transcript: trimmed });
792
+ // Status row so the operator sees the state change without
793
+ // guessing. Brand voice: single ASCII line, return-arrow glyph
794
+ // (U+21A9) which renders across every modern terminal.
795
+ this.appendSystemLine('↩ walked back 1 turn');
796
+ return 'walked-back';
797
+ }
542
798
  /**
543
799
  * Current cancellation token. Returned for the tool execution path
544
800
  * (file-tools.ts) so it can pass the token down into a ToolContext
@@ -564,7 +820,7 @@ export class ReplSession {
564
820
  // UI overlays - no transport interaction.
565
821
  return verdict;
566
822
  case 'quit':
567
- // UI Designer audit 2026-05-25: "Brief it. It ships." is reserved
823
+ // UI Designer audit: "Brief it. It ships." is reserved
568
824
  // for identity intro + landing per wave-4 prompt rule. Drop the
569
825
  // tagline drift here; tell the operator what happened and how to
570
826
  // resume.
@@ -577,8 +833,20 @@ export class ReplSession {
577
833
  await this.dispatchStop(verdict.persona);
578
834
  return verdict;
579
835
  }
836
+ case 'servers': {
837
+ // PR H (2026-06-05): operator-facing kill для tracked dev
838
+ // servers spawned via `server_start`. Customer-visible bug
839
+ // was `/stop <persona>` not killing servers — closes gap by
840
+ // letting `/servers stop ...` reach into
841
+ // `.pugi/runs/srv-*/server.json` and run SIGTERM→SIGKILL.
842
+ const { runServersCommand } = await import('../../runtime/commands/servers.js');
843
+ await runServersCommand(verdict.mode === 'list'
844
+ ? { kind: 'list' }
845
+ : { kind: 'stop', target: verdict.target }, { write: (line) => this.appendSystemLine(line) }, { workspaceRoot: process.cwd() });
846
+ return verdict;
847
+ }
580
848
  case 'delegate': {
581
- // α7.5 Phase 1: surface the dispatch intent inline. The actual
849
+ // Phase 1: surface the dispatch intent inline. The actual
582
850
  // wire shape (POST /api/pugi/sessions/:id/delegate) requires the
583
851
  // SDK transport extension that ships alongside this PR; the
584
852
  // REPL session module wires the call when the matching transport
@@ -606,7 +874,43 @@ export class ReplSession {
606
874
  return verdict;
607
875
  }
608
876
  case 'jobs': {
609
- await this.dispatchJobs();
877
+ // cleanup : `/jobs --watch` mounts the
878
+ // live Ink TUI from inside the REPL. The dispatcher does NOT
879
+ // mount the watcher itself (that would unmount the REPL's
880
+ // own Ink tree) — instead it surfaces the shell command so
881
+ // the operator runs the watcher in a fresh terminal. Bare
882
+ // `/jobs` continues to render the one-shot snapshot.
883
+ if (verdict.watch) {
884
+ this.appendSystemLine('Run `pugi jobs --watch` from a fresh shell — the live TUI cannot share the REPL Ink tree.');
885
+ }
886
+ else {
887
+ await this.dispatchJobs();
888
+ }
889
+ return verdict;
890
+ }
891
+ case 'cancel': {
892
+ // small-CC-parity batch : forward the parsed
893
+ // mode + dispatchId to `runCancelCommand`. The dispatcher uses
894
+ // a dynamic import so the cancel module's filesystem helpers
895
+ // stay out of the REPL keystroke hot path; same separation as
896
+ // `/redo`, `/prd-check`, `/chain`. The runner writes its
897
+ // output lines through `appendSystemLine` so the verdict
898
+ // lands on the system pane alongside other slash results.
899
+ try {
900
+ const { runCancelCommand } = await import('../../runtime/commands/cancel.js');
901
+ const cancelMode = verdict.mode === 'list'
902
+ ? { kind: 'list' }
903
+ : verdict.mode === 'all'
904
+ ? { kind: 'all' }
905
+ : { kind: 'one', dispatchId: verdict.dispatchId };
906
+ await runCancelCommand(cancelMode, {
907
+ write: (line) => this.appendSystemLine(line),
908
+ });
909
+ }
910
+ catch (err) {
911
+ const message = err instanceof Error ? err.message : String(err);
912
+ this.appendSystemLine(`/cancel failed: ${message}`);
913
+ }
610
914
  return verdict;
611
915
  }
612
916
  case 'diff': {
@@ -614,11 +918,15 @@ export class ReplSession {
614
918
  return verdict;
615
919
  }
616
920
  case 'cost': {
617
- this.dispatchCost();
921
+ await this.dispatchCost();
922
+ return verdict;
923
+ }
924
+ case 'quota': {
925
+ await this.dispatchQuota();
618
926
  return verdict;
619
927
  }
620
928
  case 'status': {
621
- this.dispatchStatus();
929
+ await this.dispatchStatus();
622
930
  return verdict;
623
931
  }
624
932
  case 'consensus': {
@@ -640,7 +948,7 @@ export class ReplSession {
640
948
  return verdict;
641
949
  }
642
950
  case 'ask': {
643
- // α6.3: synthesise a local yes/no `<pugi-ask>` modal so the
951
+ // : synthesise a local yes/no `<pugi-ask>` modal so the
644
952
  // operator can exercise the question UI without a persona-side
645
953
  // round trip. The REPL UI mounts the modal from the resulting
646
954
  // `pendingAsk` state; on resolution the encoded verdict lands
@@ -663,12 +971,1007 @@ export class ReplSession {
663
971
  await this.dispatchPrivacy();
664
972
  return verdict;
665
973
  }
974
+ case 'init': {
975
+ // β1 Sl11 → β1a r1 (real inline scaffold): invoke
976
+ // `scaffoldPugiWorkspace` directly so the operator gets the
977
+ // same .pugi/ setup they would from `pugi init` on a fresh
978
+ // shell. Already-initialised workspaces (every artifact already
979
+ // present) get the "Already initialised" copy; partial / fresh
980
+ // workspaces get the full Created+Skipped breakdown. Default
981
+ // skills install is best-effort — any error from the bundled
982
+ // pack is surfaced as a system line and does not break the
983
+ // REPL session. The dynamic import keeps the slash dispatcher
984
+ // free of a runtime/cli.ts cycle on every keystroke.
985
+ try {
986
+ const { scaffoldPugiWorkspace } = await import('../../runtime/cli.js');
987
+ const lines = [];
988
+ const result = await scaffoldPugiWorkspace({
989
+ cwd: process.cwd(),
990
+ // Slash callers default to the full default-skills pack so
991
+ // the in-REPL experience matches `pugi init`. Operators who
992
+ // want a minimal scaffold still have the shell command.
993
+ noDefaults: false,
994
+ log: (line) => {
995
+ const trimmed = line.replace(/\n+$/u, '');
996
+ if (trimmed.length > 0)
997
+ lines.push(trimmed);
998
+ },
999
+ });
1000
+ if (result.alreadyInitialized) {
1001
+ this.appendSystemLine(`.pugi/ already initialised at ${result.root}. ${result.skipped.length} artefact(s) verified.`);
1002
+ }
1003
+ else {
1004
+ this.appendSystemLine(`Pugi initialised at ${result.root}. Created ${result.created.length} artefact(s), skipped ${result.skipped.length}.`);
1005
+ }
1006
+ if (result.defaultSkills.length > 0) {
1007
+ const installed = result.defaultSkills.filter((s) => s.status === 'installed').length;
1008
+ const skippedSkills = result.defaultSkills.filter((s) => s.status === 'skipped-existing').length;
1009
+ this.appendSystemLine(`Default skills: ${installed} installed, ${skippedSkills} already present.`);
1010
+ }
1011
+ for (const line of lines)
1012
+ this.appendSystemLine(line);
1013
+ }
1014
+ catch (error) {
1015
+ const message = error instanceof Error ? error.message : String(error);
1016
+ this.appendSystemLine(`/init failed: ${message}`);
1017
+ }
1018
+ return verdict;
1019
+ }
1020
+ case 'mcp': {
1021
+ // β4 Sl7 : /mcp [sub] [args...] forwards to the
1022
+ // runtime command. We deliberately route through the same
1023
+ // entry-point used by `pugi mcp` from a fresh shell so the
1024
+ // surface stays single-sourced. `serve` is refused inline —
1025
+ // booting an MCP server inside an active REPL would compete
1026
+ // with the REPL itself for stdio, which is exactly the wrong
1027
+ // thing to do.
1028
+ if (verdict.args[0] === 'serve') {
1029
+ this.appendSystemLine('/mcp serve is not safe inside the REPL (it competes for stdio). ' +
1030
+ 'Run `pugi mcp serve` from a fresh shell instead.');
1031
+ return verdict;
1032
+ }
1033
+ try {
1034
+ const { runMcpCommand } = await import('../../runtime/commands/mcp.js');
1035
+ const lines = [];
1036
+ await runMcpCommand(verdict.args, {
1037
+ workspaceRoot: process.cwd(),
1038
+ writeOutput: (_payload, text) => {
1039
+ const trimmed = text.replace(/\n+$/u, '');
1040
+ if (trimmed.length > 0)
1041
+ lines.push(trimmed);
1042
+ },
1043
+ });
1044
+ for (const line of lines)
1045
+ this.appendSystemLine(line);
1046
+ if (lines.length === 0) {
1047
+ this.appendSystemLine('/mcp: no output.');
1048
+ }
1049
+ }
1050
+ catch (error) {
1051
+ const message = error instanceof Error ? error.message : String(error);
1052
+ this.appendSystemLine(`/mcp failed: ${message}`);
1053
+ }
1054
+ return verdict;
1055
+ }
1056
+ case 'theme': {
1057
+ // /theme [name] [--persist|--reset|--list]
1058
+ // forwards to the shared `runThemeCommand` runner. Same async
1059
+ // buffer-then-flush pattern as `/style` so a future async
1060
+ // write path inside the runner cannot drop a tail emission
1061
+ // and so multi-line payloads (banner + preview table) land
1062
+ // one row per visual line in the conversation pane.
1063
+ try {
1064
+ const { runThemeCommand } = await import('../../runtime/commands/theme.js');
1065
+ const lines = [];
1066
+ await runThemeCommand(verdict.args, {
1067
+ workspaceRoot: process.cwd(),
1068
+ writeOutput: (_payload, text) => {
1069
+ for (const raw of text.split('\n')) {
1070
+ const trimmed = raw.replace(/\s+$/u, '');
1071
+ lines.push(trimmed);
1072
+ }
1073
+ },
1074
+ });
1075
+ if (lines.length === 0) {
1076
+ this.appendSystemLine('/theme: no output.');
1077
+ }
1078
+ else {
1079
+ for (const line of lines)
1080
+ this.appendSystemLine(line);
1081
+ }
1082
+ }
1083
+ catch (error) {
1084
+ const message = error instanceof Error ? error.message : String(error);
1085
+ this.appendSystemLine(`/theme failed: ${message}`);
1086
+ }
1087
+ return verdict;
1088
+ }
1089
+ case 'style': {
1090
+ // /style [name] [--persist|--reset|--list]
1091
+ // forwards to the shared `runStyleCommand` runner so the slash
1092
+ // + top-level surfaces share one code path. Dynamic import
1093
+ // keeps the dispatcher free of the output-style module graph
1094
+ // until the operator first invokes the slash. The runner's
1095
+ // exit code is captured but NOT propagated to process.exitCode
1096
+ // — REPL session should not die because a bad preset slug was
1097
+ // typed in the input box.
1098
+ try {
1099
+ const { runStyleCommand } = await import('../../runtime/commands/style.js');
1100
+ // L18 P1 fix : writeOutput is invoked SYNCHRONOUSLY
1101
+ // by `runStyleCommand` for each emitted block. We buffer every
1102
+ // emission into `lines` and flush after the await resolves so
1103
+ // that:
1104
+ // (1) any future async write path inside the runner cannot
1105
+ // drop a tail emission (callback never references the
1106
+ // Ink frame directly), and
1107
+ // (2) multi-line payloads (e.g. the active-style banner +
1108
+ // catalogue table) render one row per visual line in the
1109
+ // conversation pane, matching the `/stickers` surface.
1110
+ const lines = [];
1111
+ await runStyleCommand(verdict.args, {
1112
+ workspaceRoot: process.cwd(),
1113
+ writeOutput: (_payload, text) => {
1114
+ for (const raw of text.split('\n')) {
1115
+ const trimmed = raw.replace(/\s+$/u, '');
1116
+ lines.push(trimmed);
1117
+ }
1118
+ },
1119
+ });
1120
+ if (lines.length === 0) {
1121
+ this.appendSystemLine('/style: no output.');
1122
+ }
1123
+ else {
1124
+ for (const line of lines)
1125
+ this.appendSystemLine(line);
1126
+ }
1127
+ }
1128
+ catch (error) {
1129
+ const message = error instanceof Error ? error.message : String(error);
1130
+ this.appendSystemLine(`/style failed: ${message}`);
1131
+ }
1132
+ return verdict;
1133
+ }
1134
+ case 'onboarding': {
1135
+ // /onboarding forwards to the shared
1136
+ // `runOnboardingCommand` runner. From inside the REPL we ALWAYS
1137
+ // route through the non-interactive snapshot path — the REPL
1138
+ // already owns the Ink tree and mounting a second Ink wizard
1139
+ // on top would conflict over stdin raw mode. Operators who
1140
+ // want the interactive walk exit the REPL and run
1141
+ // `pugi onboarding` from a fresh shell; the slash surface
1142
+ // surfaces the recap card + hints inline so the operator
1143
+ // sees current values without leaving the session.
1144
+ try {
1145
+ const { runOnboardingCommand } = await import('../../runtime/commands/onboarding.js');
1146
+ const { resolveActiveCredential } = await import('../credentials.js');
1147
+ const credential = resolveActiveCredential();
1148
+ const lines = [];
1149
+ await runOnboardingCommand(verdict.args, {
1150
+ workspaceRoot: process.cwd(),
1151
+ env: process.env,
1152
+ authPresent: credential !== null,
1153
+ interactive: false,
1154
+ writeOutput: (_payload, text) => {
1155
+ const trimmed = text.replace(/\n+$/u, '');
1156
+ if (trimmed.length > 0)
1157
+ lines.push(trimmed);
1158
+ },
1159
+ });
1160
+ for (const line of lines)
1161
+ this.appendSystemLine(line);
1162
+ if (lines.length === 0) {
1163
+ this.appendSystemLine('/onboarding: no output.');
1164
+ }
1165
+ }
1166
+ catch (error) {
1167
+ const message = error instanceof Error ? error.message : String(error);
1168
+ this.appendSystemLine(`/onboarding failed: ${message}`);
1169
+ }
1170
+ return verdict;
1171
+ }
1172
+ case 'vim': {
1173
+ // /vim forwards to the shared
1174
+ // `runVimCommand` runner so the slash + top-level surfaces
1175
+ // stay single-sourced. Dynamic import mirrors /style so the
1176
+ // dispatcher does not drag the vim module graph into every
1177
+ // keystroke.
1178
+ //
1179
+ // The runner mutates `~/.pugi/config.json::vimMode`; the
1180
+ // active REPL session does NOT live-pick-up the flip (the
1181
+ // VimInput wrapper is mounted once at REPL boot). Operators
1182
+ // get a hint that the next session will reflect the change.
1183
+ // A follow-up sprint can plumb a state-store subscriber so
1184
+ // the flip takes effect mid-session.
1185
+ try {
1186
+ const { runVimCommand } = await import('../../runtime/commands/vim.js');
1187
+ const lines = [];
1188
+ await runVimCommand(verdict.args, {
1189
+ env: process.env,
1190
+ writeOutput: (_payload, text) => {
1191
+ for (const raw of text.split('\n')) {
1192
+ const trimmed = raw.replace(/\s+$/u, '');
1193
+ lines.push(trimmed);
1194
+ }
1195
+ },
1196
+ });
1197
+ if (lines.length === 0) {
1198
+ this.appendSystemLine('/vim: no output.');
1199
+ }
1200
+ else {
1201
+ for (const line of lines)
1202
+ this.appendSystemLine(line);
1203
+ }
1204
+ }
1205
+ catch (error) {
1206
+ const message = error instanceof Error ? error.message : String(error);
1207
+ this.appendSystemLine(`/vim failed: ${message}`);
1208
+ }
1209
+ return verdict;
1210
+ }
1211
+ case 'doctor': {
1212
+ // L17 : run the doctor probe sweep inline. We
1213
+ // dynamic-import the runtime/commands/doctor module so the
1214
+ // slash dispatcher does not pull the diagnostics graph
1215
+ // (execFileSync + fs probes) into every keystroke. The
1216
+ // module's output is captured into local lines so we can
1217
+ // render it as system entries in the conversation pane;
1218
+ // an Ink-rendered table inside the REPL frame is a follow-up.
1219
+ try {
1220
+ const { runDoctorCommand, defaultHome } = await import('../../runtime/commands/doctor.js');
1221
+ const lines = [];
1222
+ await runDoctorCommand({
1223
+ cwd: process.cwd(),
1224
+ home: defaultHome(),
1225
+ env: process.env,
1226
+ json: false,
1227
+ writeOutput: (_payload, text) => {
1228
+ const trimmed = text.replace(/\n+$/u, '');
1229
+ if (trimmed.length > 0)
1230
+ lines.push(trimmed);
1231
+ },
1232
+ });
1233
+ for (const line of lines)
1234
+ this.appendSystemLine(line);
1235
+ if (lines.length === 0) {
1236
+ this.appendSystemLine('/doctor: no output.');
1237
+ }
1238
+ }
1239
+ catch (error) {
1240
+ const message = error instanceof Error ? error.message : String(error);
1241
+ this.appendSystemLine(`/doctor failed: ${message}`);
1242
+ }
1243
+ return verdict;
1244
+ }
1245
+ case 'prd-check': {
1246
+ // : forward to the same handler the shell
1247
+ // surface uses so the verdict is identical between
1248
+ // `/prd-check` and `pugi prd-check`. Dynamic-import the
1249
+ // module to keep the parser + verifier graph out of the
1250
+ // REPL hot path.
1251
+ //
1252
+ // final : the runner now also honours
1253
+ // `--session` mode (orthogonal to the verifier graph — walks
1254
+ // up for PRD.md, reads NDJSON turns, dispatches a cross-
1255
+ // review subagent). We stream the runner's status lines
1256
+ // directly to the system pane so the operator sees
1257
+ // "Locating PRD..." / "Reviewing against PRD..." while the
1258
+ // dispatch is in flight, then the structured Satisfied /
1259
+ // Outstanding lists when it lands.
1260
+ try {
1261
+ const { parsePrdCheckArgs, runPrdCheckCommand } = await import('../../runtime/commands/prd-check.js');
1262
+ const parsed = parsePrdCheckArgs(verdict.args, { jsonDefault: false });
1263
+ if (!parsed.ok) {
1264
+ this.appendSystemLine(`/prd-check: ${parsed.error}`);
1265
+ return verdict;
1266
+ }
1267
+ let sawOutput = false;
1268
+ await runPrdCheckCommand({
1269
+ cwd: process.cwd(),
1270
+ ...(parsed.prdPath !== undefined ? { prdPath: parsed.prdPath } : {}),
1271
+ flags: parsed.flags,
1272
+ // The REPL slash does not have a snapshot of the CLI
1273
+ // command registry, so we pass an empty set; the
1274
+ // command:<name> verifier will report FAIL for now.
1275
+ // This is a deliberate trade-off — the slash surface
1276
+ // primarily exists for quick eyeball checks during a
1277
+ // session; the shell surface (which DOES inject the
1278
+ // full registry) is the canonical gate.
1279
+ knownCommands: new Set(),
1280
+ writeOutput: (_payload, text) => {
1281
+ const trimmed = text.replace(/\n+$/u, '');
1282
+ if (trimmed.length > 0) {
1283
+ this.appendSystemLine(trimmed);
1284
+ sawOutput = true;
1285
+ }
1286
+ },
1287
+ });
1288
+ if (!sawOutput) {
1289
+ this.appendSystemLine('/prd-check: no output.');
1290
+ }
1291
+ }
1292
+ catch (error) {
1293
+ const message = error instanceof Error ? error.message : String(error);
1294
+ this.appendSystemLine(`/prd-check failed: ${message}`);
1295
+ }
1296
+ return verdict;
1297
+ }
1298
+ case 'chain': {
1299
+ // : forward to the shell-surface runner so
1300
+ // the slash + top-level CLI share one parser + dispatcher.
1301
+ // Dynamic import keeps the chain module out of the REPL hot
1302
+ // path. The slash variant does NOT inject the live delegate
1303
+ // wire-up — operators wanting full dispatch run `pugi chain
1304
+ // next` from a fresh shell. The slash form is best-effort for
1305
+ // status / show / list which are read-only.
1306
+ try {
1307
+ const { runChainCommand } = await import('../../runtime/commands/chain.js');
1308
+ const lines = [];
1309
+ await runChainCommand(verdict.args, {
1310
+ cwd: process.cwd(),
1311
+ json: false,
1312
+ writeOutput: (_payload, text) => {
1313
+ const trimmed = text.replace(/\n+$/u, '');
1314
+ if (trimmed.length > 0)
1315
+ lines.push(trimmed);
1316
+ },
1317
+ });
1318
+ for (const line of lines)
1319
+ this.appendSystemLine(line);
1320
+ if (lines.length === 0) {
1321
+ this.appendSystemLine('/chain: no output.');
1322
+ }
1323
+ }
1324
+ catch (error) {
1325
+ const message = error instanceof Error ? error.message : String(error);
1326
+ this.appendSystemLine(`/chain failed: ${message}`);
1327
+ }
1328
+ return verdict;
1329
+ }
1330
+ case 'codegraph-status': {
1331
+ // BT 9 Phase 2 : forward to the runner. The
1332
+ // bare form renders the four-row status table; flags handle
1333
+ // install / reindex / offer. Dynamic import keeps the
1334
+ // codegraph module out of the REPL hot path until first use.
1335
+ try {
1336
+ const { runCodegraphStatusCommand } = await import('../../runtime/commands/codegraph-status.js');
1337
+ const lines = [];
1338
+ const workspaceRoot = this.options.workspace?.workspaceCwd ?? process.cwd();
1339
+ await runCodegraphStatusCommand(verdict.args, {
1340
+ workspaceRoot,
1341
+ writeOutput: (_payload, text) => {
1342
+ for (const raw of text.split('\n')) {
1343
+ const trimmed = raw.replace(/\s+$/u, '');
1344
+ lines.push(trimmed);
1345
+ }
1346
+ },
1347
+ });
1348
+ if (lines.length === 0) {
1349
+ this.appendSystemLine('/codegraph-status: no output.');
1350
+ }
1351
+ else {
1352
+ for (const line of lines)
1353
+ this.appendSystemLine(line);
1354
+ }
1355
+ }
1356
+ catch (error) {
1357
+ const message = error instanceof Error ? error.message : String(error);
1358
+ this.appendSystemLine(`/codegraph-status failed: ${message}`);
1359
+ }
1360
+ return verdict;
1361
+ }
1362
+ case 'permissions': {
1363
+ // handle the `/permissions [mode] [--persist]` flow.
1364
+ // The session module forwards to the runtime helper so the
1365
+ // workspace + global-config writes share one code path with
1366
+ // the CLI's top-level `--mode` resolution. The dynamic import
1367
+ // keeps the dispatcher free of a session.ts -> runtime/cli.ts
1368
+ // cycle.
1369
+ try {
1370
+ const { runPermissionsCommand } = await import('../../runtime/commands/permissions.js');
1371
+ const lines = [];
1372
+ await runPermissionsCommand(verdict, {
1373
+ workspaceRoot: process.cwd(),
1374
+ writeOutput: (line) => {
1375
+ const trimmed = line.replace(/\n+$/u, '');
1376
+ if (trimmed.length > 0)
1377
+ lines.push(trimmed);
1378
+ },
1379
+ });
1380
+ for (const line of lines)
1381
+ this.appendSystemLine(line);
1382
+ }
1383
+ catch (error) {
1384
+ const message = error instanceof Error ? error.message : String(error);
1385
+ this.appendSystemLine(`/permissions failed: ${message}`);
1386
+ }
1387
+ return verdict;
1388
+ }
1389
+ case 'compact': {
1390
+ // /compact summarises older turns and
1391
+ // appends a boundary marker. We forward to the same runner the
1392
+ // top-level `pugi compact` command uses so the surface stays
1393
+ // single-sourced. The session module owns the in-memory
1394
+ // transcript echo (system line + banner row) so the operator
1395
+ // sees the marker land without a fresh REPL bootstrap.
1396
+ //
1397
+ // BT 8 (the upstream tool parity): `--force` bypasses the
1398
+ // noop-empty guard so the operator can compact even short
1399
+ // sessions (useful before a manual checkpoint).
1400
+ await this.dispatchCompact('manual', { force: verdict.force });
1401
+ return verdict;
1402
+ }
1403
+ case 'model': {
1404
+ // BT 8 (the upstream tool parity): /model lists OR selects the
1405
+ // active model. Slash + top-level CLI share `runModelCommand`.
1406
+ // The session module forwards writeOutput → appendSystemLine so
1407
+ // the menu + the confirmation line land inline in the
1408
+ // transcript. Tier override is undefined at the slash surface;
1409
+ // the runner defaults to 'team' so unauthenticated operators
1410
+ // see every model. Server-side calls enforce the real tier cap.
1411
+ try {
1412
+ const { runModelCommand } = await import('../../runtime/commands/model.js');
1413
+ await runModelCommand({ slug: verdict.slug }, {
1414
+ workspaceRoot: process.cwd(),
1415
+ writeOutput: (line) => {
1416
+ const trimmed = line.replace(/\n+$/u, '');
1417
+ if (trimmed.length > 0)
1418
+ this.appendSystemLine(trimmed);
1419
+ else
1420
+ this.appendSystemLine('');
1421
+ },
1422
+ });
1423
+ }
1424
+ catch (error) {
1425
+ const message = error instanceof Error ? error.message : String(error);
1426
+ this.appendSystemLine(`/model failed: ${message}`);
1427
+ }
1428
+ return verdict;
1429
+ }
1430
+ case 'rewind': {
1431
+ // /rewind appends an append-only
1432
+ // tombstone marker that rolls the conversation back to a
1433
+ // checkpoint. The actual replay-mask is advisory — the on-disk
1434
+ // events stay durable so `pugi sessions undo-rewind` can
1435
+ // reverse the operation. We forward to the same runner the
1436
+ // top-level `pugi rewind` command uses to keep the surface
1437
+ // single-sourced. Dynamic import avoids pulling the checkpoint
1438
+ // graph into the dispatcher at module load.
1439
+ if (!this.store || !this.localSessionId) {
1440
+ this.appendSystemLine('Local session store is disabled — /rewind is unavailable.');
1441
+ return verdict;
1442
+ }
1443
+ try {
1444
+ const { runRewindCommand } = await import('../../runtime/commands/rewind.js');
1445
+ await runRewindCommand(verdict.args, {
1446
+ workspaceRoot: process.cwd(),
1447
+ sessionId: this.localSessionId,
1448
+ store: this.store,
1449
+ writeOutput: (_payload, text) => {
1450
+ if (text.length > 0)
1451
+ this.appendSystemLine(text);
1452
+ },
1453
+ });
1454
+ }
1455
+ catch (error) {
1456
+ const message = error instanceof Error ? error.message : String(error);
1457
+ this.appendSystemLine(`/rewind failed: ${message}`);
1458
+ }
1459
+ return verdict;
1460
+ }
1461
+ case 'share': {
1462
+ // /share forwards to the same runner the
1463
+ // top-level `pugi share` command uses. The session module
1464
+ // wires writeOutput to appendSystemLine so the upload result +
1465
+ // privacy gate banner land in the REPL transcript inline.
1466
+ // Confirmation prompt + readline still use stdio because the
1467
+ // Ink frame is held by the input box; operators wanting fully
1468
+ // scripted shares pass `--yes` so no prompt fires.
1469
+ try {
1470
+ const { runShareCommand } = await import('../../runtime/commands/share.js');
1471
+ const lines = [];
1472
+ await runShareCommand(verdict.args, {
1473
+ workspaceRoot: process.cwd(),
1474
+ cliVersion: this.options.cliVersion,
1475
+ sessionId: this.localSessionId ?? undefined,
1476
+ writeOutput: (_payload, text) => {
1477
+ const trimmed = text.replace(/\n+$/u, '');
1478
+ if (trimmed.length > 0)
1479
+ lines.push(trimmed);
1480
+ },
1481
+ });
1482
+ for (const line of lines)
1483
+ this.appendSystemLine(line);
1484
+ if (lines.length === 0) {
1485
+ this.appendSystemLine('/share: no output.');
1486
+ }
1487
+ }
1488
+ catch (error) {
1489
+ const message = error instanceof Error ? error.message : String(error);
1490
+ this.appendSystemLine(`/share failed: ${message}`);
1491
+ }
1492
+ return verdict;
1493
+ }
1494
+ case 'plan': {
1495
+ // handle `/plan [--back | --persist] [<prompt>]`.
1496
+ // The session module forwards the mode-switch portion to the
1497
+ // shared runtime helper so the workspace + global-config writes
1498
+ // share one code path with `pugi plan`. When the operator
1499
+ // typed a prompt alongside (`/plan write me X`), the prompt is
1500
+ // forwarded through the dispatch FSM exactly as if they had
1501
+ // typed it directly — the only difference is the gate now
1502
+ // refuses write/dispatch tools because the workspace mode flipped
1503
+ // to plan first. Same dynamic-import trick as /permissions to
1504
+ // avoid pulling the engine adapter graph into the dispatcher.
1505
+ try {
1506
+ const { runPlanCommand } = await import('../../runtime/commands/plan.js');
1507
+ const lines = [];
1508
+ await runPlanCommand({ back: verdict.back, persist: verdict.persist }, {
1509
+ workspaceRoot: process.cwd(),
1510
+ writeOutput: (line) => {
1511
+ const trimmed = line.replace(/\n+$/u, '');
1512
+ if (trimmed.length > 0)
1513
+ lines.push(trimmed);
1514
+ },
1515
+ });
1516
+ for (const line of lines)
1517
+ this.appendSystemLine(line);
1518
+ // Optional one-shot engine dispatch: when the operator typed
1519
+ // a prompt alongside the slash, route it through the existing
1520
+ // dispatch path. We rewrite the verdict into a synthetic
1521
+ // `dispatch` result so the engine sees the user's prompt with
1522
+ // the plan-mode gate already in place. `--auto-back` is NOT
1523
+ // honoured in the slash surface today — operators stay in
1524
+ // plan mode and revert manually with `/plan --back`. The CLI
1525
+ // top-level `pugi plan --auto-back` exists for scripted use.
1526
+ if (verdict.prompt.length > 0 && !verdict.back) {
1527
+ return { kind: 'dispatch', brief: verdict.prompt };
1528
+ }
1529
+ }
1530
+ catch (error) {
1531
+ const message = error instanceof Error ? error.message : String(error);
1532
+ this.appendSystemLine(`/plan failed: ${message}`);
1533
+ }
1534
+ return verdict;
1535
+ }
1536
+ case 'release-notes': {
1537
+ // changelog diff between the operator's
1538
+ // last-seen + installed CLI versions. Delegate к the shared
1539
+ // `runReleaseNotesCommand` runner so the slash + top-level
1540
+ // paths stay single-sourced. The renderer collects each line
1541
+ // into the system pane via `appendSystemLine` — no fresh Ink
1542
+ // mount, no boxed render. `--reset` is honoured via the
1543
+ // `verdict.reset` field parsed in slash-commands.ts.
1544
+ try {
1545
+ const { runReleaseNotesCommand, defaultReleaseNotesHome } = await import('../../runtime/commands/release-notes.js');
1546
+ const lines = [];
1547
+ runReleaseNotesCommand({
1548
+ home: defaultReleaseNotesHome(),
1549
+ json: false,
1550
+ reset: verdict.reset,
1551
+ writeOutput: (_payload, text) => {
1552
+ for (const line of text.split('\n')) {
1553
+ lines.push(line.replace(/\s+$/u, ''));
1554
+ }
1555
+ },
1556
+ });
1557
+ if (lines.length === 0) {
1558
+ this.appendSystemLine('/release-notes: no output.');
1559
+ }
1560
+ else {
1561
+ for (const line of lines)
1562
+ this.appendSystemLine(line);
1563
+ }
1564
+ }
1565
+ catch (error) {
1566
+ const message = error instanceof Error ? error.message : String(error);
1567
+ this.appendSystemLine(`/release-notes failed: ${message}`);
1568
+ }
1569
+ return verdict;
1570
+ }
1571
+ case 'stickers': {
1572
+ // brand-personality gimmick. Delegate to
1573
+ // the shared `runStickersCommand` so the slash + top-level
1574
+ // paths stay single-sourced. The renderer routes the text
1575
+ // through the system pane line-buffer (ascii-only — no fresh
1576
+ // Ink mount) so the gimmick lands as a single contiguous
1577
+ // block в the conversation transcript.
1578
+ try {
1579
+ const { runStickersCommand } = await import('../../runtime/commands/stickers.js');
1580
+ // L33 P1 fix : await the runner even though the
1581
+ // current implementation is synchronous. Two reasons:
1582
+ // (1) future-proofs the call site against the runner growing
1583
+ // an async path (e.g. remote stickerpack fetch) — without
1584
+ // this await, a returned promise would resolve AFTER we
1585
+ // flushed `lines` and the gimmick would render blank, and
1586
+ // (2) keeps the slash dispatcher uniform with the other
1587
+ // command runners (style, doctor, permissions, plan), all
1588
+ // of which are awaited.
1589
+ const lines = [];
1590
+ await runStickersCommand({
1591
+ json: false,
1592
+ asciiOnly: true,
1593
+ writeOutput: (_payload, text) => {
1594
+ for (const line of text.split('\n')) {
1595
+ const trimmed = line.replace(/\s+$/u, '');
1596
+ lines.push(trimmed);
1597
+ }
1598
+ },
1599
+ });
1600
+ if (lines.length === 0) {
1601
+ this.appendSystemLine('/stickers: no output.');
1602
+ }
1603
+ else {
1604
+ for (const line of lines)
1605
+ this.appendSystemLine(line);
1606
+ }
1607
+ }
1608
+ catch (error) {
1609
+ const message = error instanceof Error ? error.message : String(error);
1610
+ this.appendSystemLine(`/stickers failed: ${message}`);
1611
+ }
1612
+ return verdict;
1613
+ }
1614
+ case 'update': {
1615
+ // /update probes the npm registry for a
1616
+ // newer @pugi/cli version on the configured channel and prints
1617
+ // the install command. The slash form NEVER spawns `npm install
1618
+ // -g` — that would corrupt the binary we are currently running.
1619
+ // Operators see the install command + run it manually (or run
1620
+ // `pugi update --apply` from a fresh shell after the REPL
1621
+ // exits). The slash + top-level paths share the dispatcher so
1622
+ // channel resolution + last-check persistence stay single-
1623
+ // sourced.
1624
+ try {
1625
+ const { parseUpdateArgs, runUpdateCommand } = await import('../../runtime/commands/update.js');
1626
+ const parsed = parseUpdateArgs(verdict.args);
1627
+ if ('error' in parsed) {
1628
+ this.appendSystemLine(parsed.error);
1629
+ return verdict;
1630
+ }
1631
+ // Force `apply=false` on the slash path — see comment above.
1632
+ const slashFlags = { ...parsed, apply: false };
1633
+ const lines = [];
1634
+ await runUpdateCommand({
1635
+ cwd: process.cwd(),
1636
+ home: homedir(),
1637
+ env: process.env,
1638
+ flags: slashFlags,
1639
+ promptConfirm: async () => false,
1640
+ writeOutput: (_payload, text) => {
1641
+ for (const line of text.split('\n')) {
1642
+ const trimmed = line.replace(/\s+$/u, '');
1643
+ if (trimmed.length > 0)
1644
+ lines.push(trimmed);
1645
+ }
1646
+ },
1647
+ });
1648
+ if (lines.length === 0) {
1649
+ this.appendSystemLine('/update: no output.');
1650
+ }
1651
+ else {
1652
+ for (const line of lines)
1653
+ this.appendSystemLine(line);
1654
+ }
1655
+ }
1656
+ catch (error) {
1657
+ const message = error instanceof Error ? error.message : String(error);
1658
+ this.appendSystemLine(`/update failed: ${message}`);
1659
+ }
1660
+ return verdict;
1661
+ }
1662
+ case 'feedback': {
1663
+ // in-CLI feedback collector. The wizard
1664
+ // mounts a fresh Ink tree (renderFeedbackPrompt) outside the
1665
+ // live REPL input box so the operator can step through
1666
+ // category / rating / comment / context / confirm without
1667
+ // interleaving with persona output. The session module owns
1668
+ // the submit + queue wiring so the slash + top-level CLI
1669
+ // surfaces stay single-sourced through `runFeedbackCommand`.
1670
+ try {
1671
+ await this.runFeedbackSlash();
1672
+ }
1673
+ catch (error) {
1674
+ const message = error instanceof Error ? error.message : String(error);
1675
+ this.appendSystemLine(`/feedback failed: ${message}`);
1676
+ }
1677
+ return verdict;
1678
+ }
1679
+ case 'repo-map': {
1680
+ // AST-light workspace summary. Delegate
1681
+ // к the shared `runRepoMapCommand` so the slash + top-level
1682
+ // paths stay single-sourced. The rendered text lands on the
1683
+ // system pane via `appendSystemLine` (no fresh Ink mount) so
1684
+ // the listing flows into the conversation transcript like
1685
+ // any other command output.
1686
+ try {
1687
+ const { runRepoMapCommand } = await import('../../runtime/commands/repo-map.js');
1688
+ const lines = [];
1689
+ await runRepoMapCommand({
1690
+ cwd: process.cwd(),
1691
+ refresh: verdict.refresh,
1692
+ json: false,
1693
+ writeOutput: (_payload, text) => {
1694
+ for (const line of text.split('\n')) {
1695
+ const trimmed = line.replace(/\s+$/u, '');
1696
+ lines.push(trimmed);
1697
+ }
1698
+ },
1699
+ });
1700
+ if (lines.length === 0) {
1701
+ this.appendSystemLine('/repo-map: no output.');
1702
+ }
1703
+ else {
1704
+ for (const line of lines)
1705
+ this.appendSystemLine(line);
1706
+ }
1707
+ }
1708
+ catch (error) {
1709
+ const message = error instanceof Error ? error.message : String(error);
1710
+ this.appendSystemLine(`/repo-map failed: ${message}`);
1711
+ }
1712
+ return verdict;
1713
+ }
1714
+ case 'undo': {
1715
+ // final : graduated from stub. The runtime
1716
+ // command `runUndoCommand` already exists with full Aider walk-
1717
+ // back semantics — single-step revert of the most recent
1718
+ // successful `write` / `edit` / `multi_edit` tool result, with
1719
+ // an mtime+hash gate that refuses to overwrite uncommitted
1720
+ // operator work. We open a fresh PugiSession against the cwd
1721
+ // so the inverse-mutation audit lands on the same NDJSON
1722
+ // events stream the REPL writes to; dynamic-import keeps the
1723
+ // runner + git plumbing out of the REPL hot path.
1724
+ try {
1725
+ const [{ runUndoCommand }, { openSession }] = await Promise.all([
1726
+ import('../../runtime/commands/undo.js'),
1727
+ import('../session.js'),
1728
+ ]);
1729
+ const workspaceRoot = process.cwd();
1730
+ const session = openSession(workspaceRoot);
1731
+ this.appendSystemLine('Reverting last write...');
1732
+ await runUndoCommand([], {
1733
+ workspaceRoot,
1734
+ session,
1735
+ writeOutput: (_payload, text) => {
1736
+ const trimmed = text.replace(/\n+$/u, '');
1737
+ if (trimmed.length > 0)
1738
+ this.appendSystemLine(trimmed);
1739
+ },
1740
+ });
1741
+ }
1742
+ catch (error) {
1743
+ const message = error instanceof Error ? error.message : String(error);
1744
+ this.appendSystemLine(`/undo failed: ${message}`);
1745
+ }
1746
+ return verdict;
1747
+ }
1748
+ case 'redo': {
1749
+ // cleanup : counterpart к /undo. The runtime
1750
+ // command `runRedoCommand` consumes one entry from the LIFO
1751
+ // undo stack (most recent unconsumed `tool=undo` result), reads
1752
+ // the captured AFTER content from `.pugi/undo-blobs/`, and
1753
+ // re-applies the mutations under the same mtime+hash external-
1754
+ // modification gate the undo runner uses. Same dynamic-import
1755
+ // posture as /undo so the redo + blob-store + git plumbing
1756
+ // stays out of the REPL hot path.
1757
+ try {
1758
+ const [{ runRedoCommand }, { openSession }] = await Promise.all([
1759
+ import('../../runtime/commands/redo.js'),
1760
+ import('../session.js'),
1761
+ ]);
1762
+ const workspaceRoot = process.cwd();
1763
+ const session = openSession(workspaceRoot);
1764
+ this.appendSystemLine('Reapplying last undo...');
1765
+ await runRedoCommand([], {
1766
+ workspaceRoot,
1767
+ session,
1768
+ writeOutput: (_payload, text) => {
1769
+ const trimmed = text.replace(/\n+$/u, '');
1770
+ if (trimmed.length > 0)
1771
+ this.appendSystemLine(trimmed);
1772
+ },
1773
+ });
1774
+ }
1775
+ catch (error) {
1776
+ const message = error instanceof Error ? error.message : String(error);
1777
+ this.appendSystemLine(`/redo failed: ${message}`);
1778
+ }
1779
+ return verdict;
1780
+ }
1781
+ case 'plan-artifact': {
1782
+ // Pugi backlog : plan-as-FILE artifact surface.
1783
+ // Dynamic-import the core module so the REPL hot path stays free
1784
+ // of the artifact store + diff renderer until the operator
1785
+ // actually exercises a `/plan show|list|diff|prune` invocation.
1786
+ try {
1787
+ const { readPlan, listPlans, diffPlans, prunePlans, PlanNotFoundError, InvalidPlanIdError, } = await import('../plans/plan-artifact.js');
1788
+ const root = process.cwd();
1789
+ const sub = verdict.sub;
1790
+ if (sub.op === 'show') {
1791
+ try {
1792
+ const record = readPlan(sub.planId, { root });
1793
+ this.appendSystemLine(`plan ${record.frontmatter.planId} (task=${record.frontmatter.taskId}, created=${record.frontmatter.createdAt})`);
1794
+ if (record.frontmatter.supersededBy) {
1795
+ this.appendSystemLine(`superseded by ${record.frontmatter.supersededBy}`);
1796
+ }
1797
+ for (const line of record.body.split('\n')) {
1798
+ this.appendSystemLine(line);
1799
+ }
1800
+ }
1801
+ catch (error) {
1802
+ if (error instanceof PlanNotFoundError) {
1803
+ this.appendSystemLine(`/plan show: plan not found: ${sub.planId}`);
1804
+ }
1805
+ else if (error instanceof InvalidPlanIdError) {
1806
+ this.appendSystemLine(`/plan show: invalid plan-id: ${sub.planId}`);
1807
+ }
1808
+ else {
1809
+ throw error;
1810
+ }
1811
+ }
1812
+ }
1813
+ else if (sub.op === 'list') {
1814
+ const filter = sub.taskId ? { taskId: sub.taskId, root } : { root };
1815
+ const records = listPlans(filter);
1816
+ if (records.length === 0) {
1817
+ this.appendSystemLine('/plan list: no plans yet.');
1818
+ }
1819
+ else {
1820
+ this.appendSystemLine(`plan-id taskId createdAt supersededBy`);
1821
+ for (const rec of records) {
1822
+ const fm = rec.frontmatter;
1823
+ const supers = fm.supersededBy ?? '-';
1824
+ this.appendSystemLine(`${fm.planId} ${fm.taskId.padEnd(15)} ${fm.createdAt} ${supers}`);
1825
+ }
1826
+ }
1827
+ }
1828
+ else if (sub.op === 'diff') {
1829
+ try {
1830
+ const diff = diffPlans(sub.planId, sub.otherId, { root });
1831
+ for (const line of diff.split('\n')) {
1832
+ this.appendSystemLine(line);
1833
+ }
1834
+ }
1835
+ catch (error) {
1836
+ if (error instanceof PlanNotFoundError) {
1837
+ this.appendSystemLine(`/plan diff: plan not found`);
1838
+ }
1839
+ else if (error instanceof InvalidPlanIdError) {
1840
+ this.appendSystemLine(`/plan diff: invalid plan-id`);
1841
+ }
1842
+ else {
1843
+ throw error;
1844
+ }
1845
+ }
1846
+ }
1847
+ else {
1848
+ // prune
1849
+ const result = prunePlans(sub.maxAgeDays !== undefined
1850
+ ? { root, maxAgeDays: sub.maxAgeDays }
1851
+ : { root });
1852
+ this.appendSystemLine(`/plan prune: cleaned ${result.cleaned} plan${result.cleaned === 1 ? '' : 's'}.`);
1853
+ for (const id of result.removedIds) {
1854
+ this.appendSystemLine(` - ${id}`);
1855
+ }
1856
+ }
1857
+ }
1858
+ catch (error) {
1859
+ const message = error instanceof Error ? error.message : String(error);
1860
+ this.appendSystemLine(`/plan ${verdict.sub.op} failed: ${message}`);
1861
+ }
1862
+ return verdict;
1863
+ }
666
1864
  case 'stub': {
667
1865
  this.appendSystemLine(verdict.message);
668
1866
  return verdict;
669
1867
  }
670
1868
  }
671
1869
  }
1870
+ /**
1871
+ * drive the `/feedback` wizard from inside
1872
+ * the REPL. Mounts the Ink prompt, collects the draft, hands it to
1873
+ * `runFeedbackCommand` (which routes to submit-now or
1874
+ * queue-locally), then writes the operator-facing toast to the
1875
+ * conversation system pane.
1876
+ *
1877
+ * The session module owns the wiring (cwd, cliVersion, apiUrl,
1878
+ * apiKey, transcript provider) so the slash + top-level CLI paths
1879
+ * stay single-sourced through `runFeedbackCommand`.
1880
+ */
1881
+ async runFeedbackSlash() {
1882
+ const { renderFeedbackPrompt } = await import('../../tui/feedback-prompt.js');
1883
+ const { runFeedbackCommand, renderFeedbackToast } = await import('../../runtime/commands/feedback.js');
1884
+ const { submitFeedback, redactSessionContext } = await import('../feedback/submitter.js');
1885
+ const verdict = await renderFeedbackPrompt();
1886
+ if (verdict.cancelled || !verdict.draft) {
1887
+ this.appendSystemLine('Feedback cancelled. Nothing was sent.');
1888
+ return;
1889
+ }
1890
+ // Build a session-context provider that reads the LAST 5 turns
1891
+ // from the live transcript + applies the redactor. Only invoked
1892
+ // when the operator opted in on step 4.
1893
+ const sessionContextProvider = () => {
1894
+ const last5 = this.state.transcript
1895
+ .filter((row) => row.source !== 'system')
1896
+ .slice(-5)
1897
+ .map((row) => ({
1898
+ role: row.source === 'operator' ? 'user' : 'assistant',
1899
+ text: row.text,
1900
+ }));
1901
+ // The workspace context exposed to the session does not carry
1902
+ // a git branch field today, so we omit `gitBranch` here. When
1903
+ // `ReplWorkspaceContext` gains the field we can forward it via
1904
+ // an extra options entry without changing the redactor contract.
1905
+ return redactSessionContext(last5);
1906
+ };
1907
+ const result = await runFeedbackCommand({
1908
+ cwd: process.cwd(),
1909
+ cliVersion: this.options.cliVersion,
1910
+ submit: async (env) => submitFeedback(env, {
1911
+ apiUrl: this.options.apiUrl,
1912
+ apiKey: this.options.apiKey,
1913
+ }),
1914
+ draft: verdict.draft,
1915
+ sessionContext: sessionContextProvider,
1916
+ });
1917
+ this.appendSystemLine(renderFeedbackToast(result));
1918
+ }
1919
+ /**
1920
+ * drive the `/compact` flow from inside the
1921
+ * REPL. Reuses the standalone runner so the wire shape + reason
1922
+ * codes stay single-sourced. The result is echoed into the
1923
+ * transcript as a system line; on success the operator sees the
1924
+ * banner sentinel on next render.
1925
+ *
1926
+ * `trigger='manual'` for explicit `/compact` invocations;
1927
+ * `trigger='auto'` for the threshold gate. The runner records the
1928
+ * trigger in the marker payload so the banner can distinguish them.
1929
+ */
1930
+ async dispatchCompact(trigger, options = {}) {
1931
+ if (!this.store || !this.localSessionId) {
1932
+ this.appendSystemLine('Local session store is disabled — /compact is unavailable.');
1933
+ return;
1934
+ }
1935
+ try {
1936
+ const { runCompactCommand } = await import('../../runtime/commands/compact.js');
1937
+ const result = await runCompactCommand([], {
1938
+ workspaceRoot: process.cwd(),
1939
+ sessionId: this.localSessionId,
1940
+ store: this.store,
1941
+ trigger,
1942
+ force: options.force === true,
1943
+ writeOutput: (_payload, text) => {
1944
+ if (text.length > 0)
1945
+ this.appendSystemLine(text);
1946
+ },
1947
+ });
1948
+ if (result.status === 'compacted') {
1949
+ // L29 : emit a structured `compact-boundary` row so
1950
+ // the conversation pane routes the marker through the dedicated
1951
+ // `<CompactBanner />` Ink component (gray, terminal-width
1952
+ // separator) instead of leaking the raw text into a `system`
1953
+ // row. The plain-text body is kept as a deterministic fallback
1954
+ // for non-Ink consumers (snapshot tests, JSON-mode exports).
1955
+ const turnsBefore = result.turnsBefore ?? 0;
1956
+ this.appendRow({
1957
+ source: 'compact-boundary',
1958
+ text: `─── context compacted (${turnsBefore} turns → 1 summary, ${trigger}) ───`,
1959
+ compaction: {
1960
+ turnsBefore,
1961
+ trigger,
1962
+ summaryTokenCount: result.tokensSummarised,
1963
+ // Fresh in-REPL compaction lands at the head of the
1964
+ // transcript — no turns have followed it yet.
1965
+ turnsAgo: 0,
1966
+ },
1967
+ });
1968
+ }
1969
+ }
1970
+ catch (error) {
1971
+ const message = error instanceof Error ? error.message : String(error);
1972
+ this.appendSystemLine(`/compact failed: ${message}`);
1973
+ }
1974
+ }
672
1975
  /**
673
1976
  * In-REPL `/privacy` - alpha 6.13. Prints the full 3-mode contract
674
1977
  * doc + the current mode banner inline. The current mode is fetched
@@ -679,7 +1982,7 @@ export class ReplSession {
679
1982
  */
680
1983
  async dispatchPrivacy() {
681
1984
  const { renderPrivacyContractDoc } = await import('./privacy-banner.js');
682
- // Triple-review P1 fix (2026-05-25): use the bootstrap-cached mode
1985
+ // Triple-review P1 fix : use the bootstrap-cached mode
683
1986
  // so the operator sees the LIVE current mode in the banner header
684
1987
  // instead of "(unknown)". The fetch happens once on session start;
685
1988
  // if it failed (offline / unauth) the cache stays null and the
@@ -689,7 +1992,7 @@ export class ReplSession {
689
1992
  this.appendSystemLine(doc);
690
1993
  }
691
1994
  /**
692
- * In-REPL `/resume` - α6.4. Lists the 10 most recent sessions from
1995
+ * In-REPL `/resume` - . Lists the 10 most recent sessions from
693
1996
  * the local SessionStore and prints them as a numbered system menu.
694
1997
  * The Ink-side picker UI is deferred to the next sprint; today the
695
1998
  * operator gets a deterministic list + the exact command to relaunch
@@ -720,7 +2023,7 @@ export class ReplSession {
720
2023
  const title = (row.title ?? '(untitled)').slice(0, 64);
721
2024
  const idShort = row.id.slice(0, 13);
722
2025
  const branch = row.branch ?? 'no-branch';
723
- this.appendSystemLine(` ${(i + 1).toString().padStart(2)}. ${idShort} ${branch.padEnd(16)} ${title}`);
2026
+ this.appendSystemLine(` ${(i + 1).toString().padStart(2)}. ${idShort} ${branch.padEnd(16)} ${title}`);
724
2027
  }
725
2028
  this.appendSystemLine('Pick one with: pugi resume <id> (paste the 13-char id from above).');
726
2029
  }
@@ -732,7 +2035,7 @@ export class ReplSession {
732
2035
  clearTranscript() {
733
2036
  this.patch({ transcript: [] });
734
2037
  }
735
- /* ------------- α6.3 office-hours surface -------------- */
2038
+ /* ------------- office-hours surface -------------- */
736
2039
  /**
737
2040
  * Surface an `<pugi-ask>` modal manually. Returned promise resolves
738
2041
  * with the operator's verdict - used by the `pugi ask "<q>"` shell
@@ -781,7 +2084,7 @@ export class ReplSession {
781
2084
  * came from a persona stream, cancel ALSO dispatches a literal
782
2085
  * `[ASK-RESPONSE:cancelled]` to admin-api so the persona observes the
783
2086
  * cancellation rather than hanging indefinitely on the missing
784
- * follow-up. The matching documentation in the Mira system prompt
2087
+ * follow-up. The matching documentation in the Pugi system prompt
785
2088
  * teaches the persona to acknowledge cancellation and offer a
786
2089
  * different path. Local-origin modals (synthesised via `/ask`) skip
787
2090
  * the dispatch entirely - the persona never saw the question.
@@ -812,7 +2115,7 @@ export class ReplSession {
812
2115
  // Surface the operator's choice as a transcript row so the
813
2116
  // conversation reads linearly. The label of the chosen option
814
2117
  // (or the literal custom input) is more readable than the bare
815
- // value - Codex CLI's "you chose: Vercel" pattern.
2118
+ // value - peer CLI's "you chose: Vercel" pattern.
816
2119
  const humanLabel = humanLabelForVerdict(tag, sanitisedVerdict);
817
2120
  this.appendOperatorLine(humanLabel);
818
2121
  // Local-origin modals (operator typed `/ask`) never need an
@@ -904,22 +2207,87 @@ export class ReplSession {
904
2207
  try {
905
2208
  const registry = getJobRegistry();
906
2209
  const entries = await registry.list();
907
- if (entries.length === 0) {
2210
+ // cleanup : also scan `.pugi/agent-progress/*.json`
2211
+ // so long-running external agents (the JSON pattern from
2212
+ // `feedback_agent_progress_tracking_pattern.md`) show up next к
2213
+ // background-bash entries. The two surfaces are orthogonal — bash
2214
+ // jobs come from the in-process registry, agent-progress comes from
2215
+ // sidecar JSON written by any agent (Pugi-spawned or external) — so
2216
+ // we render both, sorted with running first.
2217
+ const agentProgressRows = await this.collectAgentProgressRows();
2218
+ if (entries.length === 0 && agentProgressRows.length === 0) {
908
2219
  this.appendSystemLine('No background jobs tracked.');
909
2220
  return;
910
2221
  }
911
- this.appendSystemLine(`Background jobs (${entries.length}):`);
912
- for (const entry of entries) {
913
- const id = entry.id.replace(/^pj-/, '').slice(0, 8);
914
- const status = entry.status;
915
- const cmd = entry.command.length > 48 ? `${entry.command.slice(0, 47)}…` : entry.command;
916
- this.appendSystemLine(` ${id} ${status.padEnd(10)} ${cmd}`);
2222
+ if (entries.length > 0) {
2223
+ this.appendSystemLine(`Background jobs (${entries.length}):`);
2224
+ for (const entry of entries) {
2225
+ const id = entry.id.replace(/^pj-/, '').slice(0, 8);
2226
+ const status = entry.status;
2227
+ const cmd = entry.command.length > 48 ? `${entry.command.slice(0, 47)}…` : entry.command;
2228
+ this.appendSystemLine(` ${id} ${status.padEnd(10)} ${cmd}`);
2229
+ }
2230
+ }
2231
+ if (agentProgressRows.length > 0) {
2232
+ this.appendSystemLine(`Agent progress (${agentProgressRows.length}):`);
2233
+ for (const row of agentProgressRows) {
2234
+ this.appendSystemLine(` ${row}`);
2235
+ }
2236
+ this.appendSystemLine('Tip: run `pugi jobs --watch` for the live Ink TUI.');
917
2237
  }
918
2238
  }
919
2239
  catch (error) {
920
2240
  this.appendSystemLine(`/jobs failed: ${this.errorMessage(error)}`);
921
2241
  }
922
2242
  }
2243
+ /**
2244
+ * cleanup : scan `.pugi/agent-progress/*.json`
2245
+ * for in-flight long-running agent tasks and emit a one-line per
2246
+ * agent for the `/jobs` snapshot. Sorting matches the live TUI's
2247
+ * `sortProgressEntries` (running first, then by lastUpdate desc).
2248
+ *
2249
+ * Best-effort: a missing dir, malformed JSON, or bad permissions
2250
+ * yields an empty list and a swallowed error — the in-process
2251
+ * registry view is the older well-tested surface and must never be
2252
+ * gated behind a sidecar dir's health.
2253
+ */
2254
+ async collectAgentProgressRows() {
2255
+ try {
2256
+ const [{ resolveProgressDir }, { readProgressFile, sortProgressEntries }, fs, path] = await Promise.all([
2257
+ import('../agent-progress/writer.js'),
2258
+ import('../../commands/jobs-watch.js'),
2259
+ import('node:fs'),
2260
+ import('node:path'),
2261
+ ]);
2262
+ const dir = resolveProgressDir();
2263
+ if (!fs.existsSync(dir))
2264
+ return [];
2265
+ const files = fs
2266
+ .readdirSync(dir)
2267
+ .filter((f) => f.endsWith('.json'))
2268
+ .map((f) => path.join(dir, f));
2269
+ const progress = files
2270
+ .map((p) => readProgressFile(p))
2271
+ .filter((p) => p !== undefined);
2272
+ const sorted = sortProgressEntries(progress);
2273
+ return sorted.map((p) => {
2274
+ const id = p.agentId.length > 24 ? `${p.agentId.slice(0, 23)}…` : p.agentId;
2275
+ const pct = `${String(Math.round(p.percentComplete)).padStart(3, ' ')}%`;
2276
+ const elapsedSec = Math.max(0, Math.floor(p.elapsedMs / 1000));
2277
+ const elapsed = elapsedSec >= 60
2278
+ ? `${Math.floor(elapsedSec / 60)}m${String(elapsedSec % 60).padStart(2, '0')}s`
2279
+ : `${elapsedSec}s`;
2280
+ const status = p.status.padEnd(9, ' ');
2281
+ const step = p.stepDescription.length > 36
2282
+ ? `${p.stepDescription.slice(0, 35)}…`
2283
+ : p.stepDescription;
2284
+ return `${id.padEnd(24, ' ')} ${status} ${pct} ${elapsed.padStart(6, ' ')} ${step}`;
2285
+ });
2286
+ }
2287
+ catch {
2288
+ return [];
2289
+ }
2290
+ }
923
2291
  dispatchDiff() {
924
2292
  try {
925
2293
  const artifactsRoot = resolvePath(process.cwd(), '.pugi', 'artifacts');
@@ -935,7 +2303,7 @@ export class ReplSession {
935
2303
  const candidate = resolvePath(artifactsRoot, name, 'diff.patch');
936
2304
  if (existsSync(candidate)) {
937
2305
  const size = statSync(candidate).size;
938
- diffs.push(` ${name}/diff.patch (${size} bytes)`);
2306
+ diffs.push(` ${name}/diff.patch (${size} bytes)`);
939
2307
  }
940
2308
  }
941
2309
  if (diffs.length === 0) {
@@ -950,38 +2318,233 @@ export class ReplSession {
950
2318
  this.appendSystemLine(`/diff failed: ${this.errorMessage(error)}`);
951
2319
  }
952
2320
  }
953
- dispatchCost() {
954
- const { tokensDownstreamTotal, agents } = this.state;
2321
+ async dispatchCost() {
2322
+ // cost-meter sprint full breakdown matching the TUI status row
2323
+ // footer. The session totals line mirrors the footer format
2324
+ // (`↑ <in> ↓ <out> · $X.XX · <elapsed>`) so the operator scans the
2325
+ // same numbers in two places. Per-turn list shows the last 5 turns
2326
+ // oldest → newest; an empty list renders one system line so the
2327
+ // operator knows the surface is wired (`No completed turns yet.`).
2328
+ //
2329
+ // L19 — after the in-memory recap, also render the
2330
+ // persisted per-model table from `.pugi/cost.json`. That surface
2331
+ // survives a REPL restart and answers the "what did I spend on
2332
+ // claude-opus vs qwen this week?" question the in-memory recap can
2333
+ // not. Errors loading the file collapse to a single warning line so
2334
+ // the in-memory recap (the older, well-tested surface) is never
2335
+ // gated behind a fresh dependency.
2336
+ const { sessionTokensIn, sessionTokensOut, sessionCostUsd, sessionStartedAtEpochMs, recentTurns, agents, } = this.state;
955
2337
  const active = agents.filter((a) => a.status === 'queued' || a.status === 'thinking').length;
956
- const lineTokens = `Tokens this session: ${tokensDownstreamTotal.toLocaleString()} (in+out).`;
957
- const lineAgents = `Active dispatches: ${active} of cap.`;
958
- this.appendSystemLine(lineTokens);
959
- this.appendSystemLine(lineAgents);
960
- this.appendSystemLine('Full per-persona budget breakdown lands in α6.5.');
961
- }
962
- dispatchStatus() {
963
- const sessionId = this.state.sessionId ?? '(unbound)';
964
- const reach = this.state.connection;
965
- this.appendSystemLine(`Backend: ${this.options.apiUrl} (${reach}).`);
966
- this.appendSystemLine(`Session: ${sessionId}.`);
967
- this.appendSystemLine(`Workspace: ${this.state.workspaceLabel}.`);
968
- this.appendSystemLine(`CLI: pugi ${this.state.cliVersion}.`);
969
- }
970
- /**
971
- * α6.5 `/context` slash handler. Surfaces the three-tier context
2338
+ const elapsedMs = Math.max(0, this.now() - sessionStartedAtEpochMs);
2339
+ const elapsedLabel = formatElapsedShort(elapsedMs);
2340
+ this.appendSystemLine(`Session: ↑ ${formatTokens(sessionTokensIn)} ↓ ${formatTokens(sessionTokensOut)} · ${formatCostUsd(sessionCostUsd)} · ${elapsedLabel}`);
2341
+ this.appendSystemLine(`Active dispatches: ${active} of cap.`);
2342
+ if (recentTurns.length === 0) {
2343
+ this.appendSystemLine('No completed turns yet — brief the workforce to charge the meter.');
2344
+ }
2345
+ else {
2346
+ this.appendSystemLine(`Recent turns (last ${recentTurns.length}):`);
2347
+ for (let i = 0; i < recentTurns.length; i += 1) {
2348
+ const turn = recentTurns[i];
2349
+ const idx = (i + 1).toString().padStart(2, ' ');
2350
+ this.appendSystemLine(` ${idx}. ${formatTokens(turn.tokensIn)} ↓ ${formatTokens(turn.tokensOut)} · ${formatCostUsd(turn.costUsd)}`);
2351
+ }
2352
+ }
2353
+ // L19: append the persisted per-model table from .pugi/cost.json.
2354
+ try {
2355
+ const [{ createCostTracker }, { renderCostForSlash }] = await Promise.all([
2356
+ import('../cost/tracker.js'),
2357
+ import('../../runtime/commands/cost.js'),
2358
+ ]);
2359
+ const workspaceRoot = this.options.workspace?.workspaceCwd ?? process.cwd();
2360
+ const sessionId = this.state.sessionId ?? 'no-session';
2361
+ const tracker = createCostTracker({
2362
+ workspaceRoot,
2363
+ sessionIdProvider: () => sessionId,
2364
+ now: () => this.now(),
2365
+ });
2366
+ const current = tracker.current();
2367
+ if (current && Object.keys(current.models).length > 0) {
2368
+ this.appendSystemLine('');
2369
+ const { lines } = renderCostForSlash({
2370
+ tracker,
2371
+ allSessions: false,
2372
+ windowDays: 30,
2373
+ now: () => this.now(),
2374
+ });
2375
+ for (const line of lines)
2376
+ this.appendSystemLine(line);
2377
+ }
2378
+ }
2379
+ catch {
2380
+ // best-effort — the persisted view is additive; failure never
2381
+ // breaks the in-memory recap above
2382
+ }
2383
+ }
2384
+ /**
2385
+ * cost-meter sprint — `/quota` slash handler. Fetches the live
2386
+ * `/api/pugi/usage` snapshot and renders three lines: plan tier,
2387
+ * monthly window, and per-counter `used/cap (pct%)`. Failure modes
2388
+ * (offline, unauth, older admin-api) collapse to a single one-line
2389
+ * `Could not fetch quota…` system message so the surface never throws
2390
+ * from a keystroke handler.
2391
+ *
2392
+ * The fetch is best-effort with a 4s timeout — mirrors the `whoami`
2393
+ * pattern in `runtime/cli.ts` so the operator gets the same UX on the
2394
+ * REPL slash and the CLI command.
2395
+ */
2396
+ async dispatchQuota() {
2397
+ const controller = new AbortController();
2398
+ const timer = setTimeout(() => controller.abort(), 4000);
2399
+ try {
2400
+ const url = `${this.options.apiUrl.replace(/\/+$/, '')}/api/pugi/usage`;
2401
+ const res = await fetch(url, {
2402
+ method: 'GET',
2403
+ headers: {
2404
+ authorization: `Bearer ${this.options.apiKey}`,
2405
+ accept: 'application/json',
2406
+ },
2407
+ signal: controller.signal,
2408
+ });
2409
+ if (!res.ok) {
2410
+ this.appendSystemLine(`Could not fetch quota: HTTP ${res.status}.`);
2411
+ return;
2412
+ }
2413
+ const body = (await res.json());
2414
+ const tier = typeof body.tier === 'string' ? body.tier : '(unknown)';
2415
+ const tierLabel = QUOTA_TIER_LABELS[tier] ?? tier;
2416
+ const month = typeof body.billingMonth === 'string' ? body.billingMonth : '(unknown month)';
2417
+ const resetAt = typeof body.resetAt === 'string' ? body.resetAt : null;
2418
+ const resetLine = resetAt ? ` · resets ${formatResetWindow(resetAt, this.now())}` : '';
2419
+ this.appendSystemLine(`Plan: ${tierLabel} · ${month}${resetLine}`);
2420
+ const used = body.used ?? {};
2421
+ const caps = body.quotas ?? {};
2422
+ const counters = [
2423
+ ['sync', used.sync, caps.sync],
2424
+ ['review', used.review, caps.review],
2425
+ ['engine', used.engine, caps.engine],
2426
+ ];
2427
+ // cleanup : color-code each counter row by
2428
+ // utilisation. The thresholds match the upstream tool's tier-meter
2429
+ // convention so operators trained on that surface read the same
2430
+ // signal here. ANSI codes wrap the WHOLE row (not just the
2431
+ // percent) so the line wraps as one visual unit; the cost-quota
2432
+ // spec regex still matches because anchors are inside the
2433
+ // wrapped substring.
2434
+ for (const [name, value, cap] of counters) {
2435
+ const v = typeof value === 'number' ? value : 0;
2436
+ if (cap === null || cap === undefined) {
2437
+ // Unlimited counters never trip the gauge — leave them
2438
+ // uncolored so the eye does not register an alarm signal
2439
+ // where there is no cap к exhaust.
2440
+ this.appendSystemLine(` ${name.padEnd(7, ' ')} ${v.toLocaleString()} / unlimited`);
2441
+ }
2442
+ else {
2443
+ const pct = cap > 0 ? Math.round((v / cap) * 100) : 0;
2444
+ const row = ` ${name.padEnd(7, ' ')} ${v.toLocaleString()} / ${cap.toLocaleString()} (${pct}%)`;
2445
+ this.appendSystemLine(colorizeQuotaRow(row, pct));
2446
+ }
2447
+ }
2448
+ }
2449
+ catch (error) {
2450
+ const msg = error instanceof Error ? error.message : String(error);
2451
+ this.appendSystemLine(`Could not fetch quota: ${msg}.`);
2452
+ }
2453
+ finally {
2454
+ clearTimeout(timer);
2455
+ }
2456
+ }
2457
+ /**
2458
+ * In-REPL `/status` — . Surfaces the full
2459
+ * session snapshot (id + age, cwd, permission mode, CLI version,
2460
+ * tokens, dispatches, last cmd, compact boundaries, auth identity,
2461
+ * connection) by delegating к the same `runStatusCommand` the
2462
+ * top-level `pugi status` shell uses. Live REPL state (session
2463
+ * id, token totals, last operator command) flows in through the
2464
+ * context so the slash variant shows MORE than the shell path.
2465
+ *
2466
+ * The renderer routes к the system pane via `appendSystemLine`
2467
+ * so the snapshot lands as a single contiguous block в the
2468
+ * conversation transcript. Migrating к the Ink `<StatusTable>`
2469
+ * mounted directly в the REPL frame is a follow-up sprint —
2470
+ * keeping the line-buffered path here avoids cycling the
2471
+ * conversation pane's render model mid-.
2472
+ */
2473
+ async dispatchStatus() {
2474
+ try {
2475
+ const { runStatusCommand, defaultStatusHome } = await import('../../runtime/commands/status.js');
2476
+ // Find the most-recent operator transcript row + its timestamp
2477
+ // so the snapshot's `Last cmd` field has real content в REPL
2478
+ // mode. Walking от newest end is O(transcript) worst case but
2479
+ // bounded by MAX_TRANSCRIPT_ROWS so this stays cheap.
2480
+ let lastCommand = null;
2481
+ let lastCommandAtEpochMs = null;
2482
+ for (let i = this.state.transcript.length - 1; i >= 0; i -= 1) {
2483
+ const row = this.state.transcript[i];
2484
+ if (row.source === 'operator') {
2485
+ lastCommand = row.text;
2486
+ lastCommandAtEpochMs = row.timestampEpochMs;
2487
+ break;
2488
+ }
2489
+ }
2490
+ const liveTokens = this.state.sessionTokensIn + this.state.sessionTokensOut;
2491
+ const lines = [];
2492
+ await runStatusCommand({
2493
+ cwd: process.cwd(),
2494
+ home: defaultStatusHome(),
2495
+ env: process.env,
2496
+ json: false,
2497
+ liveSessionId: this.state.sessionId ?? null,
2498
+ sessionStartedAtEpochMs: this.state.sessionStartedAtEpochMs,
2499
+ liveTokensUsed: liveTokens >= 0 ? liveTokens : 0,
2500
+ lastCommand,
2501
+ lastCommandAtEpochMs,
2502
+ // Repl-mode context: the session knows both the live
2503
+ // transport URL and the operator's workspace label, so we
2504
+ // forward them as authoritative inputs к the snapshot.
2505
+ // The status snapshot used к infer these from the
2506
+ // credentials file, which was wrong in two cases:
2507
+ // (a) the operator was inside a REPL talking к Anvil dev
2508
+ // (port 4100) but credentials still pointed к
2509
+ // api.pugi.io — the `Backend` row mis-reported;
2510
+ // (b) `workspaceLabel` was никогда rendered at all.
2511
+ liveApiUrl: this.options.apiUrl,
2512
+ workspaceLabel: this.options.workspaceLabel,
2513
+ writeOutput: (_payload, text) => {
2514
+ for (const line of text.split('\n')) {
2515
+ const trimmed = line.replace(/\s+$/u, '');
2516
+ if (trimmed.length > 0)
2517
+ lines.push(trimmed);
2518
+ }
2519
+ },
2520
+ });
2521
+ if (lines.length === 0) {
2522
+ this.appendSystemLine('/status: no output.');
2523
+ return;
2524
+ }
2525
+ for (const line of lines)
2526
+ this.appendSystemLine(line);
2527
+ }
2528
+ catch (error) {
2529
+ const message = error instanceof Error ? error.message : String(error);
2530
+ this.appendSystemLine(`/status failed: ${message}`);
2531
+ }
2532
+ }
2533
+ /**
2534
+ * `/context` slash handler. Surfaces the three-tier context
972
2535
  * summary as a stack of system lines. Sections (in order):
973
2536
  *
974
- * 1. Tier 0 (repo skeleton) - size in bytes, branch, package
975
- * manager, languages. Skipped when no skeleton was injected
976
- * (REPL launched outside a workspace or with --no-context).
2537
+ * 1. Tier 0 (repo skeleton) - size in bytes, branch, package
2538
+ * manager, languages. Skipped when no skeleton was injected
2539
+ * (REPL launched outside a workspace or with --no-context).
977
2540
  *
978
- * 2. Tier 1 (working set) - `count / capacity` plus the total
979
- * size in bytes plus the oldest entry's age in seconds.
980
- * Always emits even when empty so the operator can confirm
981
- * the tier is wired.
2541
+ * 2. Tier 1 (working set) - `count / capacity` plus the total
2542
+ * size in bytes plus the oldest entry's age in seconds.
2543
+ * Always emits even when empty so the operator can confirm
2544
+ * the tier is wired.
982
2545
  *
983
- * 3. Tier 2 (RAG) - one-line heads-up that the Anvil-side
984
- * workspace lands in α6.5b.
2546
+ * 3. Tier 2 (RAG) - one-line heads-up that the Anvil-side
2547
+ * workspace lands in .
985
2548
  *
986
2549
  * The renderer never mutates state.
987
2550
  */
@@ -1010,10 +2573,10 @@ export class ReplSession {
1010
2573
  else {
1011
2574
  this.appendSystemLine('Tier 1 working set: not wired.');
1012
2575
  }
1013
- this.appendSystemLine('Tier 2 RAG: deferred to α6.5b (Anvil-side per-tenant workspace).');
2576
+ this.appendSystemLine('Tier 2 RAG: deferred to (Anvil-side per-tenant workspace).');
1014
2577
  }
1015
2578
  /**
1016
- * α6.5 chokidar batch handler. Forwards each event to the working
2579
+ * chokidar batch handler. Forwards each event to the working
1017
2580
  * set tracker (so `unlink` evicts and `add`/`change` bump the
1018
2581
  * recency) and emits at most one throttled system line per
1019
2582
  * `FILEWATCH_SYSTEM_LINE_GAP_MS` window.
@@ -1021,7 +2584,7 @@ export class ReplSession {
1021
2584
  * The transcript surface intentionally shows ONE filename + the
1022
2585
  * count of additional changes (`file changed: src/foo.ts (+3 more)`).
1023
2586
  * The full event list is preserved in the buffer for future
1024
- * `/context --files` deep-dive (not in α6.5 Phase 1).
2587
+ * `/context --files` deep-dive (not in Phase 1).
1025
2588
  */
1026
2589
  recordFilewatchBatch(batch) {
1027
2590
  // Hard-guard against post-close invocation. close() detaches the
@@ -1030,7 +2593,7 @@ export class ReplSession {
1030
2593
  // listener captured at the start of emit(). If the session closes
1031
2594
  // mid-emit, the handler can still fire on a dead session. Returning
1032
2595
  // early keeps the working set + transcript untouched.
1033
- // triple-review P1 (PR #380).
2596
+ // triple-review P1 (PR).
1034
2597
  if (this.closed)
1035
2598
  return;
1036
2599
  if (this.workingSet) {
@@ -1050,7 +2613,7 @@ export class ReplSession {
1050
2613
  // do not emit a system line. Cap the buffer at
1051
2614
  // PENDING_FILEWATCH_BATCH_CAP and drop the oldest on overflow so
1052
2615
  // a noisy filewatch source cannot drive unbounded memory growth
1053
- // across a long REPL session. triple-review P1 (PR #380).
2616
+ // across a long REPL session. triple-review P1 (PR).
1054
2617
  if (this.pendingFilewatchBatches.length >= PENDING_FILEWATCH_BATCH_CAP) {
1055
2618
  this.pendingFilewatchBatches.shift();
1056
2619
  if (!this.pendingFilewatchOverflowWarned) {
@@ -1078,14 +2641,14 @@ export class ReplSession {
1078
2641
  this.pendingFilewatchBatches = [];
1079
2642
  }
1080
2643
  /**
1081
- * α6.5 chokidar cap-exceeded handler. The watcher closes itself
2644
+ * chokidar cap-exceeded handler. The watcher closes itself
1082
2645
  * when it crosses the watched-paths cap; the session surfaces a
1083
2646
  * single system line so the operator knows live updates are off.
1084
2647
  * The conversation stays usable - we just lose the file-changed
1085
2648
  * badge for the rest of the session.
1086
2649
  */
1087
2650
  recordFilewatchCapExceeded(info) {
1088
- // Same post-close guard as recordFilewatchBatch. triple-review P1 (PR #380).
2651
+ // Same post-close guard as recordFilewatchBatch. triple-review P1 (PR).
1089
2652
  if (this.closed)
1090
2653
  return;
1091
2654
  this.appendSystemLine(`Filewatch off: ${info.watchedCount} watched paths exceeded cap (${info.cap}). Falling back to manual stat-on-read.`);
@@ -1093,7 +2656,7 @@ export class ReplSession {
1093
2656
  /**
1094
2657
  * Fetch one URL via the web_fetch tool and inject the resulting
1095
2658
  * Markdown into the transcript as an operator-attributed brief. The
1096
- * `<untrusted-content>` sentinel travels with the body so the Mira
2659
+ * `<untrusted-content>` sentinel travels with the body so the Pugi
1097
2660
  * system prompt can refuse to follow instructions inside it.
1098
2661
  *
1099
2662
  * Gating: the dispatcher reads PugiSettings from disk on every
@@ -1148,33 +2711,36 @@ export class ReplSession {
1148
2711
  this.appendSystemLine(capLine);
1149
2712
  }
1150
2713
  this.appendOperatorLine(brief);
1151
- this.patch({ briefStartedAtEpochMs: this.now() });
1152
- // α6.9 + R3 P1 (Codex triple-review 2026-05-25): supersede the
2714
+ // Reset `lastCompletedOutcome` so a fresh dispatch does not
2715
+ // inherit the prior turn's status-bar label (e.g. a stale
2716
+ // "replied" sticking around while the next dispatch is in flight).
2717
+ this.patch({ briefStartedAtEpochMs: this.now(), lastCompletedOutcome: null });
2718
+ // + R3 P1 (Codex triple-review): supersede the
1153
2719
  // prior dispatch when one is in flight. Steps in order:
1154
2720
  //
1155
- // 1. Abort the old CancellationToken so any in-flight tool
1156
- // holding `ctx.cancellation` sees `isAborted = true` and bails
1157
- // (the R2 fix; preserves the file-tools cancellation gate).
1158
- // 2. Drive the OLD FSM through `aborting -> aborted` terminal.
1159
- // This is load-bearing for the R3 race: a LATE event arriving
1160
- // on the old FSM (`agent.spawned`, `agent.step`, terminal,
1161
- // etc.) before the timestamp gate trips would otherwise still
1162
- // attempt to transition the new FSM. Driving the old FSM to a
1163
- // terminal state means the FSM check in
1164
- // `advanceFsmOnDispatchEnd` (`isTerminal`) short-circuits as a
1165
- // defense-in-depth layer.
1166
- // 3. `resetFsmToIdle()` mints a fresh FSM so the new dispatch
1167
- // starts clean. The FSM legal-transition matrix forbids
1168
- // `aborted -> awaiting_response`, so the reset is required.
1169
- // 4. Record `currentDispatchStartTime` BEFORE bumping
1170
- // `dispatchSeq` + clearing `taskDispatchSeq`. The timestamp
1171
- // gate in `handleServerEvent` checks
1172
- // `event.timestamp < currentDispatchStartTime` to drop late
1173
- // events from any superseded dispatch (including the late
1174
- // `agent.spawned` that the R2 seq gate could not catch).
1175
- // 5. Clear `taskDispatchSeq` so any stamp left over from the old
1176
- // dispatch cannot influence seq comparisons for the new turn.
1177
- // 6. Bump `dispatchSeq` and mint a fresh `CancellationToken`.
2721
+ // 1. Abort the old CancellationToken so any in-flight tool
2722
+ // holding `ctx.cancellation` sees `isAborted = true` and bails
2723
+ // (the R2 fix; preserves the file-tools cancellation gate).
2724
+ // 2. Drive the OLD FSM through `aborting -> aborted` terminal.
2725
+ // This is load-bearing for the R3 race: a LATE event arriving
2726
+ // on the old FSM (`agent.spawned`, `agent.step`, terminal,
2727
+ // etc.) before the timestamp gate trips would otherwise still
2728
+ // attempt to transition the new FSM. Driving the old FSM to a
2729
+ // terminal state means the FSM check in
2730
+ // `advanceFsmOnDispatchEnd` (`isTerminal`) short-circuits as a
2731
+ // defense-in-depth layer.
2732
+ // 3. `resetFsmToIdle()` mints a fresh FSM so the new dispatch
2733
+ // starts clean. The FSM legal-transition matrix forbids
2734
+ // `aborted -> awaiting_response`, so the reset is required.
2735
+ // 4. Record `currentDispatchStartTime` BEFORE bumping
2736
+ // `dispatchSeq` + clearing `taskDispatchSeq`. The timestamp
2737
+ // gate in `handleServerEvent` checks
2738
+ // `event.timestamp < currentDispatchStartTime` to drop late
2739
+ // events from any superseded dispatch (including the late
2740
+ // `agent.spawned` that the R2 seq gate could not catch).
2741
+ // 5. Clear `taskDispatchSeq` so any stamp left over from the old
2742
+ // dispatch cannot influence seq comparisons for the new turn.
2743
+ // 6. Bump `dispatchSeq` and mint a fresh `CancellationToken`.
1178
2744
  //
1179
2745
  // If no prior dispatch is in flight (clean idle / terminal entry),
1180
2746
  // the supersede block is skipped; we only reset the FSM if it sits
@@ -1226,7 +2792,7 @@ export class ReplSession {
1226
2792
  if (this.fsm.current === 'idle') {
1227
2793
  this.fsm.transition('awaiting_response', 'brief_dispatched');
1228
2794
  }
1229
- // α6.9: re-open the SSE stream if a prior `cancel()` tore it
2795
+ // : re-open the SSE stream if a prior `cancel()` tore it
1230
2796
  // down. Without this, the new brief would dispatch on admin-api
1231
2797
  // but the client would never observe `agent.spawned` / `step` /
1232
2798
  // `completed` — the operator would see a stalled status bar
@@ -1235,24 +2801,148 @@ export class ReplSession {
1235
2801
  if (!this.streamHandle && !this.closed) {
1236
2802
  this.openStream();
1237
2803
  }
2804
+ // PR A (PUGI-538-FU) — REPL becomes a first-class engine
2805
+ // path. When the CLI REPL has an engine bridge wired the brief is
2806
+ // dispatched DIRECTLY to the inproc engine adapter via
2807
+ // `runEngineBridge` instead of POSTing к admin-api `/sessions/:id/brief`.
2808
+ //
2809
+ // Why this matters:
2810
+ // - The server-side bypass () had to fabricate a synthetic
2811
+ // `<pugi-tool-route>` envelope SSE event so the CLI parser would
2812
+ // fire `runEngineBridge`. That worked but cost one full HTTP
2813
+ // round-trip + SSE latency per turn — and required `cliVersion`
2814
+ // to thread correctly через the session-create + header pipe
2815
+ // (which broke in production: CEO smoke 2026-06-05 showed
2816
+ // `envelope=delegate` instead of `tool-route` because the version
2817
+ // header was missing on his customer-installed beta.95 client,
2818
+ // so the bypass branch never matched и the coordinator chat
2819
+ // ceremony ran anyway).
2820
+ // - Going direct removes that whole class of bug: the CLI knows
2821
+ // it is the CLI, it has the engine bridge in hand, it skips the
2822
+ // server entirely и calls the adapter inproc. Matches Claude
2823
+ // Code / Codex / Aider tools-first loop architecture.
2824
+ //
2825
+ // Personas survive: `personaSlugFor('code')` returns 'dev' (Hiroshi),
2826
+ // the engine adapter renders the persona system prompt + memory
2827
+ // recall just like `pugi code` direct CLI. The synthetic agent-tree
2828
+ // node inside `runEngineBridge` carries `personaName` so the TUI
2829
+ // shows "Hiroshi" the same way it did before.
2830
+ //
2831
+ // Server-side bypass от remains в place для non-CLI surfaces
2832
+ // (cabinet BFF, telegram bot) — they have no engine adapter wired,
2833
+ // so the server still needs to fabricate the dispatch on their behalf.
2834
+ //
2835
+ // Env opt-out: `PUGI_REPL_DIRECT_ENGINE=0` falls back к the HTTP
2836
+ // path for regression debugging. cliVersion presence is the CLI
2837
+ // signal — REPL embedded inside cabinet BFF mounts without that
2838
+ // field и continues к hit the server route.
2839
+ const useDirectEngine = this.options.engineBridge !== undefined &&
2840
+ typeof this.options.cliVersion === 'string' &&
2841
+ this.options.cliVersion.length > 0 &&
2842
+ (this.options.env ?? process.env).PUGI_REPL_DIRECT_ENGINE !== '0';
1238
2843
  try {
1239
- await this.options.transport.postBrief({
1240
- apiUrl: this.options.apiUrl,
1241
- apiKey: this.options.apiKey,
1242
- sessionId,
1243
- brief,
1244
- });
2844
+ if (useDirectEngine) {
2845
+ const persona = personaSlugFor('code');
2846
+ // PR C (PUGI-538-FU): thread the recent conversation
2847
+ // into the engine prompt so multi-turn refinements work. Without
2848
+ // this, the engine sees only the literal current brief — a
2849
+ // follow-up like "react" after "сделай крестики нолики" arrives
2850
+ // as a bare "react" with no prior context, and the engine ships
2851
+ // arbitrary nonsense or asks again ("нет конкретного feature
2852
+ // request"). The CEO reproduction 2026-06-05 (Python tic-tac-toe
2853
+ // shipped когда customer wanted React браузер game, then engine
2854
+ // claimed "нет feature request" on the correction turn) is
2855
+ // exactly this gap.
2856
+ //
2857
+ // Display channels (system line, transcript) keep using the bare
2858
+ // `brief` for UX cleanliness; only the engine's task.prompt gets
2859
+ // the full conversational context via the new `enginePrompt`
2860
+ // field. Engine-bridge falls back to brief when enginePrompt is
2861
+ // undefined (server-emitted parser-built tags), preserving the
2862
+ // legacy behaviour for those surfaces.
2863
+ const enginePrompt = this.buildEnginePromptWithContext(brief);
2864
+ const tag = {
2865
+ command: 'code',
2866
+ brief,
2867
+ persona,
2868
+ // Direct-dispatch tags do not flow through the parser, so the
2869
+ // start/end byte offsets are inapplicable. Keep `signatureForToolRoute`
2870
+ // so the seen-tag rolling set still de-dupes a brief that the
2871
+ // operator submits twice in a row by accident.
2872
+ signature: signatureForToolRoute('code', persona, brief),
2873
+ start: 0,
2874
+ end: 0,
2875
+ ...(enginePrompt !== brief ? { enginePrompt } : {}),
2876
+ };
2877
+ await this.runEngineBridge(tag);
2878
+ }
2879
+ else {
2880
+ await this.options.transport.postBrief({
2881
+ apiUrl: this.options.apiUrl,
2882
+ apiKey: this.options.apiKey,
2883
+ sessionId,
2884
+ brief,
2885
+ });
2886
+ }
1245
2887
  }
1246
2888
  catch (error) {
1247
2889
  this.appendSystemLine(`Brief dispatch refused: ${this.errorMessage(error)}`);
1248
- // α6.9: a failed brief POST never produced a turn, so we move
2890
+ // : a failed brief POST never produced a turn, so we move
1249
2891
  // the FSM straight to `failed` so the bottom-bar surfaces the
1250
2892
  // outcome and the next brief can mint a fresh token.
1251
2893
  this.markDispatchFailed('post_brief_failed');
1252
2894
  }
1253
2895
  }
1254
2896
  /**
1255
- * α6.9: reset the FSM to `idle` after a terminal transition so the
2897
+ * PR C (PUGI-538-FU): build the engine prompt with recent
2898
+ * conversation context prepended. The current brief is preserved as
2899
+ * the explicit "Current request:" terminal so the engine knows what
2900
+ * the user is asking right now, while the prior turns give it the
2901
+ * stack/framework/format hints from earlier in the dialog.
2902
+ *
2903
+ * Returns `brief` unchanged when there is no prior conversation —
2904
+ * the empty preamble would just waste tokens.
2905
+ *
2906
+ * Window policy: last 4 conversational exchanges (operator + persona
2907
+ * pairs), text truncated к 400 chars per row. Drops the trailing
2908
+ * operator row if it matches `brief` (which has already been appended
2909
+ * to the transcript by `appendOperatorLine` at line 3429 above and
2910
+ * would otherwise duplicate inside the prompt).
2911
+ *
2912
+ * Doc strings stay в English per repo convention; the rendered
2913
+ * preamble uses neutral English labels ("User", "Pugi") so the
2914
+ * engine's model treats it as standard transcript context rather
2915
+ * than a localized field name.
2916
+ */
2917
+ buildEnginePromptWithContext(brief) {
2918
+ const MAX_TURNS = 4;
2919
+ const MAX_ROW_CHARS = 400;
2920
+ const conversational = this.state.transcript.filter((r) => r.source === 'operator' || r.source === 'persona');
2921
+ if (conversational.length === 0)
2922
+ return brief;
2923
+ // Take the last MAX_TURNS * 2 rows (each turn = 1 operator + 1 persona).
2924
+ const recent = conversational.slice(-(MAX_TURNS * 2));
2925
+ // Drop trailing operator row when it equals the brief we're about
2926
+ // to dispatch — the brief is the "current request" and already
2927
+ // landed in the transcript via `appendOperatorLine` earlier in
2928
+ // `dispatchBrief`. Including it twice would confuse the engine.
2929
+ const lastRow = recent[recent.length - 1];
2930
+ const trimmed = lastRow && lastRow.source === 'operator' && lastRow.text === brief
2931
+ ? recent.slice(0, -1)
2932
+ : recent;
2933
+ if (trimmed.length === 0)
2934
+ return brief;
2935
+ const lines = trimmed.map((r) => {
2936
+ const role = r.source === 'operator' ? 'User' : 'Pugi';
2937
+ const truncated = r.text.length > MAX_ROW_CHARS
2938
+ ? r.text.slice(0, MAX_ROW_CHARS) + '...'
2939
+ : r.text;
2940
+ return `- ${role}: ${truncated}`;
2941
+ });
2942
+ return `Recent conversation:\n${lines.join('\n')}\n\nCurrent request: ${brief}`;
2943
+ }
2944
+ /**
2945
+ * : reset the FSM to `idle` after a terminal transition so the
1256
2946
  * next brief can start. The FSM does not allow direct
1257
2947
  * `completed -> awaiting_response`, so we mint a fresh FSM by
1258
2948
  * overwriting the field. Listeners on the old FSM are dropped (they
@@ -1281,7 +2971,7 @@ export class ReplSession {
1281
2971
  this.patch({ dispatchState: 'idle', dispatchToolLabel: null });
1282
2972
  }
1283
2973
  /**
1284
- * α6.9: short-circuit the FSM to `failed` on a non-recoverable
2974
+ * : short-circuit the FSM to `failed` on a non-recoverable
1285
2975
  * dispatch error (network refusal, malformed event, etc). Idempotent
1286
2976
  * — a second call from a terminal state is a no-op.
1287
2977
  */
@@ -1297,7 +2987,7 @@ export class ReplSession {
1297
2987
  if (this.fsm.current === 'aborting')
1298
2988
  return;
1299
2989
  this.fsm.transition('failed', reason);
1300
- // α6.9 P1 fix (Claude triple-review): postBrief threw between
2990
+ // P1 fix (Claude triple-review): postBrief threw between
1301
2991
  // openStream() and dispatch registration server-side. The local
1302
2992
  // SSE handle is open but listening for events under a dispatchId
1303
2993
  // the admin-api never created. If we leave it open, any inbound
@@ -1306,7 +2996,7 @@ export class ReplSession {
1306
2996
  // IllegalDispatchTransitionError. Tear down so the next brief
1307
2997
  // re-opens cleanly via dispatchBrief's openStream() gate.
1308
2998
  //
1309
- // R2 P2 fix (Claude triple-review 2026-05-25): tear down the
2999
+ // R2 P2 fix (Claude triple-review): tear down the
1310
3000
  // stream BEFORE nulling the token. Same ordering contract as
1311
3001
  // `cancel()`: any onAbort listener fired during teardown should
1312
3002
  // observe the (now-aborted) token via getCurrentDispatchToken()
@@ -1364,7 +3054,7 @@ export class ReplSession {
1364
3054
  onError: (error) => {
1365
3055
  if (this.closed)
1366
3056
  return;
1367
- // α6.14.2 wave 5: when admin-api restarts it drops the in-memory
3057
+ // wave 5: when admin-api restarts it drops the in-memory
1368
3058
  // session store, so subscribe returns HTTP 404 forever on the
1369
3059
  // saved sessionId. Detect that case and mint a fresh server
1370
3060
  // session silently rather than spamming the operator with
@@ -1394,7 +3084,7 @@ export class ReplSession {
1394
3084
  void this.recreateSessionSilently();
1395
3085
  return;
1396
3086
  }
1397
- // α6.14.4 CEO dogfood 2026-05-25 (parity with Claude Code):
3087
+ // CEO dogfood (parity with the upstream tool):
1398
3088
  // collapse the repeated "Stream interrupted (fetch failed).
1399
3089
  // Reconnecting." spam. The status bar already shows
1400
3090
  // connection='reconnecting' AND the attempt counter; pushing
@@ -1419,7 +3109,7 @@ export class ReplSession {
1419
3109
  * `Error("HTTP 404 on SSE stream")`. We pattern-match on the status
1420
3110
  * 404 so a different transport (e.g. a test fake or a future polling
1421
3111
  * fallback) can surface the same intent with the same shape.
1422
- * (α6.14.2 wave 5.)
3112
+ *
1423
3113
  */
1424
3114
  isSessionNotFoundError(error) {
1425
3115
  const msg = this.errorMessage(error);
@@ -1432,7 +3122,7 @@ export class ReplSession {
1432
3122
  * a permanently down admin-api fails loud after a few seconds of
1433
3123
  * trying. Logged once per attempt at debug level (we surface a
1434
3124
  * single visible line on first auto-recreate so the operator knows
1435
- * what happened, then stay quiet). (α6.14.2 wave 5.)
3125
+ * what happened, then stay quiet).
1436
3126
  */
1437
3127
  async recreateSessionSilently() {
1438
3128
  if (this.closed)
@@ -1475,6 +3165,7 @@ export class ReplSession {
1475
3165
  apiUrl: this.options.apiUrl,
1476
3166
  apiKey: this.options.apiKey,
1477
3167
  workspace: this.options.workspace,
3168
+ cyberZoo: this.options.cyberZoo,
1478
3169
  });
1479
3170
  this.patch({ sessionId, connection: 'connecting' });
1480
3171
  this.openStream();
@@ -1506,7 +3197,7 @@ export class ReplSession {
1506
3197
  }
1507
3198
  /* ------------- event reducer -------------- */
1508
3199
  handleServerEvent(event) {
1509
- // R3 P1 fix (Codex triple-review 2026-05-25): wall-clock gate that
3200
+ // R3 P1 fix (Codex triple-review): wall-clock gate that
1510
3201
  // drops events from a SUPERSEDED dispatch. The R2 seq gate alone
1511
3202
  // could not catch a LATE `agent.spawned` for an old taskId arriving
1512
3203
  // AFTER `dispatchBrief` already bumped `dispatchSeq`. The late
@@ -1534,16 +3225,16 @@ export class ReplSession {
1534
3225
  switch (event.type) {
1535
3226
  case 'agent.spawned': {
1536
3227
  const persona = safePersonaName(event.role);
1537
- // Wave 4 fix 2026-05-25: the roster collapses to one row per
1538
- // persona slug. The α5.7 reducer pushed a fresh row on every
3228
+ // fix: the roster collapses to one row per
3229
+ // persona slug. The reducer pushed a fresh row on every
1539
3230
  // spawn, so after three turns the bottom panel stacked
1540
3231
  // "Pugi orchestrator shipped" three times. The new contract:
1541
- // - If a row already exists for this personaSlug, REUSE it.
1542
- // Replace its taskId, reset status to 'queued', clear the
1543
- // detail line, restart the duration clock, zero the token
1544
- // counters. The persona name + slug + role stay stable
1545
- // (they are the row identity).
1546
- // - If no row exists yet, push a new one.
3232
+ // - If a row already exists for this personaSlug, REUSE it.
3233
+ // Replace its taskId, reset status to 'queued', clear the
3234
+ // detail line, restart the duration clock, zero the token
3235
+ // counters. The persona name + slug + role stay stable
3236
+ // (they are the row identity).
3237
+ // - If no row exists yet, push a new one.
1547
3238
  // Per-task lifecycle (step/tokens/completed/blocked/failed) is
1548
3239
  // keyed off `taskId` everywhere, so the reused row still folds
1549
3240
  // the latest task's events correctly.
@@ -1567,7 +3258,7 @@ export class ReplSession {
1567
3258
  else {
1568
3259
  this.patch({ agents: [node, ...this.state.agents] });
1569
3260
  }
1570
- // R2 P1 fix (Codex triple-review 2026-05-25): stamp the live
3261
+ // R2 P1 fix (Codex triple-review): stamp the live
1571
3262
  // dispatch sequence onto this taskId so terminal handlers can
1572
3263
  // tell apart a "current dispatch" event from a "superseded
1573
3264
  // dispatch" event. See `dispatchSeq` + `taskDispatchSeq`
@@ -1579,7 +3270,7 @@ export class ReplSession {
1579
3270
  // double-print. `void persona` keeps the resolved name in scope
1580
3271
  // for the agent tree node above without leaking it into the
1581
3272
  // transcript body.
1582
- // α6.14.3 CEO dogfood 2026-05-25: drop the "dispatched (X)"
3273
+ // CEO dogfood: drop the "dispatched (X)"
1583
3274
  // transcript echo. The agent tree pane already shows the
1584
3275
  // spawned state; printing it as a persona row is pure noise
1585
3276
  // between the operator's brief and the persona's real reply.
@@ -1587,7 +3278,7 @@ export class ReplSession {
1587
3278
  return;
1588
3279
  }
1589
3280
  case 'agent.step': {
1590
- // α6.3 office-hours: scan the running buffer for `<pugi-ask>` /
3281
+ // office-hours: scan the running buffer for `<pugi-ask>` /
1591
3282
  // `<pugi-plan-review>` envelopes BEFORE we cache the detail.
1592
3283
  // The parser returns the cleaned remainder with the raw XML
1593
3284
  // stripped, so the operator never sees the envelope as prose.
@@ -1600,7 +3291,7 @@ export class ReplSession {
1600
3291
  if (sanitised && sanitised.trim().length > 0) {
1601
3292
  this.lastStepDetail.set(event.taskId, sanitised);
1602
3293
  }
1603
- // α6.12: synthesise a tool call entry when the step detail
3294
+ // : synthesise a tool call entry when the step detail
1604
3295
  // matches a tool-invocation grammar. The pattern is generous
1605
3296
  // (Read(path) / Edit(path:lines) / Bash(cmd) / Grep(pat) /
1606
3297
  // Glob(pat) / WebFetch(url)) so the pane has rows to render
@@ -1615,7 +3306,7 @@ export class ReplSession {
1615
3306
  });
1616
3307
  if (synthesised) {
1617
3308
  this.appendToolCall(synthesised);
1618
- // α6.9: a fresh tool call moves the FSM to `tool_running`
3309
+ // : a fresh tool call moves the FSM to `tool_running`
1619
3310
  // when the dispatch is still active. The status-bar surface
1620
3311
  // also gets a short label (`tool: read`, `tool: bash`, etc).
1621
3312
  // Aborting / terminal states are not allowed to transition
@@ -1631,8 +3322,22 @@ export class ReplSession {
1631
3322
  }
1632
3323
  case 'agent.tokens': {
1633
3324
  const delta = event.tokensIn + event.tokensOut;
3325
+ // cost-meter sprint — bind a client-side USD figure to this
3326
+ // frame. The model slug rides on the event (optional for back-
3327
+ // compat); the price ladder in `model-pricing.ts` falls back to
3328
+ // a Sonnet-tier rate when the slug is missing, so the meter is
3329
+ // always populated. Negative / NaN values are clamped to zero
3330
+ // inside `computeCostUsd` so a buggy upstream never credits the
3331
+ // meter.
3332
+ const deltaCostUsd = computeCostUsd(event.tokensIn, event.tokensOut, event.model);
1634
3333
  this.patch({
1635
3334
  tokensDownstreamTotal: this.state.tokensDownstreamTotal + delta,
3335
+ sessionTokensIn: this.state.sessionTokensIn + event.tokensIn,
3336
+ sessionTokensOut: this.state.sessionTokensOut + event.tokensOut,
3337
+ sessionCostUsd: this.state.sessionCostUsd + deltaCostUsd,
3338
+ turnTokensIn: this.state.turnTokensIn + event.tokensIn,
3339
+ turnTokensOut: this.state.turnTokensOut + event.tokensOut,
3340
+ turnCostUsd: this.state.turnCostUsd + deltaCostUsd,
1636
3341
  agents: this.state.agents.map((a) => a.taskId === event.taskId
1637
3342
  ? {
1638
3343
  ...a,
@@ -1652,17 +3357,49 @@ export class ReplSession {
1652
3357
  }
1653
3358
  this.askBuffer.delete(event.taskId);
1654
3359
  this.askBufferPending.delete(event.taskId);
3360
+ // Honour the work-done signal from admin-api.
3361
+ // `outcome === 'replied'` means the turn was a pure text reply
3362
+ // with no delegate XML and no tool call — render it as
3363
+ // "replied" so the operator can tell the difference between
3364
+ // "the orchestrator just talked" and "real work shipped".
3365
+ // Older servers omit the field; default to 'shipped' so the
3366
+ // existing wire stays back-compat.
3367
+ const completedStatus = event.outcome === 'replied' ? 'replied' : 'shipped';
1655
3368
  this.patch({
1656
3369
  agents: this.state.agents.map((a) => a.taskId === event.taskId
1657
- ? { ...a, status: 'shipped', detail: 'shipped' }
3370
+ ? { ...a, status: completedStatus, detail: completedStatus }
1658
3371
  : a),
3372
+ // Mirror the outcome to top-level state so the status-bar
3373
+ // can render `replied` instead of the legacy `shipped`
3374
+ // label when the FSM lands in `completed`. Without this
3375
+ // the bottom-bar would still say "shipped" while the
3376
+ // agent-tree said "replied", restoring the same
3377
+ // contradiction this PR is fixing (Codex triple-review P2).
3378
+ //
3379
+ // r2: gate on the same stale-dispatch check that
3380
+ // advanceFsmOnDispatchEnd applies. If this completion
3381
+ // belongs to a SUPERSEDED dispatch (a newer dispatchBrief
3382
+ // already bumped dispatchSeq before this late terminal
3383
+ // arrived), don't let the status-bar label flip to the
3384
+ // stale outcome — the current turn is the live one.
3385
+ // The agent-tree row patch above is still safe because
3386
+ // it only updates the row keyed by taskId.
3387
+ ...(this.isStaleTaskEvent(event.taskId)
3388
+ ? {}
3389
+ : { lastCompletedOutcome: completedStatus }),
1659
3390
  });
1660
- // α6.9: transition the FSM to `completed` when no other
3391
+ // : transition the FSM to `completed` when no other
1661
3392
  // dispatch is still in flight. The check uses the agents list
1662
3393
  // POST-patch so any sibling task in `queued` / `thinking` keeps
1663
3394
  // the dispatch alive; the FSM only goes terminal when the last
1664
3395
  // agent ships.
1665
3396
  this.advanceFsmOnDispatchEnd('completed', 'agent_completed', event.taskId);
3397
+ // cost-meter sprint — flush the per-turn delta when the
3398
+ // LAST agent settles. Decoupled from the FSM gate so a test
3399
+ // fixture (or a single-agent dispatch that never reached
3400
+ // `awaiting_response` — happens on instant SSE replay) still
3401
+ // gets the row written into recentTurns + lastTurnDelta.
3402
+ this.maybeFlushTurnOnAgentSettle(event.taskId);
1666
3403
  if (target) {
1667
3404
  // If the persona actually produced a reply via incremental
1668
3405
  // agent.step events, render that reply in the transcript so
@@ -1675,16 +3412,16 @@ export class ReplSession {
1675
3412
  if (finalDetail
1676
3413
  && finalDetail !== 'queued for dispatch'
1677
3414
  && finalDetail.trim().length > 4) {
1678
- // α6.12: ship the WHOLE body as one transcript row when the
3415
+ // : ship the WHOLE body as one transcript row when the
1679
3416
  // reply contains ANY Markdown structure (code fence, bullet
1680
3417
  // list, numbered list, headings). The conversation pane
1681
3418
  // routes it through Markdown renderer в one pass, preserving
1682
3419
  // grouped bullets + heading hierarchy. Plain prose still
1683
3420
  // splits per line so word-wrap stays correct.
1684
3421
  //
1685
- // Claude triple-review P1 (PR #369): the prior `includes('```')`
3422
+ // Claude triple-review P1 (PR): the prior `includes('```')`
1686
3423
  // gate only caught fences - multi-line bullets fragmented
1687
- // per row showed as `▸ Mira • read PUGI.md / ▸ Mira • patched
3424
+ // per row showed as `▸ Pugi • read PUGI.md / ▸ Pugi • patched
1688
3425
  // bug / ...` instead of a single grouped bullet block.
1689
3426
  if (looksLikeMarkdown(finalDetail)) {
1690
3427
  this.appendPersonaLine(target.personaSlug, finalDetail);
@@ -1699,12 +3436,26 @@ export class ReplSession {
1699
3436
  }
1700
3437
  }
1701
3438
  else {
1702
- // α6.14.3 CEO dogfood 2026-05-25: drop the literal
3439
+ // CEO dogfood: drop the literal
1703
3440
  // "shipped." fallback row. If we have no cached detail to
1704
3441
  // surface, stay silent. The agent tree pane already shows
1705
3442
  // the green check + duration.
1706
3443
  }
1707
3444
  }
3445
+ // PUGI-538b () — after Pugi's coordinator turn settles,
3446
+ // fire the engine bridge for any pending `<pugi-tool-route>`
3447
+ // envelope stashed by `consumeAskAndPlanReviewTags`. The bridge
3448
+ // runs ASYNCHRONOUSLY (we deliberately do not await — the SSE
3449
+ // event handler must stay fast so the next frame is not
3450
+ // delayed). `runEngineBridge` is wrapped in its own try/catch
3451
+ // so a bridge failure cannot crash the REPL.
3452
+ const pendingRoute = this.pendingToolRoutes.get(event.taskId);
3453
+ if (pendingRoute) {
3454
+ this.pendingToolRoutes.delete(event.taskId);
3455
+ void this.runEngineBridge(pendingRoute).catch((err) => {
3456
+ this.appendSystemLine(`engine bridge crashed: ${this.errorMessage(err)}`);
3457
+ });
3458
+ }
1708
3459
  return;
1709
3460
  }
1710
3461
  case 'agent.blocked': {
@@ -1715,6 +3466,11 @@ export class ReplSession {
1715
3466
  }
1716
3467
  this.askBuffer.delete(event.taskId);
1717
3468
  this.askBufferPending.delete(event.taskId);
3469
+ // PUGI-538b () — drop any pending tool-route envelope on
3470
+ // an aborted coordinator turn. Firing the bridge after the
3471
+ // operator already stopped the dispatch would silently burn
3472
+ // engine tokens for work they cancelled.
3473
+ this.pendingToolRoutes.delete(event.taskId);
1718
3474
  this.patch({
1719
3475
  agents: this.state.agents.map((a) => a.taskId === event.taskId
1720
3476
  ? { ...a, status: 'blocked', detail: event.detail }
@@ -1723,11 +3479,15 @@ export class ReplSession {
1723
3479
  if (target) {
1724
3480
  this.appendPersonaLine(target.personaSlug, `blocked: ${event.detail}`);
1725
3481
  }
1726
- // α6.9: `blocked` is a graceful refusal, not a crash — treat it
3482
+ // : `blocked` is a graceful refusal, not a crash — treat it
1727
3483
  // as a `completed` outcome from the FSM's perspective so the
1728
3484
  // operator sees the bottom-bar settle back to `idle` after the
1729
3485
  // last block clears.
1730
3486
  this.advanceFsmOnDispatchEnd('completed', 'agent_blocked', event.taskId);
3487
+ // cost-meter sprint — flush the per-turn delta (blocked
3488
+ // still counts as a billable turn — the operator paid for the
3489
+ // tokens that landed before the refusal).
3490
+ this.maybeFlushTurnOnAgentSettle(event.taskId);
1731
3491
  return;
1732
3492
  }
1733
3493
  case 'agent.failed': {
@@ -1738,6 +3498,9 @@ export class ReplSession {
1738
3498
  }
1739
3499
  this.askBuffer.delete(event.taskId);
1740
3500
  this.askBufferPending.delete(event.taskId);
3501
+ // PUGI-538b () — drop any pending tool-route envelope on
3502
+ // an aborted/failed coordinator turn. See agent.blocked rationale.
3503
+ this.pendingToolRoutes.delete(event.taskId);
1741
3504
  this.patch({
1742
3505
  agents: this.state.agents.map((a) => a.taskId === event.taskId
1743
3506
  ? { ...a, status: 'failed', detail: event.error }
@@ -1746,17 +3509,21 @@ export class ReplSession {
1746
3509
  if (target) {
1747
3510
  this.appendPersonaLine(target.personaSlug, `failed: ${event.error}`);
1748
3511
  }
1749
- // α6.9: terminal `failed` transition when no sibling task
3512
+ // : terminal `failed` transition when no sibling task
1750
3513
  // remains. Same defer-until-last-agent semantics as
1751
3514
  // `completed` so the bottom-bar surface tracks the dispatch
1752
3515
  // collectively.
1753
3516
  this.advanceFsmOnDispatchEnd('failed', 'agent_failed', event.taskId);
3517
+ // cost-meter sprint — flush the per-turn delta when the
3518
+ // dispatch fails (the operator still paid for whatever tokens
3519
+ // landed before the failure).
3520
+ this.maybeFlushTurnOnAgentSettle(event.taskId);
1754
3521
  return;
1755
3522
  }
1756
3523
  }
1757
3524
  }
1758
3525
  /**
1759
- * α6.9 helper: advance the FSM to `tool_running` when a tool call
3526
+ * helper: advance the FSM to `tool_running` when a tool call
1760
3527
  * lands mid-dispatch. Guarded against terminal / aborting states so
1761
3528
  * a late tool event after `cancel()` does not throw on an illegal
1762
3529
  * transition. The `tool` label drives the bottom-bar's
@@ -1780,7 +3547,7 @@ export class ReplSession {
1780
3547
  this.patch({ dispatchToolLabel: `tool: ${tool}` });
1781
3548
  }
1782
3549
  /**
1783
- * α6.9 helper: advance the FSM toward a terminal outcome when the
3550
+ * helper: advance the FSM toward a terminal outcome when the
1784
3551
  * LAST in-flight agent's lifecycle ends. The dispatch is "still
1785
3552
  * running" when any other agent in the tree is in `queued` /
1786
3553
  * `thinking`; the FSM only goes terminal when the last one settles.
@@ -1789,13 +3556,25 @@ export class ReplSession {
1789
3556
  * after a manual `cancel()` finds the FSM already in `aborted` and
1790
3557
  * is silently dropped.
1791
3558
  */
3559
+ /**
3560
+ * — shared stale-task check used by both the FSM advance
3561
+ * gate AND the status-bar `lastCompletedOutcome` mirror. Lifts the
3562
+ * R2 dispatchSeq compare out of `advanceFsmOnDispatchEnd` so other
3563
+ * agent.completed-handler side-effects (status-bar label, future
3564
+ * metric counters) can apply the same guard without duplicating it.
3565
+ * Returns true iff the task's stamped dispatchSeq is older than the
3566
+ * current dispatchSeq — i.e. a newer dispatchBrief() superseded it
3567
+ * and the late terminal event must not corrupt live-turn state.
3568
+ */
3569
+ isStaleTaskEvent(taskId) {
3570
+ const taskSeq = this.taskDispatchSeq.get(taskId);
3571
+ return taskSeq !== undefined && taskSeq < this.dispatchSeq;
3572
+ }
1792
3573
  advanceFsmOnDispatchEnd(outcome, reason, taskId) {
1793
- // R2 P1 fix (Codex triple-review 2026-05-25): a terminal event
3574
+ // R2 P1 fix (Codex triple-review): a terminal event
1794
3575
  // for a SUPERSEDED dispatch must NOT advance the live FSM or null
1795
- // the live token. If the event carries a taskId and the stamped
1796
- // dispatchSeq for that task is older than the current dispatchSeq,
1797
- // the event belongs to a prior dispatch that was replaced by a
1798
- // newer `dispatchBrief()`. Silently drop the FSM advance.
3576
+ // the live token. Delegates to isStaleTaskEvent so the agent.completed
3577
+ // status-bar mirror in the handler above uses the same gate.
1799
3578
  if (taskId !== undefined) {
1800
3579
  const taskSeq = this.taskDispatchSeq.get(taskId);
1801
3580
  if (taskSeq !== undefined && taskSeq < this.dispatchSeq) {
@@ -1827,6 +3606,63 @@ export class ReplSession {
1827
3606
  this.currentDispatchToken = null;
1828
3607
  this.patch({ briefStartedAtEpochMs: undefined });
1829
3608
  }
3609
+ /**
3610
+ * cost-meter sprint — gate the per-turn flush on "this was the
3611
+ * LAST in-flight agent". Mirrors the `stillActive` guard inside
3612
+ * `advanceFsmOnDispatchEnd` so a multi-agent dispatch only emits a
3613
+ * single recentTurns row + a single lastTurnDelta flash.
3614
+ *
3615
+ * Idempotent: if no tokens have been billed this turn, the inner
3616
+ * `flushTurnAccumulator` short-circuits without pushing an empty row.
3617
+ */
3618
+ maybeFlushTurnOnAgentSettle(taskId) {
3619
+ const stillActive = this.state.agents.some((a) => a.status === 'queued' || a.status === 'thinking');
3620
+ if (stillActive)
3621
+ return;
3622
+ this.flushTurnAccumulator(taskId);
3623
+ }
3624
+ /**
3625
+ * cost-meter sprint — flush the per-turn accumulator into
3626
+ * `recentTurns` + `lastTurnDelta`. Idempotent + safe to call from any
3627
+ * terminal-state branch (`agent.completed` / `agent.blocked` /
3628
+ * `agent.failed`). When no tokens have been billed this turn
3629
+ * (instant abort, cap-warning gate), the helper short-circuits
3630
+ * without pushing an empty row.
3631
+ */
3632
+ flushTurnAccumulator(taskId) {
3633
+ const turnTokensIn = this.state.turnTokensIn;
3634
+ const turnTokensOut = this.state.turnTokensOut;
3635
+ const turnCostUsd = this.state.turnCostUsd;
3636
+ if (turnTokensIn === 0 && turnTokensOut === 0) {
3637
+ // Idempotent zero-flush — never push an empty row into recentTurns.
3638
+ return;
3639
+ }
3640
+ const turnId = taskId !== undefined ? taskId : `turn-${this.dispatchSeq}-${this.now()}`;
3641
+ const newTurn = {
3642
+ id: turnId,
3643
+ tokensIn: turnTokensIn,
3644
+ tokensOut: turnTokensOut,
3645
+ costUsd: turnCostUsd,
3646
+ completedAt: new Date(this.now()).toISOString(),
3647
+ };
3648
+ // Keep the buffer capped at 5 entries (oldest first). The push
3649
+ // order matches the surface contract: `/cost` paginates oldest →
3650
+ // newest so the operator scans top-down chronologically.
3651
+ const recent = [...this.state.recentTurns, newTurn];
3652
+ const trimmed = recent.length > 5 ? recent.slice(-5) : recent;
3653
+ this.patch({
3654
+ recentTurns: trimmed,
3655
+ lastTurnDelta: {
3656
+ tokensIn: turnTokensIn,
3657
+ tokensOut: turnTokensOut,
3658
+ costUsd: turnCostUsd,
3659
+ completedAtEpochMs: this.now(),
3660
+ },
3661
+ turnTokensIn: 0,
3662
+ turnTokensOut: 0,
3663
+ turnCostUsd: 0,
3664
+ });
3665
+ }
1830
3666
  /* ------------- transcript helpers -------------- */
1831
3667
  /**
1832
3668
  * Look up the persona slug for a running task. Used by the tool call
@@ -1839,6 +3675,73 @@ export class ReplSession {
1839
3675
  const agent = this.state.agents.find((a) => a.taskId === taskId);
1840
3676
  return agent?.personaSlug ?? 'unknown';
1841
3677
  }
3678
+ /**
3679
+ * small-CC-parity batch : public ingest path for
3680
+ * a backend-driven `tool.call.delta` event. Appends the delta tail
3681
+ * onto the row's `streamingDelta` (capped at
3682
+ * `STREAMING_DELTA_MAX_CHARS` so the row stays single-line) when the
3683
+ * id matches a `running` row. No-op when the id is unknown OR when
3684
+ * the row already transitioned to a terminal status — late deltas
3685
+ * from a completed call must not overwrite the final detail.
3686
+ *
3687
+ * The renderer in `tool-stream-pane.tsx` reads `streamingDelta` to
3688
+ * paint the inline preview after the canonical args. This method is
3689
+ * the seam the future admin-api SSE consumer hooks into; until then
3690
+ * the spec drives it directly so the delta-append branch is locked
3691
+ * down behaviourally.
3692
+ */
3693
+ appendToolCallDelta(id, deltaChunk) {
3694
+ if (!id || !deltaChunk)
3695
+ return;
3696
+ const idx = this.state.toolCalls.findIndex((c) => c.id === id);
3697
+ if (idx < 0)
3698
+ return;
3699
+ const existing = this.state.toolCalls[idx];
3700
+ if (existing.status !== 'running')
3701
+ return;
3702
+ const current = existing.streamingDelta ?? '';
3703
+ let combined = current + deltaChunk;
3704
+ if (combined.length > STREAMING_DELTA_MAX_CHARS) {
3705
+ // Keep the TAIL — the operator wants the freshest bytes (the
3706
+ // line being written right now), not the stale head. The leading
3707
+ // ellipsis signals truncation.
3708
+ combined = `…${combined.slice(combined.length - STREAMING_DELTA_MAX_CHARS + 1)}`;
3709
+ }
3710
+ const next = this.state.toolCalls.slice();
3711
+ next[idx] = { ...existing, streamingDelta: combined };
3712
+ this.patch({ toolCalls: next });
3713
+ }
3714
+ /**
3715
+ * small-CC-parity batch : public ingest path for
3716
+ * the terminal `tool.call.end` event. Flips the row to `ok` / `error`
3717
+ * with the resolved duration + optional result preview. Cleans up the
3718
+ * transient `streamingDelta` so the completed row renders cleanly
3719
+ * without the live tail. No-op when the id is unknown.
3720
+ */
3721
+ endToolCall(input) {
3722
+ if (!input.id)
3723
+ return;
3724
+ const idx = this.state.toolCalls.findIndex((c) => c.id === input.id);
3725
+ if (idx < 0)
3726
+ return;
3727
+ const existing = this.state.toolCalls[idx];
3728
+ const endedAt = input.endedAtEpochMs ?? Date.now();
3729
+ const durationMs = Math.max(0, endedAt - existing.startedAtEpochMs);
3730
+ const preview = input.resultPreview
3731
+ ? truncatePreview(input.resultPreview, RESULT_PREVIEW_MAX_CHARS)
3732
+ : undefined;
3733
+ const next = this.state.toolCalls.slice();
3734
+ next[idx] = {
3735
+ ...existing,
3736
+ status: input.status,
3737
+ detail: input.detail ?? existing.detail,
3738
+ resultLines: input.resultLines ?? existing.resultLines,
3739
+ durationMs,
3740
+ resultPreview: preview,
3741
+ streamingDelta: undefined,
3742
+ };
3743
+ this.patch({ toolCalls: next });
3744
+ }
1842
3745
  /**
1843
3746
  * Fold a tool call entry into the rolling list. If the entry id
1844
3747
  * already exists, replace it in-place (so a synthesised `running` →
@@ -1868,10 +3771,10 @@ export class ReplSession {
1868
3771
  this.appendRow({ source: 'system', text });
1869
3772
  }
1870
3773
  appendPersonaLine(personaSlug, text) {
1871
- // α6.14.2 wave 5: dedup the persona display-name prefix. The
3774
+ // wave 5: dedup the persona display-name prefix. The
1872
3775
  // conversation pane already renders `▸ <DisplayName> <text>` from
1873
3776
  // the slug → name map; when the model's own reply begins with
1874
- // the same display name (CEO 2026-05-25 screenshot: "Pugi Pugi,
3777
+ // the same display name (CEO screenshot: "Pugi Pugi,
1875
3778
  // координатор Pugi"), the operator sees the name twice. Strip
1876
3779
  // the leading display-name token (with optional trailing comma /
1877
3780
  // colon / whitespace) so the prefix the pane adds is the only one
@@ -1883,13 +3786,14 @@ export class ReplSession {
1883
3786
  this.appendRow({ source: 'persona', text: stripped, personaSlug });
1884
3787
  }
1885
3788
  appendRow(input) {
1886
- if (input.text.length === 0)
3789
+ if (input.text.length === 0 && input.source !== 'compact-boundary')
1887
3790
  return;
1888
3791
  const row = {
1889
3792
  id: randomUUID(),
1890
3793
  source: input.source,
1891
3794
  text: input.text,
1892
3795
  personaSlug: input.personaSlug,
3796
+ compaction: input.compaction,
1893
3797
  timestampEpochMs: this.now(),
1894
3798
  };
1895
3799
  const next = this.state.transcript.concat(row).slice(-MAX_TRANSCRIPT_ROWS);
@@ -1898,10 +3802,66 @@ export class ReplSession {
1898
3802
  // Persistence is fail-safe: a single error becomes one system
1899
3803
  // line, subsequent errors are silent so a stuck disk does not
1900
3804
  // flood the operator. The mapping from row.source -> store kind:
1901
- // operator -> 'user' (drives turn_count + title)
1902
- // persona -> 'persona'
1903
- // system -> 'system'
3805
+ // operator -> 'user' (drives turn_count + title)
3806
+ // persona -> 'persona'
3807
+ // system -> 'system'
1904
3808
  this.persistRow(row);
3809
+ // evaluate the auto-compact gate after
3810
+ // every appendRow that produces a transcript turn. Wrapped in a
3811
+ // setImmediate so the gate never blocks the input-handling fast
3812
+ // path; if the threshold is tripped, the auto-trigger dispatches
3813
+ // `/compact` in the background while the operator keeps typing.
3814
+ if (row.source === 'operator' || row.source === 'persona') {
3815
+ this.maybeAutoCompact();
3816
+ }
3817
+ }
3818
+ /**
3819
+ * Auto-compact gate. Cheap: builds an in-memory token estimate from
3820
+ * the current transcript and consults `evaluateAutoCompact`. When the
3821
+ * gate fires AND a compaction is not already in flight, we dispatch
3822
+ * `/compact` with `trigger='auto'`. The fire-and-forget shape means
3823
+ * the input box stays responsive while the background round-trip
3824
+ * runs.
3825
+ *
3826
+ * Hysteresis: `compactionInFlight` blocks re-entry. The gate is
3827
+ * cleared when the dispatch promise resolves regardless of outcome
3828
+ * so a transient transport failure does not permanently disable the
3829
+ * auto-trigger.
3830
+ */
3831
+ compactionInFlight = false;
3832
+ maybeAutoCompact() {
3833
+ if (this.compactionInFlight)
3834
+ return;
3835
+ if (!this.store || !this.localSessionId)
3836
+ return;
3837
+ if (process.env['PUGI_AUTOCOMPACT_DISABLED'] === '1')
3838
+ return;
3839
+ // Token estimate from the in-memory transcript. The estimate is a
3840
+ // lower bound on actual context pressure (server-side system
3841
+ // prompts add overhead) but the 4-char/token heuristic plus the
3842
+ // 0.75 default threshold gives generous headroom.
3843
+ const texts = this.state.transcript.map((r) => r.text);
3844
+ const tokenCount = estimateTokensInMany(texts);
3845
+ // Conservative default: assume the smallest commonly-used window
3846
+ // (32k tokens for deepseek-v3.1). Resolving the live model slug
3847
+ // through DispatchFSM + admin-api adds latency on a hot path; the
3848
+ // 0.75 threshold + smallest-window assumption errs toward
3849
+ // EARLY trigger which is the safe direction.
3850
+ const verdict = evaluateAutoCompact({
3851
+ tokenCount,
3852
+ windowSize: 32_000,
3853
+ });
3854
+ if (verdict.kind !== 'fire')
3855
+ return;
3856
+ this.compactionInFlight = true;
3857
+ void (async () => {
3858
+ try {
3859
+ await this.dispatchCompact('auto');
3860
+ }
3861
+ finally {
3862
+ this.compactionInFlight = false;
3863
+ }
3864
+ })();
1905
3865
  }
1906
3866
  /**
1907
3867
  * Best-effort write of one transcript row into the local
@@ -1912,6 +3872,15 @@ export class ReplSession {
1912
3872
  persistRow(row) {
1913
3873
  if (!this.store)
1914
3874
  return;
3875
+ // L29 : `compact-boundary` transcript rows are echoes of
3876
+ // the JSONL `compaction` event the compact runner already appended
3877
+ // via `appendCompactBoundary`. Persisting them here would double-
3878
+ // write the marker (and worse, with a stripped payload that lacks
3879
+ // `summary` / `coversUntilOffset`) — `isCompactBoundary` would
3880
+ // reject the duplicate but `applyCompactMask` would still index off
3881
+ // the wrong offset. Skip the write.
3882
+ if (row.source === 'compact-boundary')
3883
+ return;
1915
3884
  const kind = row.source === 'operator' ? 'user'
1916
3885
  : row.source === 'persona' ? 'persona'
1917
3886
  : 'system';
@@ -1939,7 +3908,7 @@ export class ReplSession {
1939
3908
  });
1940
3909
  }
1941
3910
  /**
1942
- * Restore a transcript from a stored event log - α6.4. Called by
3911
+ * Restore a transcript from a stored event log - . Called by
1943
3912
  * the CLI bootstrap when the operator runs `pugi resume <id>` or
1944
3913
  * picks an entry from the `/resume` picker. Replays each event into
1945
3914
  * the local transcript WITHOUT writing back to the store so the
@@ -1952,12 +3921,30 @@ export class ReplSession {
1952
3921
  * write the restored events.
1953
3922
  */
1954
3923
  restoreTranscript(events) {
3924
+ // apply compact-boundary masking BEFORE the
3925
+ // row conversion. Events strictly before the latest marker are
3926
+ // condensed into the boundary's `keptTailTurns + marker` slice so
3927
+ // the post-resume transcript starts at the most-recent context
3928
+ // floor rather than re-playing the full pre-compaction history.
3929
+ //
3930
+ // then apply rewind-marker masking. Any
3931
+ // event inside an active rewind range is stripped from the
3932
+ // visible transcript; the on-disk events stay durable so a
3933
+ // follow-up `pugi sessions undo-rewind` can restore them.
3934
+ const masked = applyRewindMask(applyCompactMask(events));
1955
3935
  const rows = [];
1956
- for (const event of events) {
3936
+ for (const event of masked) {
1957
3937
  const row = eventToTranscriptRow(event);
1958
3938
  if (row)
1959
3939
  rows.push(row);
1960
3940
  }
3941
+ // L29 : tag each compact-boundary row with the count of
3942
+ // operator + persona turns that landed AFTER it in the replay
3943
+ // window. The banner reads `turnsAgo` to render the "N turns ago"
3944
+ // suffix so a long session that resumes across multiple compactions
3945
+ // stays self-orienting. System rows + sibling boundaries are NOT
3946
+ // counted — they are chrome, not operator-visible turns.
3947
+ annotateBoundaryTurnsAgo(rows);
1961
3948
  // Cap at MAX_TRANSCRIPT_ROWS - the same cap appendRow uses so the
1962
3949
  // window math stays consistent post-restore.
1963
3950
  const capped = rows.slice(-MAX_TRANSCRIPT_ROWS);
@@ -1971,7 +3958,7 @@ export class ReplSession {
1971
3958
  getLocalSessionId() {
1972
3959
  return this.localSessionId;
1973
3960
  }
1974
- /* ------------- α6.3 buffered tag detection -------------- */
3961
+ /* ------------- buffered tag detection -------------- */
1975
3962
  /**
1976
3963
  * Scan the running `agent.step.detail` buffer for `<pugi-ask>` /
1977
3964
  * `<pugi-plan-review>` envelopes. If a complete envelope is found,
@@ -2031,12 +4018,39 @@ export class ReplSession {
2031
4018
  if (planResult.hadMalformedTag) {
2032
4019
  this.appendSystemLine('Malformed <pugi-plan-review> dropped (parser refusal).');
2033
4020
  }
4021
+ // PUGI-538b () — third envelope family: `<pugi-tool-route>`.
4022
+ // Pugi emits it on the coordinator turn when the operator's brief
4023
+ // requires workspace tool use. We strip the raw XML from the
4024
+ // operator-visible body, dedupe via the seen-tag rolling set, and
4025
+ // STASH the parsed envelope keyed by taskId. The `agent.completed`
4026
+ // handler reads the stash and fires `bridgeToEngine` — firing
4027
+ // mid-stream would race with the still-streaming coordinator turn.
4028
+ const routeResult = extractToolRouteTags(working);
4029
+ working = routeResult.cleaned;
4030
+ for (const tag of routeResult.tags) {
4031
+ if (this.seenTagSignatures.includes(tag.signature))
4032
+ continue;
4033
+ this.recordSeenTag(tag.signature);
4034
+ if (this.pendingToolRoutes.has(taskId)) {
4035
+ // Grammar says one envelope per turn. A second on the same
4036
+ // taskId is dropped to a system line so the operator can see
4037
+ // why the bridge did not fire twice.
4038
+ this.appendSystemLine('Persona emitted a second <pugi-tool-route> while one was already pending. Dropped.');
4039
+ continue;
4040
+ }
4041
+ this.pendingToolRoutes.set(taskId, tag);
4042
+ }
4043
+ if (routeResult.hadMalformedTag) {
4044
+ this.appendSystemLine('Malformed <pugi-tool-route> dropped (parser refusal).');
4045
+ }
2034
4046
  // Record / clear the "pending open tag" flag so agent.completed can
2035
4047
  // emit a warning if the persona ends the turn with an unfinished
2036
- // envelope. The flag flips OFF when both parsers report no
2037
- // outstanding open tag - if either is still pending, we keep it on
4048
+ // envelope. The flag flips OFF when ALL parsers report no
4049
+ // outstanding open tag - if any is still pending, we keep it on
2038
4050
  // so the warning fires once at turn end.
2039
- if (askResult.pendingOpenTag || planResult.pendingOpenTag) {
4051
+ if (askResult.pendingOpenTag
4052
+ || planResult.pendingOpenTag
4053
+ || routeResult.pendingOpenTag) {
2040
4054
  this.askBufferPending.add(taskId);
2041
4055
  }
2042
4056
  else {
@@ -2044,6 +4058,244 @@ export class ReplSession {
2044
4058
  }
2045
4059
  return working;
2046
4060
  }
4061
+ /**
4062
+ * PUGI-538b () — public alias for the buffer-and-strip
4063
+ * routine, kept for test ergonomics and external callers that want
4064
+ * to invoke the parser without driving a full SSE replay. Mirrors
4065
+ * the per-task buffering contract the private method already obeys.
4066
+ *
4067
+ * Exposed for the new `repl-tool-route-bridge.spec.ts` so the spec
4068
+ * can assert that a streamed envelope is parsed and stripped without
4069
+ * needing to fabricate a full agent.step / agent.completed sequence.
4070
+ */
4071
+ consumePugiToolRouteTag(taskId, detail) {
4072
+ return this.consumeAskAndPlanReviewTags(taskId, detail);
4073
+ }
4074
+ /**
4075
+ * PUGI-538b () — test-only inspector for the pending-tool-
4076
+ * route stash. Spec asserts that an envelope captured mid-stream
4077
+ * lands here and is cleared once the coordinator turn completes
4078
+ * (which fires the bridge).
4079
+ */
4080
+ pendingToolRouteForTest(taskId) {
4081
+ return this.pendingToolRoutes.get(taskId);
4082
+ }
4083
+ /**
4084
+ * PUGI-538b () — fire the engine bridge for a parsed
4085
+ * `<pugi-tool-route>` envelope. This is the CLI half of
4086
+ * Path A: the coordinator turn's envelope routes the operational
4087
+ * brief through the production engine path (NativePugiEngineAdapter
4088
+ * → runEngineLoop → POST /api/pugi/engine) so workspace tool calls
4089
+ * actually write files instead of dumping prose-only heredocs.
4090
+ *
4091
+ * The actual engine adapter wiring lives in the REPL bootstrap
4092
+ * (`repl-render.tsx`); this method only:
4093
+ * 1. surfaces a "Routing to engine" system line so the operator
4094
+ * sees the handoff,
4095
+ * 2. mints a fresh AbortController and registers it in
4096
+ * `bridgeAborts` so REPL stop can cancel the bridge,
4097
+ * 3. inserts a synthetic `agent` row keyed off a `bridge-<uuid>`
4098
+ * taskId so the agent-tree pane renders the engine turn the
4099
+ * same way it renders a sub-agent,
4100
+ * 4. invokes `engineBridge` (the injected callback) and translates
4101
+ * every `BridgedEngineEvent` into a state patch on the synthetic
4102
+ * row,
4103
+ * 5. flips the synthetic row to its terminal status when the
4104
+ * bridge resolves, surfacing the engine's final reply text (if
4105
+ * any) on a persona row so the operator sees it in the
4106
+ * transcript.
4107
+ *
4108
+ * When no `engineBridge` is provided in `ReplSessionOptions` (e.g. a
4109
+ * test that opts out, or a CLI build that has not wired the adapter
4110
+ * yet) we surface a single system-line warning explaining why the
4111
+ * brief did not write files. This degradation preserves the pre-PR
4112
+ * "see code, no file" UX without adding the "see envelope, no file"
4113
+ * surprise on top.
4114
+ */
4115
+ async runEngineBridge(tag) {
4116
+ const bridge = this.options.engineBridge;
4117
+ if (!bridge) {
4118
+ // No bridge wired — fall back to the pre-PR behaviour with one
4119
+ // additional honest sentence so the operator can see WHY no
4120
+ // files appeared. Triple-review surface: makes it obvious that
4121
+ // the regression mode is "bridge not wired in this build", not
4122
+ // "engine call failed". The brief is bounded by the parser at
4123
+ // 400 chars so this line cannot blow up the transcript.
4124
+ this.appendSystemLine(`Engine bridge not configured. Brief would have routed to ${tag.command}: "${tag.brief}".`);
4125
+ return;
4126
+ }
4127
+ const bridgeId = `bridge-${randomUUID()}`;
4128
+ const abort = new AbortController();
4129
+ this.bridgeAborts.set(bridgeId, abort);
4130
+ // Surface a system line so the operator sees the handoff before
4131
+ // engine events start flowing. The wording mirrors the prompt's
4132
+ // "Routing to engine" sentence so prompt + transcript stay in
4133
+ // lockstep.
4134
+ this.appendSystemLine(`Routing to engine (${tag.command} | ${tag.persona}): ${tag.brief}`);
4135
+ // PUGI-538b — insert a synthetic agent-tree node so the existing
4136
+ // pane renders the engine turn the same way it renders a
4137
+ // sub-agent. Role is `coder` because the engine path is the write
4138
+ // surface; the slug is the parsed persona hint so the pane shows
4139
+ // "Hiroshi" (or whichever Tier-1 the envelope asked for) instead
4140
+ // of a generic label.
4141
+ const startedAt = this.now();
4142
+ const personaName = this.resolveBridgePersonaName(tag.persona);
4143
+ const syntheticNode = {
4144
+ taskId: bridgeId,
4145
+ role: 'coder',
4146
+ personaSlug: tag.persona,
4147
+ personaName,
4148
+ status: 'thinking',
4149
+ detail: tag.brief,
4150
+ startedAtEpochMs: startedAt,
4151
+ tokensIn: 0,
4152
+ tokensOut: 0,
4153
+ };
4154
+ this.patch({ agents: [syntheticNode, ...this.state.agents] });
4155
+ const onEvent = (event) => {
4156
+ // Translate the bridge's typed events onto the synthetic
4157
+ // agent-tree node. We deliberately mirror the existing
4158
+ // agent.step / agent.tool / agent.tokens consumers above so the
4159
+ // UI surface stays uniform across delegate and bridge sub-agents.
4160
+ if (event.type === 'step') {
4161
+ this.patch({
4162
+ agents: this.state.agents.map((a) => a.taskId === bridgeId
4163
+ ? { ...a, status: 'thinking', detail: event.detail }
4164
+ : a),
4165
+ });
4166
+ }
4167
+ else if (event.type === 'tool.start') {
4168
+ const mapped = normaliseBridgedToolName(event.tool);
4169
+ if (mapped !== null) {
4170
+ this.appendToolCall({
4171
+ id: `${bridgeId}-${mapped}-${this.now()}`,
4172
+ tool: mapped,
4173
+ args: (event.args ?? '').slice(0, 80),
4174
+ agent: tag.persona,
4175
+ status: 'running',
4176
+ startedAtEpochMs: this.now(),
4177
+ });
4178
+ }
4179
+ }
4180
+ else if (event.type === 'tool.result') {
4181
+ const mapped = normaliseBridgedToolName(event.tool);
4182
+ if (mapped !== null) {
4183
+ this.appendToolCall({
4184
+ id: `${bridgeId}-${mapped}-${this.now()}`,
4185
+ tool: mapped,
4186
+ args: '',
4187
+ agent: tag.persona,
4188
+ status: event.ok ? 'ok' : 'error',
4189
+ startedAtEpochMs: this.now(),
4190
+ resultPreview: (event.preview ?? '').slice(0, RESULT_PREVIEW_MAX_CHARS),
4191
+ });
4192
+ }
4193
+ }
4194
+ else if (event.type === 'tokens') {
4195
+ const deltaCostUsd = computeCostUsd(event.tokensIn, event.tokensOut, undefined);
4196
+ this.patch({
4197
+ tokensDownstreamTotal: this.state.tokensDownstreamTotal + event.tokensIn + event.tokensOut,
4198
+ sessionTokensIn: this.state.sessionTokensIn + event.tokensIn,
4199
+ sessionTokensOut: this.state.sessionTokensOut + event.tokensOut,
4200
+ sessionCostUsd: this.state.sessionCostUsd + deltaCostUsd,
4201
+ turnTokensIn: this.state.turnTokensIn + event.tokensIn,
4202
+ turnTokensOut: this.state.turnTokensOut + event.tokensOut,
4203
+ turnCostUsd: this.state.turnCostUsd + deltaCostUsd,
4204
+ agents: this.state.agents.map((a) => a.taskId === bridgeId
4205
+ ? {
4206
+ ...a,
4207
+ tokensIn: a.tokensIn + event.tokensIn,
4208
+ tokensOut: a.tokensOut + event.tokensOut,
4209
+ }
4210
+ : a),
4211
+ });
4212
+ }
4213
+ };
4214
+ let result;
4215
+ try {
4216
+ result = await bridge({
4217
+ command: tag.command,
4218
+ persona: tag.persona,
4219
+ // PR C (PUGI-538-FU): prefer the contextualized
4220
+ // engine prompt when the direct-engine path set it. Falls back
4221
+ // к the bare brief for parser-built tags from the server-emitted
4222
+ // envelope path (no conversation context available there).
4223
+ brief: tag.enginePrompt ?? tag.brief,
4224
+ bridgeId,
4225
+ signal: abort.signal,
4226
+ onEvent,
4227
+ });
4228
+ }
4229
+ catch (err) {
4230
+ this.bridgeAborts.delete(bridgeId);
4231
+ const message = this.errorMessage(err);
4232
+ this.patch({
4233
+ agents: this.state.agents.map((a) => a.taskId === bridgeId
4234
+ ? { ...a, status: 'failed', detail: message }
4235
+ : a),
4236
+ });
4237
+ this.appendSystemLine(`Engine bridge failed: ${message}`);
4238
+ return;
4239
+ }
4240
+ this.bridgeAborts.delete(bridgeId);
4241
+ // PUGI-538c-FU-OUTCOME (2026-06-05): the bridge outcome union now
4242
+ // carries `unverified`, which maps to the same-named agent-tree
4243
+ // status so a fresh customer repo with no test infra no longer
4244
+ // false-fails on the agent-tree pane. The verify-gate contract is
4245
+ // preserved: real `verification_command_failed` runs still surface
4246
+ // as `failed`; only `needs_verification` (no command detected)
4247
+ // downgrades to advisory.
4248
+ const terminalStatus = result.outcome === 'shipped'
4249
+ ? 'shipped'
4250
+ : result.outcome === 'unverified'
4251
+ ? 'unverified'
4252
+ : result.outcome === 'blocked'
4253
+ ? 'blocked'
4254
+ : 'failed';
4255
+ this.patch({
4256
+ agents: this.state.agents.map((a) => a.taskId === bridgeId
4257
+ ? { ...a, status: terminalStatus, detail: result.detail ?? terminalStatus }
4258
+ : a),
4259
+ });
4260
+ if (result.outcome === 'unverified') {
4261
+ // Operator-visible advisory: explain why the agent-tree node
4262
+ // landed in `unverified` rather than `shipped`. Files DID write
4263
+ // (the bridge proved that) but the gate could not certify the
4264
+ // run. Keep the wording neutral and actionable: avoid the word
4265
+ // "failed" so the operator does not lose trust in the engine.
4266
+ this.appendSystemLine('Pugi shipped files. No verification command detected; run your tests manually to confirm.');
4267
+ }
4268
+ if (result.finalText && result.finalText.trim().length > 0) {
4269
+ this.appendPersonaLine(tag.persona, result.finalText.trim());
4270
+ }
4271
+ }
4272
+ /**
4273
+ * PUGI-538b () — best-effort display-name lookup for a
4274
+ * bridge persona slug. The local frontend roster has the names; we
4275
+ * keep the resolver narrow (Tier-1 slugs only) so this method does
4276
+ * not pull in the full roster cycle. Unknown slugs fall back to a
4277
+ * title-cased version of the slug, which is the same fallback the
4278
+ * agent-tree pane uses for unrecognised persona slugs.
4279
+ */
4280
+ resolveBridgePersonaName(slug) {
4281
+ const tier1 = {
4282
+ dev: 'Hiroshi',
4283
+ qa: 'Vera',
4284
+ pm: 'Olivia',
4285
+ devops: 'Diego',
4286
+ researcher: 'Anika',
4287
+ analyst: 'Liam',
4288
+ designer: 'Sofia',
4289
+ frontend: 'Mia',
4290
+ architect: 'Marcus',
4291
+ };
4292
+ const known = tier1[slug];
4293
+ if (known)
4294
+ return known;
4295
+ if (slug.length === 0)
4296
+ return 'Engine';
4297
+ return slug.charAt(0).toUpperCase() + slug.slice(1);
4298
+ }
2047
4299
  recordSeenTag(signature) {
2048
4300
  this.seenTagSignatures.push(signature);
2049
4301
  while (this.seenTagSignatures.length > 32) {
@@ -2076,7 +4328,7 @@ export class ReplSession {
2076
4328
  }
2077
4329
  }
2078
4330
  /* ------------------------------------------------------------------ */
2079
- /* Helpers */
4331
+ /* Helpers */
2080
4332
  /* ------------------------------------------------------------------ */
2081
4333
  /**
2082
4334
  * Resolve role → display name without throwing on unknown roles. The
@@ -2092,9 +4344,9 @@ export class ReplSession {
2092
4344
  * tool stream rows, not transcript rows). The shape mirrors the
2093
4345
  * `persistRow` mapping in reverse:
2094
4346
  *
2095
- * 'user' -> operator (brief)
2096
- * 'persona' -> persona (text + personaSlug)
2097
- * 'system' -> system (text)
4347
+ * 'user' -> operator (brief)
4348
+ * 'persona' -> persona (text + personaSlug)
4349
+ * 'system' -> system (text)
2098
4350
  *
2099
4351
  * Exported indirectly via `restoreTranscript`.
2100
4352
  */
@@ -2141,13 +4393,76 @@ function eventToTranscriptRow(event) {
2141
4393
  timestampEpochMs: event.t,
2142
4394
  };
2143
4395
  }
4396
+ if (event.kind === 'compaction') {
4397
+ // L8 + L29 : render the marker as a structured
4398
+ // `compact-boundary` row so the renderer can route it to the
4399
+ // dedicated <CompactBanner /> Ink component. The full summary text
4400
+ // is intentionally NOT inlined here (a 2k-token summary in the
4401
+ // transcript would defeat the purpose of compacting); the operator
4402
+ // sees the "context compacted" banner and can run `/context` to
4403
+ // inspect the marker payload when they want the details. The plain
4404
+ // text fallback stays in place for non-Ink consumers (snapshot
4405
+ // tests, future JSON exports).
4406
+ const compactionPayload = (event.payload ?? null);
4407
+ const trigger = compactionPayload?.trigger === 'auto' ? 'auto' : 'manual';
4408
+ const turns = typeof compactionPayload?.summaryTurnsBefore === 'number'
4409
+ ? compactionPayload.summaryTurnsBefore
4410
+ : 0;
4411
+ const tokens = typeof compactionPayload?.summaryTokenCount === 'number'
4412
+ ? compactionPayload.summaryTokenCount
4413
+ : undefined;
4414
+ return {
4415
+ id: randomUUID(),
4416
+ source: 'compact-boundary',
4417
+ text: `─── context compacted (${turns} turns → 1 summary, ${trigger}) ───`,
4418
+ compaction: {
4419
+ turnsBefore: turns,
4420
+ trigger,
4421
+ summaryTokenCount: tokens,
4422
+ },
4423
+ timestampEpochMs: event.t,
4424
+ };
4425
+ }
2144
4426
  return null;
2145
4427
  }
4428
+ /**
4429
+ * L29 : walk a chronological transcript window and stamp
4430
+ * every `compact-boundary` row's `compaction.turnsAgo` with the count of
4431
+ * operator + persona rows that land AFTER it. The annotation runs in
4432
+ * place on the array — boundaries earlier in time get larger `turnsAgo`
4433
+ * values, the boundary at the head of the window gets zero. System rows
4434
+ * and sibling boundaries are excluded from the count (they are chrome,
4435
+ * not operator-visible turns).
4436
+ *
4437
+ * Exported so a future spec can lock the contract and so the in-REPL
4438
+ * `/compact` path can reuse the same counter on live appends if it ever
4439
+ * needs to. Pure function (mutates only the input slice).
4440
+ */
4441
+ export function annotateBoundaryTurnsAgo(rows) {
4442
+ let trailingTurns = 0;
4443
+ for (let i = rows.length - 1; i >= 0; i -= 1) {
4444
+ const row = rows[i];
4445
+ if (row.source === 'operator' || row.source === 'persona') {
4446
+ trailingTurns += 1;
4447
+ continue;
4448
+ }
4449
+ if (row.source === 'compact-boundary') {
4450
+ // Re-assign with the live `turnsAgo`. Carry forward the existing
4451
+ // structured payload so we never lose the trigger / token-count
4452
+ // data the renderer needs.
4453
+ const compaction = row.compaction ?? { turnsBefore: 0, trigger: 'manual' };
4454
+ rows[i] = {
4455
+ ...row,
4456
+ compaction: { ...compaction, turnsAgo: trailingTurns },
4457
+ };
4458
+ }
4459
+ }
4460
+ }
2146
4461
  /**
2147
4462
  * Heuristic: does this text contain Markdown structures that benefit
2148
4463
  * from atomic grouping? Code fences, bullet lists, numbered lists,
2149
4464
  * headings - anything where per-line splitting would fragment visual
2150
- * grouping (Claude triple-review P1 PR #369).
4465
+ * grouping (Claude triple-review P1 PR).
2151
4466
  */
2152
4467
  function looksLikeMarkdown(text) {
2153
4468
  if (text.includes('```'))
@@ -2168,6 +4483,29 @@ function looksLikeMarkdown(text) {
2168
4483
  // 2+ bullets OR 2+ numbered OR any heading = group atomically.
2169
4484
  return bulletCount >= 2 || numberedCount >= 2 || headingCount >= 1;
2170
4485
  }
4486
+ /**
4487
+ * PUGI-538b () — normalise a bridge-reported tool name onto
4488
+ * the REPL's closed `ToolCallEntry['tool']` set. The engine surface
4489
+ * has a wider tool registry (symbols.*, mcp_*, agent, …); the REPL
4490
+ * pane only renders the seven canonical names. Unknown names return
4491
+ * null so the bridge-event consumer skips the row instead of
4492
+ * crashing on an out-of-set string.
4493
+ */
4494
+ function normaliseBridgedToolName(name) {
4495
+ const normalised = name.trim().toLowerCase();
4496
+ switch (normalised) {
4497
+ case 'read':
4498
+ case 'write':
4499
+ case 'edit':
4500
+ case 'bash':
4501
+ case 'grep':
4502
+ case 'glob':
4503
+ case 'web_fetch':
4504
+ return normalised;
4505
+ default:
4506
+ return null;
4507
+ }
4508
+ }
2171
4509
  function safePersonaName(role) {
2172
4510
  try {
2173
4511
  return getPersonaForRole(role).name;
@@ -2180,10 +4518,10 @@ function safePersonaName(role) {
2180
4518
  * Render a millisecond delta as a compact human-readable age. Used by
2181
4519
  * `/context` to surface the oldest working-set entry's age:
2182
4520
  *
2183
- * < 60s -> `45s`
2184
- * < 1h -> `4m`
2185
- * < 24h -> `2h`
2186
- * >= 24h -> `3d`
4521
+ * < 60s -> `45s`
4522
+ * < 1h -> `4m`
4523
+ * < 24h -> `2h`
4524
+ * >= 24h -> `3d`
2187
4525
  *
2188
4526
  * Negative deltas (clock skew) clamp to `0s`.
2189
4527
  */
@@ -2209,23 +4547,103 @@ function formatAgeSeconds(deltaMs) {
2209
4547
  export function knownRoles() {
2210
4548
  return listRoles();
2211
4549
  }
4550
+ /**
4551
+ * cost-meter sprint — render a session-elapsed ms delta as the
4552
+ * status-row's compact `XmYs` / `XhYm` shape. Distinct from
4553
+ * `formatAgeSeconds` above because `/cost` needs minute-granularity
4554
+ * uniformly (operator wants `2m44s`, not `2m`). Pure / branch-cheap;
4555
+ * the TUI status row + `/cost` both call this on every render.
4556
+ */
4557
+ function formatElapsedShort(elapsedMs) {
4558
+ if (!Number.isFinite(elapsedMs) || elapsedMs <= 0)
4559
+ return '0s';
4560
+ const totalSec = Math.floor(elapsedMs / 1000);
4561
+ if (totalSec < 60)
4562
+ return `${totalSec}s`;
4563
+ const min = Math.floor(totalSec / 60);
4564
+ const sec = totalSec % 60;
4565
+ if (min < 60)
4566
+ return `${min}m${sec.toString().padStart(2, '0')}s`;
4567
+ const hr = Math.floor(min / 60);
4568
+ const restMin = min % 60;
4569
+ return `${hr}h${restMin.toString().padStart(2, '0')}m`;
4570
+ }
4571
+ /**
4572
+ * cost-meter sprint — public-facing tier labels for the `/quota`
4573
+ * slash. Mirrors `TIER_PRICE_LABEL` in `runtime/cli.ts` (kept in sync
4574
+ * via `pricing.spec.ts` gate). Falls through to the raw slug when an
4575
+ * unknown tier ships from a forward-compat admin-api build.
4576
+ */
4577
+ const QUOTA_TIER_LABELS = Object.freeze({
4578
+ free: 'Free',
4579
+ founder: 'Founder ($20/mo)',
4580
+ builder: 'Builder ($99/mo)',
4581
+ team: 'Team ($199/mo)',
4582
+ });
4583
+ /**
4584
+ * cost-meter sprint — render the time-until-reset window for the
4585
+ * `/quota` plan line. `resetAt` is the ISO string admin-api returns;
4586
+ * `now` is the current epoch ms (injected for test determinism). Falls
4587
+ * back to the raw ISO string when parsing fails so the operator never
4588
+ * sees an empty hint.
4589
+ */
4590
+ function formatResetWindow(resetAtIso, nowEpochMs) {
4591
+ const resetMs = Date.parse(resetAtIso);
4592
+ if (!Number.isFinite(resetMs))
4593
+ return resetAtIso;
4594
+ const deltaMs = resetMs - nowEpochMs;
4595
+ if (deltaMs <= 0)
4596
+ return 'now';
4597
+ const days = Math.floor(deltaMs / (24 * 60 * 60 * 1000));
4598
+ if (days >= 2)
4599
+ return `in ${days}d`;
4600
+ const hours = Math.floor(deltaMs / (60 * 60 * 1000));
4601
+ if (hours >= 1)
4602
+ return `in ${hours}h`;
4603
+ const minutes = Math.max(1, Math.floor(deltaMs / (60 * 1000)));
4604
+ return `in ${minutes}m`;
4605
+ }
4606
+ /**
4607
+ * cleanup : wrap a `/quota` counter row in ANSI
4608
+ * color codes by utilisation percent. Thresholds match the upstream tool's
4609
+ * tier-meter convention so operators trained on that surface read the
4610
+ * same signal here:
4611
+ *
4612
+ * - 0..70% → green (32m) — comfortable headroom
4613
+ * - 70..90% → yellow (33m) — approaching cap, plan ahead
4614
+ * - 90..100% → red (31m) — burn rate alarm, throttle now
4615
+ *
4616
+ * The wrap is whole-row (not just the percent) so the eye registers
4617
+ * the level on the line, not just the trailing parenthesis. Tests
4618
+ * that match the inner row text via regex are unaffected because the
4619
+ * regex anchors live inside the wrapped substring; the ANSI codes
4620
+ * sit at the boundaries.
4621
+ */
4622
+ export function colorizeQuotaRow(row, pct) {
4623
+ const RESET = '\x1b[0m';
4624
+ if (pct >= 90)
4625
+ return `\x1b[31m${row}${RESET}`;
4626
+ if (pct >= 70)
4627
+ return `\x1b[33m${row}${RESET}`;
4628
+ return `\x1b[32m${row}${RESET}`;
4629
+ }
2212
4630
  /* ------------------------------------------------------------------ */
2213
- /* Tool call synthesiser - α6.12 */
4631
+ /* Tool call synthesiser - */
2214
4632
  /* ------------------------------------------------------------------ */
2215
4633
  /**
2216
4634
  * Match canonical tool invocation grammar in an `agent.step.detail`
2217
4635
  * string and emit a synthesised `ToolCallEntry`. Returns null when no
2218
4636
  * known tool pattern matches.
2219
4637
  *
2220
- * The grammar mirrors the way Claude Code, Codex CLI, and Gemini CLI
4638
+ * The grammar mirrors the way the upstream tool, peer CLI, and Gemini CLI
2221
4639
  * display tool calls in their tool stream panes:
2222
4640
  *
2223
- * Read(path)
2224
- * Edit(path[:lines])
2225
- * Bash(command)
2226
- * Grep("pattern" [in path])
2227
- * Glob(pattern)
2228
- * WebFetch(url)
4641
+ * Read(path)
4642
+ * Edit(path[:lines])
4643
+ * Bash(command)
4644
+ * Grep("pattern" [in path])
4645
+ * Glob(pattern)
4646
+ * WebFetch(url)
2229
4647
  *
2230
4648
  * The matcher is case-insensitive on the tool name so a persona that
2231
4649
  * spells the tool as `READ(...)` or `web_fetch(...)` still lands in
@@ -2240,9 +4658,9 @@ export function synthesiseToolCall(input) {
2240
4658
  if (detail.length === 0)
2241
4659
  return null;
2242
4660
  // Pattern: ToolName(args) optionally suffixed with a result hint.
2243
- // We allow the canonical Claude Code casing AND the snake_case
4661
+ // We allow the canonical the upstream tool casing AND the snake_case
2244
4662
  // alias `web_fetch` so the synthesiser matches what personas write.
2245
- const match = /^(Read|Edit|Bash|Grep|Glob|WebFetch|web_fetch)\s*\(\s*([^)]*)\s*\)\s*(.*)$/i
4663
+ const match = /^(Read|Write|Edit|Bash|Grep|Glob|WebFetch|web_fetch)\s*\(\s*([^)]*)\s*\)\s*(.*)$/i
2246
4664
  .exec(detail);
2247
4665
  if (!match)
2248
4666
  return null;
@@ -2260,12 +4678,32 @@ export function synthesiseToolCall(input) {
2260
4678
  startedAtEpochMs: input.now,
2261
4679
  };
2262
4680
  }
4681
+ /**
4682
+ * small-CC-parity batch : collapse a multi-line
4683
+ * result preview down to a single-line head capped at `max` chars. The
4684
+ * collapsed-result row on a completed tool call uses this so the
4685
+ * preview never expands the row vertically. Exported для the spec so
4686
+ * the truncation behaviour is locked down.
4687
+ */
4688
+ export function truncatePreview(value, max) {
4689
+ if (!value)
4690
+ return '';
4691
+ // Strip CR/LF + tab so the preview stays single-line. Multiple
4692
+ // whitespace runs collapse to single space — operator wants signal,
4693
+ // not formatting noise.
4694
+ const single = value.replace(/[\r\n\t]+/g, ' ').replace(/\s{2,}/g, ' ').trim();
4695
+ if (single.length <= max)
4696
+ return single;
4697
+ return `${single.slice(0, Math.max(0, max - 1))}…`;
4698
+ }
2263
4699
  function normaliseToolName(raw) {
2264
4700
  const lower = raw.toLowerCase();
2265
4701
  if (lower === 'webfetch' || lower === 'web_fetch')
2266
4702
  return 'web_fetch';
2267
4703
  if (lower === 'read')
2268
4704
  return 'read';
4705
+ if (lower === 'write')
4706
+ return 'write';
2269
4707
  if (lower === 'edit')
2270
4708
  return 'edit';
2271
4709
  if (lower === 'bash')
@@ -2296,12 +4734,12 @@ function parseStatusFromTail(tail) {
2296
4734
  return { status: 'ok', detail: tail };
2297
4735
  }
2298
4736
  /* ------------------------------------------------------------------ */
2299
- /* α6.3 office-hours encoders */
2300
- /* */
2301
- /* Mirrors `tui/ask-modal.tsx#encodeAskVerdict` so the session can */
2302
- /* synthesise the operator-side echo without dragging an Ink module */
2303
- /* into the test surface. The two encoders MUST agree byte-for-byte - */
2304
- /* a divergence would silently mis-prefix the persona's follow-up. */
4737
+ /* office-hours encoders */
4738
+ /* */
4739
+ /* Mirrors `tui/ask-modal.tsx#encodeAskVerdict` so the session can */
4740
+ /* synthesise the operator-side echo without dragging an Ink module */
4741
+ /* into the test surface. The two encoders MUST agree byte-for-byte - */
4742
+ /* a divergence would silently mis-prefix the persona's follow-up. */
2305
4743
  /* ------------------------------------------------------------------ */
2306
4744
  function encodeAskVerdictLocal(verdict) {
2307
4745
  if (verdict.cancelled)
@@ -2327,7 +4765,7 @@ function encodeAskVerdictLocal(verdict) {
2327
4765
  * `[ASK-RESPONSE:other] [ASK-RESPONSE:vercel] my real answer` which
2328
4766
  * a prefix-greedy persona could read as "operator chose vercel".
2329
4767
  *
2330
- * Claude triple-review P1 (PR #375).
4768
+ * Claude triple-review P1 (PR).
2331
4769
  */
2332
4770
  function sanitiseVerdictText(raw) {
2333
4771
  let cleaned = raw;
@@ -2385,7 +4823,7 @@ function encodePlanReviewVerdictLocal(result) {
2385
4823
  }
2386
4824
  /**
2387
4825
  * Compose the human-readable transcript line that records the
2388
- * operator's ask verdict. Mirrors Codex CLI's "you chose: <label>"
4826
+ * operator's ask verdict. Mirrors peer CLI's "you chose: <label>"
2389
4827
  * pattern so the conversation reads linearly.
2390
4828
  */
2391
4829
  function humanLabelForVerdict(tag, verdict) {
@@ -2433,7 +4871,7 @@ export function synthesiseLocalAskTag(question) {
2433
4871
  // Use the single-source signature helper so a persona-emitted ask
2434
4872
  // with the same question + same option values does not collide with
2435
4873
  // this synthesised one under a divergent algorithm. Claude
2436
- // triple-review P1 (PR #375).
4874
+ // triple-review P1 (PR).
2437
4875
  const signature = signatureForAsk(trimmed, options);
2438
4876
  return {
2439
4877
  question: trimmed,
@@ -2450,20 +4888,20 @@ export function synthesiseLocalAskTag(question) {
2450
4888
  * production callers go through `appendPersonaLine`.
2451
4889
  *
2452
4890
  * Examples (display name = "Pugi"):
2453
- * "Pugi, координатор Pugi. Брифую..." -> "координатор Pugi. Брифую..."
2454
- * "Pugi: вот результат" -> "вот результат"
2455
- * "<workspace-context-abc>Pugi, привет" -> "привет"
2456
- * "обычный ответ без префикса" -> "обычный ответ без префикса"
4891
+ * "Pugi, координатор Pugi. Брифую..." -> "координатор Pugi. Брифую..."
4892
+ * "Pugi: вот результат" -> "вот результат"
4893
+ * "<workspace-context-abc>Pugi, привет" -> "привет"
4894
+ * "обычный ответ без префикса" -> "обычный ответ без префикса"
2457
4895
  *
2458
4896
  * The strip is conservative - we only remove the display name when it
2459
4897
  * is followed by a separator (comma, colon, dash, space) so a sentence
2460
4898
  * that legitimately contains the name mid-text ("спроси у Pugi") is
2461
- * not mangled. (α6.14.2 wave 5 - CEO dogfood fix.)
4899
+ * not mangled.
2462
4900
  */
2463
4901
  export function stripPersonaPrefixEcho(personaSlug, text) {
2464
4902
  let working = text.trimStart();
2465
4903
  // Drop any leaked `<workspace-context-...>` / `</workspace-context-...>`
2466
- // wrapper at the head. The Mira prompt v1.1 sometimes echoes the
4904
+ // wrapper at the head. The Pugi prompt v1.1 sometimes echoes the
2467
4905
  // scaffolding envelope back when the model is warm-starting the
2468
4906
  // first turn; cosmetic noise the operator never needs to see.
2469
4907
  // We strip both opening tag and any text up to (and including) the
@@ -2491,7 +4929,22 @@ export function stripPersonaPrefixEcho(personaSlug, text) {
2491
4929
  // Escape regex specials in the display name even though THE_TEN
2492
4930
  // names are alpha-only today (forward-defense).
2493
4931
  const escaped = display.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
4932
+ // Match `<DisplayName>` (case-insensitive) followed by EITHER:
4933
+ // - an end-of-string, OR
4934
+ // - a separator (whitespace / comma / colon / dash / period+space).
4935
+ // The `i` flag is needed so a model writing "PUGI:" or "pugi," still
4936
+ // strips. After this match the post-fix `noSepUppercaseRe` handles
4937
+ // the "PugiПринял" / "PugiHello" no-separator emission pattern
4938
+ // (CEO red-alert) using a SEPARATE regex without the `i`
4939
+ // flag so the lookahead is case-strict (Pugineous must NOT strip).
2494
4940
  const re = new RegExp(`^${escaped}(?:[\\s,:;\\-—–]+|$)`, 'i');
4941
+ // No-separator case-strict matcher. Display name in either of its
4942
+ // canonical casings ("Pugi" / "PUGI") immediately followed by an
4943
+ // uppercase Cyrillic or Latin letter. The strip is intentionally
4944
+ // narrower than the case-insensitive `re` above because a lowercase
4945
+ // continuation ("Pugineous") is a single word, not a display-name
4946
+ // echo - we must not eat real content.
4947
+ const noSepUppercaseRe = new RegExp(`^(?:${escaped}|${escaped.toUpperCase()})(?=[А-ЯЁA-Z])`);
2495
4948
  // Loop the strip so cascading echoes ("Pugi Pugi Pugi, координатор ...")
2496
4949
  // collapse to a single name. The model occasionally emits the display
2497
4950
  // name two or three times back-to-back when the pane prefix also
@@ -2503,10 +4956,18 @@ export function stripPersonaPrefixEcho(personaSlug, text) {
2503
4956
  // matches an empty string (defence-in-depth even though the current
2504
4957
  // pattern guarantees at least one consumed char).
2505
4958
  for (let i = 0; i < 3; i += 1) {
2506
- const m = re.exec(working);
2507
- if (!m || m[0].length === 0)
2508
- break;
2509
- working = working.slice(m[0].length).trimStart();
4959
+ let m = re.exec(working);
4960
+ if (m && m[0].length > 0) {
4961
+ working = working.slice(m[0].length).trimStart();
4962
+ continue;
4963
+ }
4964
+ // Fallback: no-separator match for "PugiПринял" / "PugiHello" shape.
4965
+ m = noSepUppercaseRe.exec(working);
4966
+ if (m && m[0].length > 0) {
4967
+ working = working.slice(m[0].length);
4968
+ continue;
4969
+ }
4970
+ break;
2510
4971
  }
2511
4972
  return working;
2512
4973
  }