@pugi/cli 0.1.0-beta.8 → 0.1.0-beta.88
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.
- package/CHANGELOG.md +132 -0
- package/LICENSE +1 -1
- package/THIRD_PARTY_NOTICES.md +40 -0
- package/assets/pugi-prozr2-mascot.ansi +9 -0
- package/bin/run.js +33 -1
- package/dist/commands/deploy.js +40 -40
- package/dist/commands/flatten.js +191 -0
- package/dist/commands/jobs-watch.js +201 -0
- package/dist/commands/jobs.js +42 -27
- package/dist/commands/smoke.js +133 -0
- package/dist/core/agent-progress/cleanup.js +134 -0
- package/dist/core/agent-progress/schema.js +144 -0
- package/dist/core/agent-progress/writer.js +101 -0
- package/dist/core/agents/adaptive-router.js +330 -0
- package/dist/core/agents/query-decomposer.js +297 -0
- package/dist/core/agents/registry.js +3 -3
- package/dist/core/approvals/shortcut-resolver.js +98 -0
- package/dist/core/artifact-chain/dispatcher.js +148 -0
- package/dist/core/artifact-chain/exporter.js +164 -0
- package/dist/core/artifact-chain/state.js +243 -0
- package/dist/core/artifact-chain/steps.js +169 -0
- package/dist/core/ask-user/question.js +92 -0
- package/dist/core/audit/audit-trail.js +275 -0
- package/dist/core/auth/ensure-authenticated.js +129 -0
- package/dist/core/auth/env-provider.js +238 -0
- package/dist/core/auto-open-browser.js +4 -4
- package/dist/core/auto-update/channels.js +122 -0
- package/dist/core/auto-update/checker.js +241 -0
- package/dist/core/auto-update/state.js +235 -0
- package/dist/core/bare-mode/index.js +107 -0
- package/dist/core/bash/redirect.js +281 -0
- package/dist/core/bash-classifier.js +436 -40
- package/dist/core/checkpoint/resumer.js +149 -0
- package/dist/core/checkpoint/rewinder.js +291 -0
- package/dist/core/checkpoints/shadow-git.js +670 -0
- package/dist/core/citations/parser.js +109 -0
- package/dist/core/classifier/yolo-classifier.js +88 -0
- package/dist/core/codegraph/decision-store.js +248 -0
- package/dist/core/codegraph/detect-repo.js +459 -0
- package/dist/core/codegraph/install.js +134 -0
- package/dist/core/codegraph/offer-hook.js +220 -0
- package/dist/core/compact/auto-trigger.js +96 -0
- package/dist/core/compact/buffer-rewriter.js +115 -0
- package/dist/core/compact/summarizer.js +208 -0
- package/dist/core/compact/token-counter.js +108 -0
- package/dist/core/consensus/anvil-fanout.js +25 -25
- package/dist/core/consensus/diff-capture.js +121 -12
- package/dist/core/consensus/rubric.js +21 -21
- package/dist/core/context/builder.js +6 -6
- package/dist/core/context/compaction-events.js +8 -8
- package/dist/core/context/compaction.js +31 -31
- package/dist/core/context/index.js +15 -8
- package/dist/core/context/invariants.js +51 -51
- package/dist/core/context/markdown-loader.js +28 -10
- package/dist/core/context/markdown-traverse.js +255 -0
- package/dist/core/context/pugiignore.js +41 -41
- package/dist/core/context/repo-skeleton.js +37 -37
- package/dist/core/context/tool-eviction.js +55 -0
- package/dist/core/context/watcher.js +32 -32
- package/dist/core/context/working-set.js +23 -23
- package/dist/core/coordinator/agent-tools.js +77 -0
- package/dist/core/coordinator/agent-toolset.js +65 -0
- package/dist/core/coordinator/fsm.js +73 -0
- package/dist/core/coordinator/mode-fsm.js +70 -0
- package/dist/core/cost/rate-card.js +129 -0
- package/dist/core/cost/tracker.js +221 -0
- package/dist/core/credentials.js +12 -12
- package/dist/core/cron/scheduler.js +138 -0
- package/dist/core/denial-tracking/index.js +8 -0
- package/dist/core/denial-tracking/state.js +264 -0
- package/dist/core/diagnostics/probe-runner.js +93 -0
- package/dist/core/diagnostics/probes/api.js +46 -0
- package/dist/core/diagnostics/probes/auth.js +93 -0
- package/dist/core/diagnostics/probes/bare-mode.js +42 -0
- package/dist/core/diagnostics/probes/cli-version.js +127 -0
- package/dist/core/diagnostics/probes/config.js +72 -0
- package/dist/core/diagnostics/probes/denial-tracking.js +57 -0
- package/dist/core/diagnostics/probes/disk.js +81 -0
- package/dist/core/diagnostics/probes/engine-live.js +46 -0
- package/dist/core/diagnostics/probes/git.js +65 -0
- package/dist/core/diagnostics/probes/hooks.js +118 -0
- package/dist/core/diagnostics/probes/mcp.js +75 -0
- package/dist/core/diagnostics/probes/node.js +59 -0
- package/dist/core/diagnostics/probes/pnpm.js +36 -0
- package/dist/core/diagnostics/probes/pugi-md.js +89 -0
- package/dist/core/diagnostics/probes/sandbox.js +40 -0
- package/dist/core/diagnostics/probes/session.js +74 -0
- package/dist/core/diagnostics/probes/status-snapshot.js +488 -0
- package/dist/core/diagnostics/probes/workspace.js +63 -0
- package/dist/core/diagnostics/types.js +70 -0
- package/dist/core/dispatch/cache-cleanup.js +197 -0
- package/dist/core/dispatch/cache-handoff.js +295 -0
- package/dist/core/edits/apply-patch-layer-e.js +189 -0
- package/dist/core/edits/dispatch.js +293 -7
- package/dist/core/edits/format-matrix.js +26 -0
- package/dist/core/edits/fuzzy-ladder.js +650 -0
- package/dist/core/edits/index.js +3 -1
- package/dist/core/edits/journal.js +199 -0
- package/dist/core/edits/layer-a-apply.js +15 -15
- package/dist/core/edits/layer-a-fuzzy-apply.js +198 -0
- package/dist/core/edits/layer-b-apply.js +9 -9
- package/dist/core/edits/layer-c-apply.js +6 -6
- package/dist/core/edits/layer-d-ast.js +557 -14
- package/dist/core/edits/marker-parser.js +12 -12
- package/dist/core/edits/security-gate.js +27 -27
- package/dist/core/edits/verify-hook.js +273 -0
- package/dist/core/edits/worktree.js +322 -0
- package/dist/core/engine/anvil-client.js +151 -26
- package/dist/core/engine/auto-compact.js +179 -0
- package/dist/core/engine/budgets.js +186 -0
- package/dist/core/engine/context-prefix.js +155 -0
- package/dist/core/engine/index.js +1 -1
- package/dist/core/engine/intensity.js +158 -0
- package/dist/core/engine/intent.js +260 -0
- package/dist/core/engine/native-pugi.js +1295 -227
- package/dist/core/engine/prompts.js +134 -16
- package/dist/core/engine/strip-internal-fields.js +124 -0
- package/dist/core/engine/tool-bridge.js +1295 -59
- package/dist/core/evaluation/golden-dataset.js +293 -0
- package/dist/core/feedback/queue.js +177 -0
- package/dist/core/feedback/submitter.js +145 -0
- package/dist/core/file-cache.js +113 -1
- package/dist/core/flatten/flatten-repo.js +439 -0
- package/dist/core/format/osc8-link.js +28 -0
- package/dist/core/hook-chains.js +392 -0
- package/dist/core/hooks/citation-verify-hook.js +138 -0
- package/dist/core/hooks/citation-verify.js +112 -0
- package/dist/core/hooks/events.js +44 -0
- package/dist/core/hooks/index.js +15 -0
- package/dist/core/hooks/registry.js +213 -0
- package/dist/core/hooks/runner.js +236 -0
- package/dist/core/hooks/v2/event-emitter.js +115 -0
- package/dist/core/hooks/v2/executor.js +282 -0
- package/dist/core/hooks/v2/index.js +25 -0
- package/dist/core/hooks/v2/lifecycle.js +104 -0
- package/dist/core/hooks/v2/loader.js +216 -0
- package/dist/core/hooks/v2/matcher.js +125 -0
- package/dist/core/hooks/v2/trust.js +143 -0
- package/dist/core/hooks/v2/types.js +86 -0
- package/dist/core/image/renderer.js +71 -0
- package/dist/core/init/detector.js +582 -0
- package/dist/core/init/template-renderer.js +242 -0
- package/dist/core/jobs/registry.js +18 -18
- package/dist/core/ledger/results-tsv.js +142 -0
- package/dist/core/log-discipline/stdout-redirect.js +51 -0
- package/dist/core/lsp/cache.js +105 -0
- package/dist/core/lsp/client.js +776 -0
- package/dist/core/lsp/language-detect.js +66 -0
- package/dist/core/lsp/post-edit-diagnostics.js +171 -0
- package/dist/core/lsp/symbol-tools.js +372 -0
- package/dist/core/mcp/client.js +97 -28
- package/dist/core/mcp/http-server.js +553 -0
- package/dist/core/mcp/orchestrator-tools.js +662 -0
- package/dist/core/mcp/permission.js +190 -0
- package/dist/core/mcp/registry.js +39 -17
- package/dist/core/mcp/server-tools.js +219 -0
- package/dist/core/mcp/server.js +397 -0
- package/dist/core/mcp/trust.js +10 -10
- package/dist/core/memory/dual-write.js +416 -0
- package/dist/core/memory/passive-extract.js +130 -0
- package/dist/core/memory/phase1-kinds.js +20 -0
- package/dist/core/memory/secret-scanner.js +304 -0
- package/dist/core/memory-sync/queue.js +170 -0
- package/dist/core/metrics/extract.js +113 -0
- package/dist/core/modes/roo-modes.js +68 -0
- package/dist/core/onboarding/ensure-initialized.js +133 -0
- package/dist/core/onboarding/marker.js +111 -0
- package/dist/core/onboarding/telemetry-state.js +108 -0
- package/dist/core/output-style/presets.js +176 -0
- package/dist/core/output-style/state.js +185 -0
- package/dist/core/path-security.js +287 -5
- package/dist/core/permission.js +82 -22
- package/dist/core/permissions/auto-classifier.js +124 -0
- package/dist/core/permissions/bash-parser.js +371 -0
- package/dist/core/permissions/circuit-breaker.js +83 -0
- package/dist/core/permissions/constrained-edit.js +91 -0
- package/dist/core/permissions/gate.js +278 -0
- package/dist/core/permissions/index.js +20 -0
- package/dist/core/permissions/mode.js +174 -0
- package/dist/core/permissions/network-egress.js +137 -0
- package/dist/core/permissions/state.js +241 -0
- package/dist/core/permissions/tool-class.js +93 -0
- package/dist/core/plan-mode/ui-state.js +51 -0
- package/dist/core/plans/plan-artifact.js +721 -0
- package/dist/core/policy-limits/etag-store.js +122 -0
- package/dist/core/prd-check/parser.js +215 -0
- package/dist/core/prd-check/reporter.js +127 -0
- package/dist/core/prd-check/session-review.js +557 -0
- package/dist/core/prd-check/verifiers.js +223 -0
- package/dist/core/prompt-cache/client-cache.js +99 -0
- package/dist/core/prompts/assembly.js +29 -0
- package/dist/core/prompts/registry.js +364 -0
- package/dist/core/pugi-md/cc-compat-rules.js +735 -0
- package/dist/core/pugi-md/context-injector.js +76 -0
- package/dist/core/pugi-md/walk-up.js +207 -0
- package/dist/core/python/uv-installer.js +270 -0
- package/dist/core/python/uv-resolver.js +83 -0
- package/dist/core/rate-limit/narrator.js +146 -0
- package/dist/core/recipes/cli-types.js +20 -0
- package/dist/core/recipes/loader.js +103 -0
- package/dist/core/recipes/runner.js +345 -0
- package/dist/core/recipes/schema.js +587 -0
- package/dist/core/release-notes/parser.js +241 -0
- package/dist/core/release-notes/state.js +116 -0
- package/dist/core/repl/ask.js +37 -37
- package/dist/core/repl/cancellation.js +26 -26
- package/dist/core/repl/cap-warning.js +4 -4
- package/dist/core/repl/clipboard-read.js +11 -11
- package/dist/core/repl/dispatch-fsm.js +12 -12
- package/dist/core/repl/history-search.js +15 -15
- package/dist/core/repl/history.js +28 -18
- package/dist/core/repl/kill-ring.js +5 -5
- package/dist/core/repl/model-pricing.js +135 -0
- package/dist/core/repl/privacy-banner.js +22 -22
- package/dist/core/repl/session.js +2157 -214
- package/dist/core/repl/slash-commands.js +533 -40
- package/dist/core/repl/store/index.js +1 -1
- package/dist/core/repl/store/jsonl-log.js +22 -22
- package/dist/core/repl/store/lockfile.js +10 -10
- package/dist/core/repl/store/session-store.js +136 -107
- package/dist/core/repl/store/types.js +15 -15
- package/dist/core/repl/store/uuid-v7.js +12 -12
- package/dist/core/repl/workspace-context.js +43 -21
- package/dist/core/repo-map/build.js +125 -0
- package/dist/core/repo-map/cache.js +185 -0
- package/dist/core/repo-map/extractor.js +254 -0
- package/dist/core/repo-map/formatter.js +145 -0
- package/dist/core/repo-map/page-rank.js +105 -0
- package/dist/core/repo-map/scanner.js +211 -0
- package/dist/core/retry-budget/budget.js +284 -0
- package/dist/core/retry-budget/index.js +5 -0
- package/dist/core/retry-budget/retry-cap.js +74 -0
- package/dist/core/routing/lead-worker.js +43 -0
- package/dist/core/routing/pre-flight-estimator.js +108 -0
- package/dist/core/runs/run-tree.js +103 -0
- package/dist/core/security/injection-scanner.js +367 -0
- package/dist/core/security/output-filter.js +418 -0
- package/dist/core/session/env-file.js +105 -0
- package/dist/core/session/section-budgets.js +140 -0
- package/dist/core/session.js +92 -0
- package/dist/core/settings.js +298 -5
- package/dist/core/share/formatter.js +271 -0
- package/dist/core/share/redactor.js +221 -0
- package/dist/core/share/uploader.js +267 -0
- package/dist/core/skills/defaults.js +457 -0
- package/dist/core/skills/loader.js +22 -22
- package/dist/core/skills/sources.js +27 -27
- package/dist/core/smoke/headless-driver.js +174 -0
- package/dist/core/smoke/orchestrator.js +194 -0
- package/dist/core/smoke/runner.js +238 -0
- package/dist/core/smoke/scenario-parser.js +316 -0
- package/dist/core/statusline.js +99 -0
- package/dist/core/subagents/dispatcher-real.js +600 -0
- package/dist/core/subagents/dispatcher.js +132 -43
- package/dist/core/subagents/index.js +19 -6
- package/dist/core/subagents/isolation-matrix.js +213 -0
- package/dist/core/subagents/spawn.js +19 -4
- package/dist/core/telemetry/emitter.js +229 -0
- package/dist/core/telemetry/queue.js +251 -0
- package/dist/core/theme/context.js +91 -0
- package/dist/core/theme/presets.js +228 -0
- package/dist/core/theme/state.js +181 -0
- package/dist/core/todos/invariant.js +10 -0
- package/dist/core/todos/state.js +177 -0
- package/dist/core/tool-schema/compressor.js +89 -0
- package/dist/core/transport/version-interceptor.js +166 -0
- package/dist/core/trust.js +2 -2
- package/dist/core/tui/thinking-block.js +64 -0
- package/dist/core/vim/keymap.js +288 -0
- package/dist/core/vim/state.js +92 -0
- package/dist/core/watch-markers/marker-watcher.js +133 -0
- package/dist/core/worktree-manager/cleanup.js +123 -0
- package/dist/core/worktree-manager/manager.js +303 -0
- package/dist/index.js +36 -0
- package/dist/runtime/bootstrap.js +190 -0
- package/dist/runtime/cli.js +4203 -493
- package/dist/runtime/commands/agents.js +30 -30
- package/dist/runtime/commands/budget.js +5 -5
- package/dist/runtime/commands/cancel.js +231 -0
- package/dist/runtime/commands/chain.js +489 -0
- package/dist/runtime/commands/codegraph-status.js +227 -0
- package/dist/runtime/commands/compact.js +297 -0
- package/dist/runtime/commands/config.js +73 -39
- package/dist/runtime/commands/cost.js +199 -0
- package/dist/runtime/commands/delegate.js +244 -13
- package/dist/runtime/commands/dispatch.js +126 -0
- package/dist/runtime/commands/doctor.js +579 -0
- package/dist/runtime/commands/feedback.js +184 -0
- package/dist/runtime/commands/hooks.js +184 -0
- package/dist/runtime/commands/init.js +254 -0
- package/dist/runtime/commands/lsp.js +368 -0
- package/dist/runtime/commands/mcp.js +879 -0
- package/dist/runtime/commands/memory.js +582 -0
- package/dist/runtime/commands/model.js +237 -0
- package/dist/runtime/commands/onboarding.js +275 -0
- package/dist/runtime/commands/patch.js +128 -0
- package/dist/runtime/commands/permissions.js +112 -0
- package/dist/runtime/commands/plan.js +143 -0
- package/dist/runtime/commands/prd-check.js +285 -0
- package/dist/runtime/commands/privacy.js +17 -17
- package/dist/runtime/commands/recipe.js +325 -0
- package/dist/runtime/commands/redo-blob-store.js +92 -0
- package/dist/runtime/commands/redo.js +361 -0
- package/dist/runtime/commands/release-notes.js +229 -0
- package/dist/runtime/commands/repo-map.js +95 -0
- package/dist/runtime/commands/report.js +299 -0
- package/dist/runtime/commands/resume.js +118 -0
- package/dist/runtime/commands/review-consensus.js +68 -53
- package/dist/runtime/commands/rewind.js +333 -0
- package/dist/runtime/commands/roster.js +14 -14
- package/dist/runtime/commands/sessions.js +163 -0
- package/dist/runtime/commands/share.js +316 -0
- package/dist/runtime/commands/skills.js +31 -31
- package/dist/runtime/commands/status.js +186 -0
- package/dist/runtime/commands/stickers.js +82 -0
- package/dist/runtime/commands/style.js +194 -0
- package/dist/runtime/commands/theme.js +196 -0
- package/dist/runtime/commands/undo.js +54 -22
- package/dist/runtime/commands/update.js +289 -0
- package/dist/runtime/commands/vim.js +140 -0
- package/dist/runtime/commands/worktree.js +177 -0
- package/dist/runtime/commands/worktrees.js +155 -0
- package/dist/runtime/headless-repl.js +195 -0
- package/dist/runtime/headless.js +543 -0
- package/dist/runtime/load-hooks-or-exit.js +71 -0
- package/dist/runtime/plan-decompose.js +531 -0
- package/dist/runtime/sigint-guard.js +272 -0
- package/dist/runtime/update-check.js +28 -28
- package/dist/runtime/version.js +65 -0
- package/dist/skills/bundled/batch.js +617 -0
- package/dist/skills/bundled/index.js +45 -0
- package/dist/skills/bundled/loop.js +358 -0
- package/dist/skills/bundled/remember.js +383 -0
- package/dist/skills/bundled/simplify.js +289 -0
- package/dist/skills/bundled/skillify.js +373 -0
- package/dist/skills/bundled/stuck.js +558 -0
- package/dist/skills/bundled/verify.js +439 -0
- package/dist/testing/vcr.js +486 -0
- package/dist/tools/agent-tool.js +229 -0
- package/dist/tools/apply-patch.js +556 -0
- package/dist/tools/ask-user-question.js +288 -0
- package/dist/tools/ask-user.js +115 -0
- package/dist/tools/bash.js +624 -46
- package/dist/tools/brief.js +224 -0
- package/dist/tools/enter-worktree.js +250 -0
- package/dist/tools/exit-worktree.js +147 -0
- package/dist/tools/file-tools.js +161 -44
- package/dist/tools/lsp-tools.js +189 -0
- package/dist/tools/mcp-tool.js +260 -0
- package/dist/tools/multi-edit.js +361 -0
- package/dist/tools/powershell.js +268 -0
- package/dist/tools/registry.js +85 -0
- package/dist/tools/skill-tool.js +96 -0
- package/dist/tools/sleep.js +99 -0
- package/dist/tools/synthetic-output.js +133 -0
- package/dist/tools/tasks.js +208 -0
- package/dist/tools/todo-write.js +184 -0
- package/dist/tools/verify-plan-execution.js +295 -0
- package/dist/tools/web-fetch-injection-scanner.js +207 -0
- package/dist/tools/web-fetch.js +195 -10
- package/dist/tools/web-search.js +458 -0
- package/dist/tui/agent-progress-card.js +111 -0
- package/dist/tui/agent-tree.js +11 -1
- package/dist/tui/ask-modal.js +14 -14
- package/dist/tui/ask-user-question-chips.js +257 -0
- package/dist/tui/ask-user-question-prompt.js +203 -0
- package/dist/tui/compact-banner.js +81 -0
- package/dist/tui/conversation-pane.js +85 -11
- package/dist/tui/cost-table.js +111 -0
- package/dist/tui/device-flow.js +2 -2
- package/dist/tui/doctor-table.js +46 -0
- package/dist/tui/feedback-prompt.js +156 -0
- package/dist/tui/input-box.js +247 -32
- package/dist/tui/login-picker.js +3 -3
- package/dist/tui/markdown-render.js +6 -6
- package/dist/tui/onboarding-wizard.js +240 -0
- package/dist/tui/permissions-picker.js +86 -0
- package/dist/tui/render.js +35 -0
- package/dist/tui/repl-render.js +332 -54
- package/dist/tui/repl-splash-art.js +16 -16
- package/dist/tui/repl-splash-mascot.js +48 -24
- package/dist/tui/repl-splash.js +22 -22
- package/dist/tui/repl.js +124 -44
- package/dist/tui/slash-palette.js +6 -6
- package/dist/tui/splash.js +2 -2
- package/dist/tui/status-bar.js +109 -31
- package/dist/tui/status-table.js +7 -0
- package/dist/tui/stickers-art.js +136 -0
- package/dist/tui/style-table.js +28 -0
- package/dist/tui/theme-table.js +29 -0
- package/dist/tui/thinking-spinner.js +123 -0
- package/dist/tui/tool-stream-pane.js +53 -4
- package/dist/tui/update-banner.js +27 -2
- package/dist/tui/vim-input.js +267 -0
- package/dist/tui/welcome-banner.js +107 -0
- package/dist/tui/welcome-data.js +293 -0
- package/dist/tui/workspace-context.js +2 -2
- package/docs/examples/codegraph.mcp.json +10 -0
- package/package.json +25 -7
- package/test/scenarios/codegen-create-file.scenario.txt +13 -0
- package/test/scenarios/compact-force.scenario.txt +11 -0
- package/test/scenarios/identity.scenario.txt +11 -0
- package/test/scenarios/persona-handoff.scenario.txt +11 -0
- package/test/scenarios/walkback.scenario.txt +12 -0
- package/dist/core/engine/compaction-hook.js +0 -154
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* REPL session lifecycle - Sprint
|
|
2
|
+
* REPL session lifecycle - Sprint .
|
|
3
3
|
*
|
|
4
4
|
* Owns the state machine that the REPL UI subscribes to:
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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 (
|
|
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,39 @@ import { parseSlashCommand } from './slash-commands.js';
|
|
|
34
35
|
import { webFetchTool } from '../../tools/web-fetch.js';
|
|
35
36
|
import { loadSettings } from '../settings.js';
|
|
36
37
|
import { getJobRegistry } from '../jobs/registry.js';
|
|
38
|
+
import { applyCompactMask } from '../compact/buffer-rewriter.js';
|
|
39
|
+
import { applyRewindMask } from '../checkpoint/rewinder.js';
|
|
40
|
+
import { evaluateAutoCompact } from '../compact/auto-trigger.js';
|
|
41
|
+
import { estimateTokensInMany } from '../compact/token-counter.js';
|
|
37
42
|
import { extractAskTags, extractPlanReviewTags, signatureForAsk, } from './ask.js';
|
|
38
43
|
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
39
44
|
import { resolve as resolvePath } from 'node:path';
|
|
40
45
|
import { CancellationToken } from './cancellation.js';
|
|
41
46
|
import { DispatchFSM } from './dispatch-fsm.js';
|
|
47
|
+
import { computeCostUsd, formatCostUsd, formatTokens } from './model-pricing.js';
|
|
42
48
|
const MAX_TRANSCRIPT_ROWS = 500;
|
|
43
49
|
const MAX_TOOL_CALLS = 200;
|
|
50
|
+
/**
|
|
51
|
+
* small-CC-parity batch : width cap for the inline
|
|
52
|
+
* `streamingDelta` tail rendered next to the args while the call is
|
|
53
|
+
* `running`. Keeps the tool-stream row single-line on an 80-col
|
|
54
|
+
* terminal even when Bash output blasts through stdout. Exported so the
|
|
55
|
+
* spec can pin the truncation behaviour.
|
|
56
|
+
*/
|
|
57
|
+
export const STREAMING_DELTA_MAX_CHARS = 80;
|
|
58
|
+
/**
|
|
59
|
+
* small-CC-parity batch : character cap for the
|
|
60
|
+
* collapsed `resultPreview` on a completed row. The pane shows
|
|
61
|
+
* `✓ Read(file) OK (2ms) "first 50 chars…"` so the operator sees what
|
|
62
|
+
* the tool produced without expanding. Per CEO spec (50 chars).
|
|
63
|
+
* Exported so the spec + the pane share one source of truth.
|
|
64
|
+
*/
|
|
65
|
+
export const RESULT_PREVIEW_MAX_CHARS = 50;
|
|
44
66
|
const MAX_RECONNECT_ATTEMPTS = 10;
|
|
45
67
|
const RECONNECT_BASE_MS = 250;
|
|
46
68
|
const RECONNECT_MAX_MS = 5_000;
|
|
47
69
|
/**
|
|
48
|
-
*
|
|
70
|
+
* filewatch throttle: minimum gap between two file-change
|
|
49
71
|
* system lines surfaced in the conversation pane. Per the sprint
|
|
50
72
|
* spec, a noisy save burst should not flood the transcript - we
|
|
51
73
|
* coalesce all chokidar batches that arrive inside the window into
|
|
@@ -60,7 +82,7 @@ const FILEWATCH_SYSTEM_LINE_GAP_MS = 5_000;
|
|
|
60
82
|
* would accumulate forever, holding refs to thousands of FilewatchBatch
|
|
61
83
|
* objects (each carrying its own events array). On overflow we drop
|
|
62
84
|
* the OLDEST batch and surface a one-shot system warning so the
|
|
63
|
-
* operator knows the buffer is shedding. triple-review P1 (PR
|
|
85
|
+
* operator knows the buffer is shedding. triple-review P1 (PR).
|
|
64
86
|
*/
|
|
65
87
|
const PENDING_FILEWATCH_BATCH_CAP = 100;
|
|
66
88
|
/**
|
|
@@ -70,7 +92,7 @@ const PENDING_FILEWATCH_BATCH_CAP = 100;
|
|
|
70
92
|
* CLI mints a fresh server session, swaps the consumer over, and
|
|
71
93
|
* keeps running - but we cap the recovery to 3 attempts inside 60s
|
|
72
94
|
* so a truly down admin-api fails loud instead of spinning forever.
|
|
73
|
-
*
|
|
95
|
+
*
|
|
74
96
|
*/
|
|
75
97
|
const MAX_SESSION_RECREATE_ATTEMPTS = 3;
|
|
76
98
|
const SESSION_RECREATE_WINDOW_MS = 60_000;
|
|
@@ -90,13 +112,13 @@ export class ReplSession {
|
|
|
90
112
|
* with "Stream interrupted (HTTP 404)" loops, we mint a fresh
|
|
91
113
|
* session and swap the consumer. Capped at MAX_SESSION_RECREATE_*
|
|
92
114
|
* inside SESSION_RECREATE_WINDOW_MS so a permanently down admin-api
|
|
93
|
-
* fails loud instead of looping silently.
|
|
115
|
+
* fails loud instead of looping silently.
|
|
94
116
|
*/
|
|
95
117
|
recentRecreateAtMs = [];
|
|
96
118
|
/**
|
|
97
119
|
* True while a session-recreate POST is in flight. Guards against
|
|
98
120
|
* the SSE stream firing multiple `onError(404)` callbacks racing
|
|
99
|
-
* the in-flight createSession promise.
|
|
121
|
+
* the in-flight createSession promise.
|
|
100
122
|
*/
|
|
101
123
|
recreatingSession = false;
|
|
102
124
|
/**
|
|
@@ -108,11 +130,11 @@ export class ReplSession {
|
|
|
108
130
|
* `shipped.` - the actual reply text was lost. By caching the last
|
|
109
131
|
* non-trivial detail here, we can flush it into the transcript when
|
|
110
132
|
* the agent completes so the operator sees what the persona actually
|
|
111
|
-
* said. CEO wave-2 fix
|
|
133
|
+
* said. CEO wave-2 fix.
|
|
112
134
|
*/
|
|
113
135
|
lastStepDetail = new Map();
|
|
114
136
|
/**
|
|
115
|
-
* Optional local SessionStore -
|
|
137
|
+
* Optional local SessionStore - . When non-null, every
|
|
116
138
|
* appendRow() call mirrors the row into the JSONL log so the
|
|
117
139
|
* conversation can be restored via `/resume`. Errors from the store
|
|
118
140
|
* are swallowed to a single system line (degradation, not crash).
|
|
@@ -146,20 +168,20 @@ export class ReplSession {
|
|
|
146
168
|
* `/privacy` slash falls back to the contract doc with an "unknown"
|
|
147
169
|
* banner when null.
|
|
148
170
|
*
|
|
149
|
-
* Triple-review P1 fix
|
|
171
|
+
* Triple-review P1 fix : the prior build defined
|
|
150
172
|
* `renderPrivacyBanner` but never called it, and `/privacy` always
|
|
151
173
|
* rendered with `null` mode. The contract was advertised but the
|
|
152
174
|
* operator had no mode visibility.
|
|
153
175
|
*/
|
|
154
176
|
privacyMode = null;
|
|
155
177
|
/**
|
|
156
|
-
*
|
|
178
|
+
* Tier 0 / Tier 1 / chokidar wiring. The bootstrap builds the
|
|
157
179
|
* skeleton + working set + watcher once and hands them to the
|
|
158
180
|
* session. The session uses them to:
|
|
159
181
|
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
182
|
+
* - render `/context` (count + cap + total bytes + skeleton size).
|
|
183
|
+
* - emit throttled "file changed" system lines on watcher batches.
|
|
184
|
+
* - forget removed files from the working set on `unlink`.
|
|
163
185
|
*
|
|
164
186
|
* All three are optional - tests and minimal callers pass null /
|
|
165
187
|
* undefined and the session degrades to "no three-tier integration"
|
|
@@ -181,7 +203,7 @@ export class ReplSession {
|
|
|
181
203
|
* a summary that mentions how many additional files were touched.
|
|
182
204
|
* Capped at PENDING_FILEWATCH_BATCH_CAP to bound memory growth
|
|
183
205
|
* under long-running noisy filewatch sources (tsc --watch on a
|
|
184
|
-
* 200-file project hammering for hours). triple-review P1 (PR
|
|
206
|
+
* 200-file project hammering for hours). triple-review P1 (PR).
|
|
185
207
|
*/
|
|
186
208
|
pendingFilewatchBatches = [];
|
|
187
209
|
/**
|
|
@@ -198,7 +220,7 @@ export class ReplSession {
|
|
|
198
220
|
* session.close() and watcher.close() does not run handlers on a
|
|
199
221
|
* dead session. Without detachment, recordFilewatchBatch would
|
|
200
222
|
* touch this.workingSet / this.transcript on a closed session.
|
|
201
|
-
* triple-review P1 (PR
|
|
223
|
+
* triple-review P1 (PR).
|
|
202
224
|
*/
|
|
203
225
|
filewatchBatchHandler = (batch) => {
|
|
204
226
|
this.recordFilewatchBatch(batch);
|
|
@@ -211,7 +233,7 @@ export class ReplSession {
|
|
|
211
233
|
* signatures. The persona may emit the same envelope twice on network
|
|
212
234
|
* retry; we suppress the duplicate so the operator does not see two
|
|
213
235
|
* stacked modals. Capped at 32 entries - generous for a real session,
|
|
214
|
-
* defensive against a hostile flood.
|
|
236
|
+
* defensive against a hostile flood.
|
|
215
237
|
*/
|
|
216
238
|
seenTagSignatures = [];
|
|
217
239
|
/**
|
|
@@ -219,11 +241,11 @@ export class ReplSession {
|
|
|
219
241
|
* `<pugi-ask>` open and close tags may arrive in separate
|
|
220
242
|
* `agent.step` events when the upstream LLM token-streams output
|
|
221
243
|
* char-by-char. We accumulate the running detail per taskId until a
|
|
222
|
-
* complete envelope lands OR the turn ends.
|
|
244
|
+
* complete envelope lands OR the turn ends.
|
|
223
245
|
*/
|
|
224
246
|
askBuffer = new Map();
|
|
225
247
|
/**
|
|
226
|
-
*
|
|
248
|
+
* dispatch FSM. One instance owned by the session; transitions
|
|
227
249
|
* are mirrored into `state.dispatchState` via an onEnter listener so
|
|
228
250
|
* subscribers see every change. Resets to `idle` after a terminal
|
|
229
251
|
* transition (`completed` / `failed` / `aborted`) so the next brief
|
|
@@ -236,7 +258,7 @@ export class ReplSession {
|
|
|
236
258
|
// accessor - callers cannot reach into this private field.
|
|
237
259
|
fsm = new DispatchFSM();
|
|
238
260
|
/**
|
|
239
|
-
*
|
|
261
|
+
* cancellation token for the currently in-flight dispatch.
|
|
240
262
|
* Minted on `dispatchBrief()` and released on terminal transitions.
|
|
241
263
|
* When non-null, calling `cancel()` aborts the token, closes the SSE
|
|
242
264
|
* stream, and transitions the FSM to `aborting` → `aborted`.
|
|
@@ -245,7 +267,7 @@ export class ReplSession {
|
|
|
245
267
|
*/
|
|
246
268
|
currentDispatchToken = null;
|
|
247
269
|
/**
|
|
248
|
-
* R2 P1 fix (Codex triple-review
|
|
270
|
+
* R2 P1 fix (Codex triple-review): monotonic dispatch
|
|
249
271
|
* sequence id. Incremented on every `dispatchBrief()`. The
|
|
250
272
|
* agent.spawned handler stamps the current value into
|
|
251
273
|
* `taskDispatchSeq[event.taskId]`. Terminal handlers
|
|
@@ -268,7 +290,7 @@ export class ReplSession {
|
|
|
268
290
|
*/
|
|
269
291
|
taskDispatchSeq = new Map();
|
|
270
292
|
/**
|
|
271
|
-
* R3 P1 fix (Codex triple-review
|
|
293
|
+
* R3 P1 fix (Codex triple-review): wall-clock guard used to
|
|
272
294
|
* drop SSE events whose `event.timestamp` predates the current
|
|
273
295
|
* dispatch. The R2 seq gate alone fails when a LATE `agent.spawned`
|
|
274
296
|
* from brief #1 arrives AFTER brief #2 mints a new dispatch token:
|
|
@@ -289,7 +311,7 @@ export class ReplSession {
|
|
|
289
311
|
* the turn ends with this flag still set, we emit a system-line
|
|
290
312
|
* warning that the persona produced an incomplete tag - the partial
|
|
291
313
|
* XML is silently dropped (the parser already withheld it from the
|
|
292
|
-
* cleaned body). Codex triple-review P2 (PR
|
|
314
|
+
* cleaned body). Codex triple-review P2 (PR).
|
|
293
315
|
*/
|
|
294
316
|
askBufferPending = new Set();
|
|
295
317
|
constructor(options) {
|
|
@@ -315,6 +337,19 @@ export class ReplSession {
|
|
|
315
337
|
toolCalls: [],
|
|
316
338
|
transcript: [],
|
|
317
339
|
tokensDownstreamTotal: 0,
|
|
340
|
+
// cost-meter sprint — cost accumulators land at zero on boot.
|
|
341
|
+
// `sessionStartedAtEpochMs` is set at construction time (vs the
|
|
342
|
+
// server-side `agent.session.opened` event) so the elapsed slot
|
|
343
|
+
// on the status row starts ticking the moment the REPL mounts.
|
|
344
|
+
sessionTokensIn: 0,
|
|
345
|
+
sessionTokensOut: 0,
|
|
346
|
+
sessionCostUsd: 0,
|
|
347
|
+
sessionStartedAtEpochMs: this.now(),
|
|
348
|
+
recentTurns: [],
|
|
349
|
+
turnTokensIn: 0,
|
|
350
|
+
turnTokensOut: 0,
|
|
351
|
+
turnCostUsd: 0,
|
|
352
|
+
lastTurnDelta: null,
|
|
318
353
|
briefStartedAtEpochMs: undefined,
|
|
319
354
|
pendingAsk: null,
|
|
320
355
|
pendingAskSource: null,
|
|
@@ -322,8 +357,9 @@ export class ReplSession {
|
|
|
322
357
|
pendingPlanReviewSource: null,
|
|
323
358
|
dispatchState: 'idle',
|
|
324
359
|
dispatchToolLabel: null,
|
|
360
|
+
lastCompletedOutcome: null,
|
|
325
361
|
};
|
|
326
|
-
//
|
|
362
|
+
// : mirror every FSM transition into the public state so the
|
|
327
363
|
// status-bar surface can rerender on the next frame. Local listener
|
|
328
364
|
// is intentionally cheap — just a patch + clear the per-state tool
|
|
329
365
|
// label when leaving `tool_running`.
|
|
@@ -359,6 +395,7 @@ export class ReplSession {
|
|
|
359
395
|
apiUrl: this.options.apiUrl,
|
|
360
396
|
apiKey: this.options.apiKey,
|
|
361
397
|
workspace: this.options.workspace,
|
|
398
|
+
cyberZoo: this.options.cyberZoo,
|
|
362
399
|
});
|
|
363
400
|
this.patch({ sessionId, connection: 'connecting' });
|
|
364
401
|
this.openStream();
|
|
@@ -371,6 +408,18 @@ export class ReplSession {
|
|
|
371
408
|
// admin-api down) is silent - the operator can still type
|
|
372
409
|
// `/privacy` to see the contract.
|
|
373
410
|
void this.fetchAndAnnouncePrivacyMode().catch(() => undefined);
|
|
411
|
+
// silently drain any feedback envelopes
|
|
412
|
+
// that landed offline during a previous session. Best-effort —
|
|
413
|
+
// a failed flush leaves the queue intact for the next start.
|
|
414
|
+
// Never blocks bootstrap.
|
|
415
|
+
void this.flushFeedbackQueueOnBootstrap().catch(() => undefined);
|
|
416
|
+
// BT 9 Phase 2 : codegraph cold-start hook.
|
|
417
|
+
// Surfaces ONE of two nudges:
|
|
418
|
+
// - stale-index reminder ("Codegraph index is N days old…")
|
|
419
|
+
// - 30-day post-decline reminder ("Detected medium TS repo…")
|
|
420
|
+
// Skips silently in every other case. Best-effort — a failed
|
|
421
|
+
// detection NEVER blocks bootstrap (the helper itself catches).
|
|
422
|
+
void this.runCodegraphColdStart().catch(() => undefined);
|
|
374
423
|
}
|
|
375
424
|
catch (error) {
|
|
376
425
|
this.appendSystemLine(`Could not open Pugi session: ${this.errorMessage(error)}`);
|
|
@@ -385,7 +434,7 @@ export class ReplSession {
|
|
|
385
434
|
* banner is preferable to a noisy "could not fetch privacy mode"
|
|
386
435
|
* line on every login.
|
|
387
436
|
*
|
|
388
|
-
* Triple-review P1 fix
|
|
437
|
+
* Triple-review P1 fix : without this call,
|
|
389
438
|
* `renderPrivacyBanner` was defined but never reached the wire, and
|
|
390
439
|
* `/privacy` always rendered with `null` mode.
|
|
391
440
|
*/
|
|
@@ -414,13 +463,69 @@ export class ReplSession {
|
|
|
414
463
|
// Silent fail - offline / DNS / unauth all collapse to no banner.
|
|
415
464
|
}
|
|
416
465
|
}
|
|
466
|
+
/**
|
|
467
|
+
* on bootstrap, drain the local feedback
|
|
468
|
+
* queue silently. Operators who ran `pugi feedback` while offline
|
|
469
|
+
* see their envelopes flushed on the next online session without
|
|
470
|
+
* any extra command. The drain is best-effort and never blocks
|
|
471
|
+
* the REPL — a failed flush leaves the queue intact for the next
|
|
472
|
+
* bootstrap attempt.
|
|
473
|
+
*/
|
|
474
|
+
async flushFeedbackQueueOnBootstrap() {
|
|
475
|
+
const { flushFeedbackQueueSilently } = await import('../../runtime/commands/feedback.js');
|
|
476
|
+
await flushFeedbackQueueSilently(process.cwd(), {
|
|
477
|
+
apiUrl: this.options.apiUrl,
|
|
478
|
+
apiKey: this.options.apiKey,
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* BT 9 Phase 2 : codegraph cold-start nudge.
|
|
483
|
+
*
|
|
484
|
+
* Surfaces ONE of two nudges on REPL boot when the gate trips:
|
|
485
|
+
* - 30-day post-decline reminder ("Detected medium TS repo…")
|
|
486
|
+
* - stale-index reminder ("Codegraph index is N days old…")
|
|
487
|
+
*
|
|
488
|
+
* The evaluator is pure; we stamp `lastReindexCheckAt` here so the
|
|
489
|
+
* stale-index nudge throttles к once-per-day. The init-flow first-
|
|
490
|
+
* run prompt is handled separately by `pugi init` to avoid double-
|
|
491
|
+
* prompting в the common "init + then code" boot sequence.
|
|
492
|
+
*
|
|
493
|
+
* Best-effort: any error inside the codegraph module is swallowed —
|
|
494
|
+
* a cold-start nudge that breaks the REPL would be worse than no
|
|
495
|
+
* nudge at all.
|
|
496
|
+
*/
|
|
497
|
+
async runCodegraphColdStart() {
|
|
498
|
+
try {
|
|
499
|
+
const workspaceRoot = this.options.workspace?.workspaceCwd ?? process.cwd();
|
|
500
|
+
const { evaluateColdStart } = await import('../codegraph/offer-hook.js');
|
|
501
|
+
const verdict = evaluateColdStart({ workspaceRoot });
|
|
502
|
+
if (verdict.kind === 'silent')
|
|
503
|
+
return;
|
|
504
|
+
if (verdict.kind === 'stale-index') {
|
|
505
|
+
this.appendSystemLine(verdict.message);
|
|
506
|
+
const { markReindexChecked } = await import('../codegraph/decision-store.js');
|
|
507
|
+
markReindexChecked(workspaceRoot);
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
// 'remind' — surface the offer copy as a system line. Operator
|
|
511
|
+
// accepts via `/codegraph-status --install` OR explicitly via
|
|
512
|
+
// `pugi mcp install codegraph codegraph serve --mcp`.
|
|
513
|
+
this.appendSystemLine('');
|
|
514
|
+
this.appendSystemLine(verdict.message);
|
|
515
|
+
this.appendSystemLine(' Accept: run `pugi mcp install codegraph codegraph serve --mcp && pugi mcp trust codegraph`');
|
|
516
|
+
this.appendSystemLine(' Skip: /codegraph-status to inspect the decision; the prompt re-appears in 30 days');
|
|
517
|
+
}
|
|
518
|
+
catch {
|
|
519
|
+
// Codegraph nudge is decoration — failure must NEVER surface.
|
|
520
|
+
}
|
|
521
|
+
}
|
|
417
522
|
/**
|
|
418
523
|
* Tear down the SSE stream and stop the reconnect timer. The session
|
|
419
524
|
* id stays valid server-side; `pugi resume <id>` reopens later.
|
|
420
525
|
*/
|
|
421
526
|
close() {
|
|
422
527
|
this.closed = true;
|
|
423
|
-
//
|
|
528
|
+
// : fire the cancellation token before tearing down the stream
|
|
424
529
|
// so any in-flight tool sees the abort signal AND any pending
|
|
425
530
|
// PostBrief promise can short-circuit. Idempotent — token.abort()
|
|
426
531
|
// is a no-op when already aborted.
|
|
@@ -446,32 +551,32 @@ export class ReplSession {
|
|
|
446
551
|
// run a handler on a dead session. The handlers themselves also
|
|
447
552
|
// hard-guard on `this.closed`, but detaching is the load-bearing
|
|
448
553
|
// fix - it severs the strong reference the watcher held on the
|
|
449
|
-
// session callback, which otherwise blocks GC. triple-review P1 (PR
|
|
554
|
+
// session callback, which otherwise blocks GC. triple-review P1 (PR).
|
|
450
555
|
if (this.watcher) {
|
|
451
556
|
this.watcher.off('batch', this.filewatchBatchHandler);
|
|
452
557
|
this.watcher.off('capExceeded', this.filewatchCapHandler);
|
|
453
558
|
}
|
|
454
559
|
}
|
|
455
|
-
/* -------------
|
|
560
|
+
/* ------------- cancellation surface -------------- */
|
|
456
561
|
/**
|
|
457
562
|
* Operator-driven abort for the in-flight dispatch. Idempotent — a
|
|
458
563
|
* second call while already in `aborting` / `aborted` is a no-op.
|
|
459
564
|
*
|
|
460
565
|
* Steps (in order):
|
|
461
566
|
*
|
|
462
|
-
*
|
|
463
|
-
*
|
|
464
|
-
*
|
|
465
|
-
*
|
|
466
|
-
*
|
|
467
|
-
*
|
|
468
|
-
*
|
|
469
|
-
*
|
|
470
|
-
*
|
|
471
|
-
*
|
|
472
|
-
*
|
|
473
|
-
*
|
|
474
|
-
*
|
|
567
|
+
* 1. Snapshot the current state. If terminal or idle, no-op.
|
|
568
|
+
* 2. Transition the FSM to `aborting` so the bottom-bar shows the
|
|
569
|
+
* pending shutdown immediately (the operator gets feedback
|
|
570
|
+
* before any IO completes).
|
|
571
|
+
* 3. Abort the cancellation token. This fans out to every listener
|
|
572
|
+
* that was attached during the dispatch — chiefly the SSE
|
|
573
|
+
* stream wrapper (which calls `streamHandle.close()`) and any
|
|
574
|
+
* mid-flight tool executor that polled `isAborted`.
|
|
575
|
+
* 4. Append a system line so the conversation reads "Aborted." at
|
|
576
|
+
* the operator's last input position.
|
|
577
|
+
* 5. Transition to `aborted` (terminal). The next operator brief
|
|
578
|
+
* mints a fresh token + transitions back to
|
|
579
|
+
* `awaiting_response`.
|
|
475
580
|
*
|
|
476
581
|
* Returns `true` when an abort was actually issued (state was
|
|
477
582
|
* non-terminal + non-idle), `false` otherwise.
|
|
@@ -539,6 +644,73 @@ export class ReplSession {
|
|
|
539
644
|
getDispatchState() {
|
|
540
645
|
return this.fsm.current;
|
|
541
646
|
}
|
|
647
|
+
/**
|
|
648
|
+
* BT 8 (the upstream tool parity): Esc-Esc walkback. Trim the last
|
|
649
|
+
* operator/persona turn pair from the in-memory transcript so the
|
|
650
|
+
* model's next call sees the conversation as if the most recent
|
|
651
|
+
* turn never happened. The local SessionStore still has the events
|
|
652
|
+
* on disk (append-only); the in-memory mask is advisory and the next
|
|
653
|
+
* `/compact` boundary will fold them naturally.
|
|
654
|
+
*
|
|
655
|
+
* Refusal modes:
|
|
656
|
+
* - `'no-turn'` - transcript has no operator/persona row to pop.
|
|
657
|
+
* - `'in-flight'` - dispatch is mid-flight; popping would race with
|
|
658
|
+
* the streaming persona row. The operator must
|
|
659
|
+
* cancel (Ctrl+C) before walking back.
|
|
660
|
+
*
|
|
661
|
+
* Success mode:
|
|
662
|
+
* - `'walked-back'` - the trailing persona row + the operator row
|
|
663
|
+
* that triggered it are gone from the transcript.
|
|
664
|
+
* A `↩ walked back 1 turn` status row is appended
|
|
665
|
+
* so the operator sees the state change without
|
|
666
|
+
* guessing.
|
|
667
|
+
*
|
|
668
|
+
* The mask is in-memory only on purpose. Disk-side rewind already has
|
|
669
|
+
* a separate first-class command (`/rewind`) with checkpoint
|
|
670
|
+
* semantics — the Esc-Esc shortcut is a one-tap "oops, undo that" for
|
|
671
|
+
* the live transcript, NOT a transactional rollback.
|
|
672
|
+
*/
|
|
673
|
+
walkbackLastTurn() {
|
|
674
|
+
// Refuse while a dispatch is running. Popping the operator row that
|
|
675
|
+
// is currently driving the model's response would leave the persona
|
|
676
|
+
// line orphaned on the next streamed chunk; the FSM also lacks a
|
|
677
|
+
// clean teardown path here. The operator gets a one-line refusal
|
|
678
|
+
// and can Ctrl+C first if they really want to walk back.
|
|
679
|
+
const current = this.fsm.current;
|
|
680
|
+
if (current !== 'idle' && current !== 'completed'
|
|
681
|
+
&& current !== 'aborted' && current !== 'failed') {
|
|
682
|
+
this.appendSystemLine('Walkback refused: dispatch in flight. Cancel with Ctrl+C, then Esc-Esc again.');
|
|
683
|
+
return 'in-flight';
|
|
684
|
+
}
|
|
685
|
+
// Find the trailing operator row. Walking backwards because the
|
|
686
|
+
// transcript is append-only and the most recent operator turn is
|
|
687
|
+
// by definition the last `source === 'operator'` row.
|
|
688
|
+
const transcript = this.state.transcript;
|
|
689
|
+
let operatorIdx = -1;
|
|
690
|
+
for (let i = transcript.length - 1; i >= 0; i -= 1) {
|
|
691
|
+
const row = transcript[i];
|
|
692
|
+
if (row.source === 'operator') {
|
|
693
|
+
operatorIdx = i;
|
|
694
|
+
break;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
if (operatorIdx === -1) {
|
|
698
|
+
// No operator turn to pop. Quiet refusal — surfacing a "nothing
|
|
699
|
+
// to undo" line on every accidental double-Esc would be noisy.
|
|
700
|
+
return 'no-turn';
|
|
701
|
+
}
|
|
702
|
+
// Trim everything from the operator row onward (its echo + any
|
|
703
|
+
// persona/system rows that landed in response). The slice keeps
|
|
704
|
+
// every row BEFORE the operator turn, which is the conversation
|
|
705
|
+
// exactly as it stood right before the operator pressed Enter.
|
|
706
|
+
const trimmed = transcript.slice(0, operatorIdx);
|
|
707
|
+
this.patch({ transcript: trimmed });
|
|
708
|
+
// Status row so the operator sees the state change without
|
|
709
|
+
// guessing. Brand voice: single ASCII line, return-arrow glyph
|
|
710
|
+
// (U+21A9) which renders across every modern terminal.
|
|
711
|
+
this.appendSystemLine('↩ walked back 1 turn');
|
|
712
|
+
return 'walked-back';
|
|
713
|
+
}
|
|
542
714
|
/**
|
|
543
715
|
* Current cancellation token. Returned for the tool execution path
|
|
544
716
|
* (file-tools.ts) so it can pass the token down into a ToolContext
|
|
@@ -564,7 +736,7 @@ export class ReplSession {
|
|
|
564
736
|
// UI overlays - no transport interaction.
|
|
565
737
|
return verdict;
|
|
566
738
|
case 'quit':
|
|
567
|
-
// UI Designer audit
|
|
739
|
+
// UI Designer audit: "Brief it. It ships." is reserved
|
|
568
740
|
// for identity intro + landing per wave-4 prompt rule. Drop the
|
|
569
741
|
// tagline drift here; tell the operator what happened and how to
|
|
570
742
|
// resume.
|
|
@@ -577,6 +749,18 @@ export class ReplSession {
|
|
|
577
749
|
await this.dispatchStop(verdict.persona);
|
|
578
750
|
return verdict;
|
|
579
751
|
}
|
|
752
|
+
case 'delegate': {
|
|
753
|
+
// Phase 1: surface the dispatch intent inline. The actual
|
|
754
|
+
// wire shape (POST /api/pugi/sessions/:id/delegate) requires the
|
|
755
|
+
// SDK transport extension that ships alongside this PR; the
|
|
756
|
+
// REPL session module wires the call when the matching transport
|
|
757
|
+
// method lands (paired CLI follow-up). Today we surface the
|
|
758
|
+
// delegation intent in the transcript so the operator sees the
|
|
759
|
+
// verdict echo for muscle-memory before the round-trip lights up.
|
|
760
|
+
this.appendSystemLine(`delegate ${verdict.persona}: ${verdict.brief.length > 80 ? `${verdict.brief.slice(0, 77)}...` : verdict.brief}`);
|
|
761
|
+
this.appendSystemLine('Run `pugi delegate <slug> "<brief>"` from a fresh shell while the REPL transport wiring lands.');
|
|
762
|
+
return verdict;
|
|
763
|
+
}
|
|
580
764
|
case 'dispatch': {
|
|
581
765
|
await this.dispatchBrief(verdict.brief);
|
|
582
766
|
return verdict;
|
|
@@ -594,7 +778,43 @@ export class ReplSession {
|
|
|
594
778
|
return verdict;
|
|
595
779
|
}
|
|
596
780
|
case 'jobs': {
|
|
597
|
-
|
|
781
|
+
// cleanup : `/jobs --watch` mounts the
|
|
782
|
+
// live Ink TUI from inside the REPL. The dispatcher does NOT
|
|
783
|
+
// mount the watcher itself (that would unmount the REPL's
|
|
784
|
+
// own Ink tree) — instead it surfaces the shell command so
|
|
785
|
+
// the operator runs the watcher in a fresh terminal. Bare
|
|
786
|
+
// `/jobs` continues to render the one-shot snapshot.
|
|
787
|
+
if (verdict.watch) {
|
|
788
|
+
this.appendSystemLine('Run `pugi jobs --watch` from a fresh shell — the live TUI cannot share the REPL Ink tree.');
|
|
789
|
+
}
|
|
790
|
+
else {
|
|
791
|
+
await this.dispatchJobs();
|
|
792
|
+
}
|
|
793
|
+
return verdict;
|
|
794
|
+
}
|
|
795
|
+
case 'cancel': {
|
|
796
|
+
// small-CC-parity batch : forward the parsed
|
|
797
|
+
// mode + dispatchId to `runCancelCommand`. The dispatcher uses
|
|
798
|
+
// a dynamic import so the cancel module's filesystem helpers
|
|
799
|
+
// stay out of the REPL keystroke hot path; same separation as
|
|
800
|
+
// `/redo`, `/prd-check`, `/chain`. The runner writes its
|
|
801
|
+
// output lines through `appendSystemLine` so the verdict
|
|
802
|
+
// lands on the system pane alongside other slash results.
|
|
803
|
+
try {
|
|
804
|
+
const { runCancelCommand } = await import('../../runtime/commands/cancel.js');
|
|
805
|
+
const cancelMode = verdict.mode === 'list'
|
|
806
|
+
? { kind: 'list' }
|
|
807
|
+
: verdict.mode === 'all'
|
|
808
|
+
? { kind: 'all' }
|
|
809
|
+
: { kind: 'one', dispatchId: verdict.dispatchId };
|
|
810
|
+
await runCancelCommand(cancelMode, {
|
|
811
|
+
write: (line) => this.appendSystemLine(line),
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
catch (err) {
|
|
815
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
816
|
+
this.appendSystemLine(`/cancel failed: ${message}`);
|
|
817
|
+
}
|
|
598
818
|
return verdict;
|
|
599
819
|
}
|
|
600
820
|
case 'diff': {
|
|
@@ -602,11 +822,15 @@ export class ReplSession {
|
|
|
602
822
|
return verdict;
|
|
603
823
|
}
|
|
604
824
|
case 'cost': {
|
|
605
|
-
this.dispatchCost();
|
|
825
|
+
await this.dispatchCost();
|
|
826
|
+
return verdict;
|
|
827
|
+
}
|
|
828
|
+
case 'quota': {
|
|
829
|
+
await this.dispatchQuota();
|
|
606
830
|
return verdict;
|
|
607
831
|
}
|
|
608
832
|
case 'status': {
|
|
609
|
-
this.dispatchStatus();
|
|
833
|
+
await this.dispatchStatus();
|
|
610
834
|
return verdict;
|
|
611
835
|
}
|
|
612
836
|
case 'consensus': {
|
|
@@ -628,7 +852,7 @@ export class ReplSession {
|
|
|
628
852
|
return verdict;
|
|
629
853
|
}
|
|
630
854
|
case 'ask': {
|
|
631
|
-
//
|
|
855
|
+
// : synthesise a local yes/no `<pugi-ask>` modal so the
|
|
632
856
|
// operator can exercise the question UI without a persona-side
|
|
633
857
|
// round trip. The REPL UI mounts the modal from the resulting
|
|
634
858
|
// `pendingAsk` state; on resolution the encoded verdict lands
|
|
@@ -651,12 +875,1007 @@ export class ReplSession {
|
|
|
651
875
|
await this.dispatchPrivacy();
|
|
652
876
|
return verdict;
|
|
653
877
|
}
|
|
878
|
+
case 'init': {
|
|
879
|
+
// β1 Sl11 → β1a r1 (real inline scaffold): invoke
|
|
880
|
+
// `scaffoldPugiWorkspace` directly so the operator gets the
|
|
881
|
+
// same .pugi/ setup they would from `pugi init` on a fresh
|
|
882
|
+
// shell. Already-initialised workspaces (every artifact already
|
|
883
|
+
// present) get the "Already initialised" copy; partial / fresh
|
|
884
|
+
// workspaces get the full Created+Skipped breakdown. Default
|
|
885
|
+
// skills install is best-effort — any error from the bundled
|
|
886
|
+
// pack is surfaced as a system line and does not break the
|
|
887
|
+
// REPL session. The dynamic import keeps the slash dispatcher
|
|
888
|
+
// free of a runtime/cli.ts cycle on every keystroke.
|
|
889
|
+
try {
|
|
890
|
+
const { scaffoldPugiWorkspace } = await import('../../runtime/cli.js');
|
|
891
|
+
const lines = [];
|
|
892
|
+
const result = await scaffoldPugiWorkspace({
|
|
893
|
+
cwd: process.cwd(),
|
|
894
|
+
// Slash callers default to the full default-skills pack so
|
|
895
|
+
// the in-REPL experience matches `pugi init`. Operators who
|
|
896
|
+
// want a minimal scaffold still have the shell command.
|
|
897
|
+
noDefaults: false,
|
|
898
|
+
log: (line) => {
|
|
899
|
+
const trimmed = line.replace(/\n+$/u, '');
|
|
900
|
+
if (trimmed.length > 0)
|
|
901
|
+
lines.push(trimmed);
|
|
902
|
+
},
|
|
903
|
+
});
|
|
904
|
+
if (result.alreadyInitialized) {
|
|
905
|
+
this.appendSystemLine(`.pugi/ already initialised at ${result.root}. ${result.skipped.length} artefact(s) verified.`);
|
|
906
|
+
}
|
|
907
|
+
else {
|
|
908
|
+
this.appendSystemLine(`Pugi initialised at ${result.root}. Created ${result.created.length} artefact(s), skipped ${result.skipped.length}.`);
|
|
909
|
+
}
|
|
910
|
+
if (result.defaultSkills.length > 0) {
|
|
911
|
+
const installed = result.defaultSkills.filter((s) => s.status === 'installed').length;
|
|
912
|
+
const skippedSkills = result.defaultSkills.filter((s) => s.status === 'skipped-existing').length;
|
|
913
|
+
this.appendSystemLine(`Default skills: ${installed} installed, ${skippedSkills} already present.`);
|
|
914
|
+
}
|
|
915
|
+
for (const line of lines)
|
|
916
|
+
this.appendSystemLine(line);
|
|
917
|
+
}
|
|
918
|
+
catch (error) {
|
|
919
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
920
|
+
this.appendSystemLine(`/init failed: ${message}`);
|
|
921
|
+
}
|
|
922
|
+
return verdict;
|
|
923
|
+
}
|
|
924
|
+
case 'mcp': {
|
|
925
|
+
// β4 Sl7 : /mcp [sub] [args...] forwards to the
|
|
926
|
+
// runtime command. We deliberately route through the same
|
|
927
|
+
// entry-point used by `pugi mcp` from a fresh shell so the
|
|
928
|
+
// surface stays single-sourced. `serve` is refused inline —
|
|
929
|
+
// booting an MCP server inside an active REPL would compete
|
|
930
|
+
// with the REPL itself for stdio, which is exactly the wrong
|
|
931
|
+
// thing to do.
|
|
932
|
+
if (verdict.args[0] === 'serve') {
|
|
933
|
+
this.appendSystemLine('/mcp serve is not safe inside the REPL (it competes for stdio). ' +
|
|
934
|
+
'Run `pugi mcp serve` from a fresh shell instead.');
|
|
935
|
+
return verdict;
|
|
936
|
+
}
|
|
937
|
+
try {
|
|
938
|
+
const { runMcpCommand } = await import('../../runtime/commands/mcp.js');
|
|
939
|
+
const lines = [];
|
|
940
|
+
await runMcpCommand(verdict.args, {
|
|
941
|
+
workspaceRoot: process.cwd(),
|
|
942
|
+
writeOutput: (_payload, text) => {
|
|
943
|
+
const trimmed = text.replace(/\n+$/u, '');
|
|
944
|
+
if (trimmed.length > 0)
|
|
945
|
+
lines.push(trimmed);
|
|
946
|
+
},
|
|
947
|
+
});
|
|
948
|
+
for (const line of lines)
|
|
949
|
+
this.appendSystemLine(line);
|
|
950
|
+
if (lines.length === 0) {
|
|
951
|
+
this.appendSystemLine('/mcp: no output.');
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
catch (error) {
|
|
955
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
956
|
+
this.appendSystemLine(`/mcp failed: ${message}`);
|
|
957
|
+
}
|
|
958
|
+
return verdict;
|
|
959
|
+
}
|
|
960
|
+
case 'theme': {
|
|
961
|
+
// /theme [name] [--persist|--reset|--list]
|
|
962
|
+
// forwards to the shared `runThemeCommand` runner. Same async
|
|
963
|
+
// buffer-then-flush pattern as `/style` so a future async
|
|
964
|
+
// write path inside the runner cannot drop a tail emission
|
|
965
|
+
// and so multi-line payloads (banner + preview table) land
|
|
966
|
+
// one row per visual line in the conversation pane.
|
|
967
|
+
try {
|
|
968
|
+
const { runThemeCommand } = await import('../../runtime/commands/theme.js');
|
|
969
|
+
const lines = [];
|
|
970
|
+
await runThemeCommand(verdict.args, {
|
|
971
|
+
workspaceRoot: process.cwd(),
|
|
972
|
+
writeOutput: (_payload, text) => {
|
|
973
|
+
for (const raw of text.split('\n')) {
|
|
974
|
+
const trimmed = raw.replace(/\s+$/u, '');
|
|
975
|
+
lines.push(trimmed);
|
|
976
|
+
}
|
|
977
|
+
},
|
|
978
|
+
});
|
|
979
|
+
if (lines.length === 0) {
|
|
980
|
+
this.appendSystemLine('/theme: no output.');
|
|
981
|
+
}
|
|
982
|
+
else {
|
|
983
|
+
for (const line of lines)
|
|
984
|
+
this.appendSystemLine(line);
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
catch (error) {
|
|
988
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
989
|
+
this.appendSystemLine(`/theme failed: ${message}`);
|
|
990
|
+
}
|
|
991
|
+
return verdict;
|
|
992
|
+
}
|
|
993
|
+
case 'style': {
|
|
994
|
+
// /style [name] [--persist|--reset|--list]
|
|
995
|
+
// forwards to the shared `runStyleCommand` runner so the slash
|
|
996
|
+
// + top-level surfaces share one code path. Dynamic import
|
|
997
|
+
// keeps the dispatcher free of the output-style module graph
|
|
998
|
+
// until the operator first invokes the slash. The runner's
|
|
999
|
+
// exit code is captured but NOT propagated to process.exitCode
|
|
1000
|
+
// — REPL session should not die because a bad preset slug was
|
|
1001
|
+
// typed in the input box.
|
|
1002
|
+
try {
|
|
1003
|
+
const { runStyleCommand } = await import('../../runtime/commands/style.js');
|
|
1004
|
+
// L18 P1 fix : writeOutput is invoked SYNCHRONOUSLY
|
|
1005
|
+
// by `runStyleCommand` for each emitted block. We buffer every
|
|
1006
|
+
// emission into `lines` and flush after the await resolves so
|
|
1007
|
+
// that:
|
|
1008
|
+
// (1) any future async write path inside the runner cannot
|
|
1009
|
+
// drop a tail emission (callback never references the
|
|
1010
|
+
// Ink frame directly), and
|
|
1011
|
+
// (2) multi-line payloads (e.g. the active-style banner +
|
|
1012
|
+
// catalogue table) render one row per visual line in the
|
|
1013
|
+
// conversation pane, matching the `/stickers` surface.
|
|
1014
|
+
const lines = [];
|
|
1015
|
+
await runStyleCommand(verdict.args, {
|
|
1016
|
+
workspaceRoot: process.cwd(),
|
|
1017
|
+
writeOutput: (_payload, text) => {
|
|
1018
|
+
for (const raw of text.split('\n')) {
|
|
1019
|
+
const trimmed = raw.replace(/\s+$/u, '');
|
|
1020
|
+
lines.push(trimmed);
|
|
1021
|
+
}
|
|
1022
|
+
},
|
|
1023
|
+
});
|
|
1024
|
+
if (lines.length === 0) {
|
|
1025
|
+
this.appendSystemLine('/style: no output.');
|
|
1026
|
+
}
|
|
1027
|
+
else {
|
|
1028
|
+
for (const line of lines)
|
|
1029
|
+
this.appendSystemLine(line);
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
catch (error) {
|
|
1033
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1034
|
+
this.appendSystemLine(`/style failed: ${message}`);
|
|
1035
|
+
}
|
|
1036
|
+
return verdict;
|
|
1037
|
+
}
|
|
1038
|
+
case 'onboarding': {
|
|
1039
|
+
// /onboarding forwards to the shared
|
|
1040
|
+
// `runOnboardingCommand` runner. From inside the REPL we ALWAYS
|
|
1041
|
+
// route through the non-interactive snapshot path — the REPL
|
|
1042
|
+
// already owns the Ink tree and mounting a second Ink wizard
|
|
1043
|
+
// on top would conflict over stdin raw mode. Operators who
|
|
1044
|
+
// want the interactive walk exit the REPL and run
|
|
1045
|
+
// `pugi onboarding` from a fresh shell; the slash surface
|
|
1046
|
+
// surfaces the recap card + hints inline so the operator
|
|
1047
|
+
// sees current values without leaving the session.
|
|
1048
|
+
try {
|
|
1049
|
+
const { runOnboardingCommand } = await import('../../runtime/commands/onboarding.js');
|
|
1050
|
+
const { resolveActiveCredential } = await import('../credentials.js');
|
|
1051
|
+
const credential = resolveActiveCredential();
|
|
1052
|
+
const lines = [];
|
|
1053
|
+
await runOnboardingCommand(verdict.args, {
|
|
1054
|
+
workspaceRoot: process.cwd(),
|
|
1055
|
+
env: process.env,
|
|
1056
|
+
authPresent: credential !== null,
|
|
1057
|
+
interactive: false,
|
|
1058
|
+
writeOutput: (_payload, text) => {
|
|
1059
|
+
const trimmed = text.replace(/\n+$/u, '');
|
|
1060
|
+
if (trimmed.length > 0)
|
|
1061
|
+
lines.push(trimmed);
|
|
1062
|
+
},
|
|
1063
|
+
});
|
|
1064
|
+
for (const line of lines)
|
|
1065
|
+
this.appendSystemLine(line);
|
|
1066
|
+
if (lines.length === 0) {
|
|
1067
|
+
this.appendSystemLine('/onboarding: no output.');
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
catch (error) {
|
|
1071
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1072
|
+
this.appendSystemLine(`/onboarding failed: ${message}`);
|
|
1073
|
+
}
|
|
1074
|
+
return verdict;
|
|
1075
|
+
}
|
|
1076
|
+
case 'vim': {
|
|
1077
|
+
// /vim forwards to the shared
|
|
1078
|
+
// `runVimCommand` runner so the slash + top-level surfaces
|
|
1079
|
+
// stay single-sourced. Dynamic import mirrors /style so the
|
|
1080
|
+
// dispatcher does not drag the vim module graph into every
|
|
1081
|
+
// keystroke.
|
|
1082
|
+
//
|
|
1083
|
+
// The runner mutates `~/.pugi/config.json::vimMode`; the
|
|
1084
|
+
// active REPL session does NOT live-pick-up the flip (the
|
|
1085
|
+
// VimInput wrapper is mounted once at REPL boot). Operators
|
|
1086
|
+
// get a hint that the next session will reflect the change.
|
|
1087
|
+
// A follow-up sprint can plumb a state-store subscriber so
|
|
1088
|
+
// the flip takes effect mid-session.
|
|
1089
|
+
try {
|
|
1090
|
+
const { runVimCommand } = await import('../../runtime/commands/vim.js');
|
|
1091
|
+
const lines = [];
|
|
1092
|
+
await runVimCommand(verdict.args, {
|
|
1093
|
+
env: process.env,
|
|
1094
|
+
writeOutput: (_payload, text) => {
|
|
1095
|
+
for (const raw of text.split('\n')) {
|
|
1096
|
+
const trimmed = raw.replace(/\s+$/u, '');
|
|
1097
|
+
lines.push(trimmed);
|
|
1098
|
+
}
|
|
1099
|
+
},
|
|
1100
|
+
});
|
|
1101
|
+
if (lines.length === 0) {
|
|
1102
|
+
this.appendSystemLine('/vim: no output.');
|
|
1103
|
+
}
|
|
1104
|
+
else {
|
|
1105
|
+
for (const line of lines)
|
|
1106
|
+
this.appendSystemLine(line);
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
catch (error) {
|
|
1110
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1111
|
+
this.appendSystemLine(`/vim failed: ${message}`);
|
|
1112
|
+
}
|
|
1113
|
+
return verdict;
|
|
1114
|
+
}
|
|
1115
|
+
case 'doctor': {
|
|
1116
|
+
// L17 : run the doctor probe sweep inline. We
|
|
1117
|
+
// dynamic-import the runtime/commands/doctor module so the
|
|
1118
|
+
// slash dispatcher does not pull the diagnostics graph
|
|
1119
|
+
// (execFileSync + fs probes) into every keystroke. The
|
|
1120
|
+
// module's output is captured into local lines so we can
|
|
1121
|
+
// render it as system entries in the conversation pane;
|
|
1122
|
+
// an Ink-rendered table inside the REPL frame is a follow-up.
|
|
1123
|
+
try {
|
|
1124
|
+
const { runDoctorCommand, defaultHome } = await import('../../runtime/commands/doctor.js');
|
|
1125
|
+
const lines = [];
|
|
1126
|
+
await runDoctorCommand({
|
|
1127
|
+
cwd: process.cwd(),
|
|
1128
|
+
home: defaultHome(),
|
|
1129
|
+
env: process.env,
|
|
1130
|
+
json: false,
|
|
1131
|
+
writeOutput: (_payload, text) => {
|
|
1132
|
+
const trimmed = text.replace(/\n+$/u, '');
|
|
1133
|
+
if (trimmed.length > 0)
|
|
1134
|
+
lines.push(trimmed);
|
|
1135
|
+
},
|
|
1136
|
+
});
|
|
1137
|
+
for (const line of lines)
|
|
1138
|
+
this.appendSystemLine(line);
|
|
1139
|
+
if (lines.length === 0) {
|
|
1140
|
+
this.appendSystemLine('/doctor: no output.');
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
catch (error) {
|
|
1144
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1145
|
+
this.appendSystemLine(`/doctor failed: ${message}`);
|
|
1146
|
+
}
|
|
1147
|
+
return verdict;
|
|
1148
|
+
}
|
|
1149
|
+
case 'prd-check': {
|
|
1150
|
+
// : forward to the same handler the shell
|
|
1151
|
+
// surface uses so the verdict is identical between
|
|
1152
|
+
// `/prd-check` and `pugi prd-check`. Dynamic-import the
|
|
1153
|
+
// module to keep the parser + verifier graph out of the
|
|
1154
|
+
// REPL hot path.
|
|
1155
|
+
//
|
|
1156
|
+
// final : the runner now also honours
|
|
1157
|
+
// `--session` mode (orthogonal to the verifier graph — walks
|
|
1158
|
+
// up for PRD.md, reads NDJSON turns, dispatches a cross-
|
|
1159
|
+
// review subagent). We stream the runner's status lines
|
|
1160
|
+
// directly to the system pane so the operator sees
|
|
1161
|
+
// "Locating PRD..." / "Reviewing against PRD..." while the
|
|
1162
|
+
// dispatch is in flight, then the structured Satisfied /
|
|
1163
|
+
// Outstanding lists when it lands.
|
|
1164
|
+
try {
|
|
1165
|
+
const { parsePrdCheckArgs, runPrdCheckCommand } = await import('../../runtime/commands/prd-check.js');
|
|
1166
|
+
const parsed = parsePrdCheckArgs(verdict.args, { jsonDefault: false });
|
|
1167
|
+
if (!parsed.ok) {
|
|
1168
|
+
this.appendSystemLine(`/prd-check: ${parsed.error}`);
|
|
1169
|
+
return verdict;
|
|
1170
|
+
}
|
|
1171
|
+
let sawOutput = false;
|
|
1172
|
+
await runPrdCheckCommand({
|
|
1173
|
+
cwd: process.cwd(),
|
|
1174
|
+
...(parsed.prdPath !== undefined ? { prdPath: parsed.prdPath } : {}),
|
|
1175
|
+
flags: parsed.flags,
|
|
1176
|
+
// The REPL slash does not have a snapshot of the CLI
|
|
1177
|
+
// command registry, so we pass an empty set; the
|
|
1178
|
+
// command:<name> verifier will report FAIL for now.
|
|
1179
|
+
// This is a deliberate trade-off — the slash surface
|
|
1180
|
+
// primarily exists for quick eyeball checks during a
|
|
1181
|
+
// session; the shell surface (which DOES inject the
|
|
1182
|
+
// full registry) is the canonical gate.
|
|
1183
|
+
knownCommands: new Set(),
|
|
1184
|
+
writeOutput: (_payload, text) => {
|
|
1185
|
+
const trimmed = text.replace(/\n+$/u, '');
|
|
1186
|
+
if (trimmed.length > 0) {
|
|
1187
|
+
this.appendSystemLine(trimmed);
|
|
1188
|
+
sawOutput = true;
|
|
1189
|
+
}
|
|
1190
|
+
},
|
|
1191
|
+
});
|
|
1192
|
+
if (!sawOutput) {
|
|
1193
|
+
this.appendSystemLine('/prd-check: no output.');
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
catch (error) {
|
|
1197
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1198
|
+
this.appendSystemLine(`/prd-check failed: ${message}`);
|
|
1199
|
+
}
|
|
1200
|
+
return verdict;
|
|
1201
|
+
}
|
|
1202
|
+
case 'chain': {
|
|
1203
|
+
// : forward to the shell-surface runner so
|
|
1204
|
+
// the slash + top-level CLI share one parser + dispatcher.
|
|
1205
|
+
// Dynamic import keeps the chain module out of the REPL hot
|
|
1206
|
+
// path. The slash variant does NOT inject the live delegate
|
|
1207
|
+
// wire-up — operators wanting full dispatch run `pugi chain
|
|
1208
|
+
// next` from a fresh shell. The slash form is best-effort for
|
|
1209
|
+
// status / show / list which are read-only.
|
|
1210
|
+
try {
|
|
1211
|
+
const { runChainCommand } = await import('../../runtime/commands/chain.js');
|
|
1212
|
+
const lines = [];
|
|
1213
|
+
await runChainCommand(verdict.args, {
|
|
1214
|
+
cwd: process.cwd(),
|
|
1215
|
+
json: false,
|
|
1216
|
+
writeOutput: (_payload, text) => {
|
|
1217
|
+
const trimmed = text.replace(/\n+$/u, '');
|
|
1218
|
+
if (trimmed.length > 0)
|
|
1219
|
+
lines.push(trimmed);
|
|
1220
|
+
},
|
|
1221
|
+
});
|
|
1222
|
+
for (const line of lines)
|
|
1223
|
+
this.appendSystemLine(line);
|
|
1224
|
+
if (lines.length === 0) {
|
|
1225
|
+
this.appendSystemLine('/chain: no output.');
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
catch (error) {
|
|
1229
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1230
|
+
this.appendSystemLine(`/chain failed: ${message}`);
|
|
1231
|
+
}
|
|
1232
|
+
return verdict;
|
|
1233
|
+
}
|
|
1234
|
+
case 'codegraph-status': {
|
|
1235
|
+
// BT 9 Phase 2 : forward to the runner. The
|
|
1236
|
+
// bare form renders the four-row status table; flags handle
|
|
1237
|
+
// install / reindex / offer. Dynamic import keeps the
|
|
1238
|
+
// codegraph module out of the REPL hot path until first use.
|
|
1239
|
+
try {
|
|
1240
|
+
const { runCodegraphStatusCommand } = await import('../../runtime/commands/codegraph-status.js');
|
|
1241
|
+
const lines = [];
|
|
1242
|
+
const workspaceRoot = this.options.workspace?.workspaceCwd ?? process.cwd();
|
|
1243
|
+
await runCodegraphStatusCommand(verdict.args, {
|
|
1244
|
+
workspaceRoot,
|
|
1245
|
+
writeOutput: (_payload, text) => {
|
|
1246
|
+
for (const raw of text.split('\n')) {
|
|
1247
|
+
const trimmed = raw.replace(/\s+$/u, '');
|
|
1248
|
+
lines.push(trimmed);
|
|
1249
|
+
}
|
|
1250
|
+
},
|
|
1251
|
+
});
|
|
1252
|
+
if (lines.length === 0) {
|
|
1253
|
+
this.appendSystemLine('/codegraph-status: no output.');
|
|
1254
|
+
}
|
|
1255
|
+
else {
|
|
1256
|
+
for (const line of lines)
|
|
1257
|
+
this.appendSystemLine(line);
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
catch (error) {
|
|
1261
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1262
|
+
this.appendSystemLine(`/codegraph-status failed: ${message}`);
|
|
1263
|
+
}
|
|
1264
|
+
return verdict;
|
|
1265
|
+
}
|
|
1266
|
+
case 'permissions': {
|
|
1267
|
+
// handle the `/permissions [mode] [--persist]` flow.
|
|
1268
|
+
// The session module forwards to the runtime helper so the
|
|
1269
|
+
// workspace + global-config writes share one code path with
|
|
1270
|
+
// the CLI's top-level `--mode` resolution. The dynamic import
|
|
1271
|
+
// keeps the dispatcher free of a session.ts -> runtime/cli.ts
|
|
1272
|
+
// cycle.
|
|
1273
|
+
try {
|
|
1274
|
+
const { runPermissionsCommand } = await import('../../runtime/commands/permissions.js');
|
|
1275
|
+
const lines = [];
|
|
1276
|
+
await runPermissionsCommand(verdict, {
|
|
1277
|
+
workspaceRoot: process.cwd(),
|
|
1278
|
+
writeOutput: (line) => {
|
|
1279
|
+
const trimmed = line.replace(/\n+$/u, '');
|
|
1280
|
+
if (trimmed.length > 0)
|
|
1281
|
+
lines.push(trimmed);
|
|
1282
|
+
},
|
|
1283
|
+
});
|
|
1284
|
+
for (const line of lines)
|
|
1285
|
+
this.appendSystemLine(line);
|
|
1286
|
+
}
|
|
1287
|
+
catch (error) {
|
|
1288
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1289
|
+
this.appendSystemLine(`/permissions failed: ${message}`);
|
|
1290
|
+
}
|
|
1291
|
+
return verdict;
|
|
1292
|
+
}
|
|
1293
|
+
case 'compact': {
|
|
1294
|
+
// /compact summarises older turns and
|
|
1295
|
+
// appends a boundary marker. We forward to the same runner the
|
|
1296
|
+
// top-level `pugi compact` command uses so the surface stays
|
|
1297
|
+
// single-sourced. The session module owns the in-memory
|
|
1298
|
+
// transcript echo (system line + banner row) so the operator
|
|
1299
|
+
// sees the marker land without a fresh REPL bootstrap.
|
|
1300
|
+
//
|
|
1301
|
+
// BT 8 (the upstream tool parity): `--force` bypasses the
|
|
1302
|
+
// noop-empty guard so the operator can compact even short
|
|
1303
|
+
// sessions (useful before a manual checkpoint).
|
|
1304
|
+
await this.dispatchCompact('manual', { force: verdict.force });
|
|
1305
|
+
return verdict;
|
|
1306
|
+
}
|
|
1307
|
+
case 'model': {
|
|
1308
|
+
// BT 8 (the upstream tool parity): /model lists OR selects the
|
|
1309
|
+
// active model. Slash + top-level CLI share `runModelCommand`.
|
|
1310
|
+
// The session module forwards writeOutput → appendSystemLine so
|
|
1311
|
+
// the menu + the confirmation line land inline in the
|
|
1312
|
+
// transcript. Tier override is undefined at the slash surface;
|
|
1313
|
+
// the runner defaults to 'team' so unauthenticated operators
|
|
1314
|
+
// see every model. Server-side calls enforce the real tier cap.
|
|
1315
|
+
try {
|
|
1316
|
+
const { runModelCommand } = await import('../../runtime/commands/model.js');
|
|
1317
|
+
await runModelCommand({ slug: verdict.slug }, {
|
|
1318
|
+
workspaceRoot: process.cwd(),
|
|
1319
|
+
writeOutput: (line) => {
|
|
1320
|
+
const trimmed = line.replace(/\n+$/u, '');
|
|
1321
|
+
if (trimmed.length > 0)
|
|
1322
|
+
this.appendSystemLine(trimmed);
|
|
1323
|
+
else
|
|
1324
|
+
this.appendSystemLine('');
|
|
1325
|
+
},
|
|
1326
|
+
});
|
|
1327
|
+
}
|
|
1328
|
+
catch (error) {
|
|
1329
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1330
|
+
this.appendSystemLine(`/model failed: ${message}`);
|
|
1331
|
+
}
|
|
1332
|
+
return verdict;
|
|
1333
|
+
}
|
|
1334
|
+
case 'rewind': {
|
|
1335
|
+
// /rewind appends an append-only
|
|
1336
|
+
// tombstone marker that rolls the conversation back to a
|
|
1337
|
+
// checkpoint. The actual replay-mask is advisory — the on-disk
|
|
1338
|
+
// events stay durable so `pugi sessions undo-rewind` can
|
|
1339
|
+
// reverse the operation. We forward to the same runner the
|
|
1340
|
+
// top-level `pugi rewind` command uses to keep the surface
|
|
1341
|
+
// single-sourced. Dynamic import avoids pulling the checkpoint
|
|
1342
|
+
// graph into the dispatcher at module load.
|
|
1343
|
+
if (!this.store || !this.localSessionId) {
|
|
1344
|
+
this.appendSystemLine('Local session store is disabled — /rewind is unavailable.');
|
|
1345
|
+
return verdict;
|
|
1346
|
+
}
|
|
1347
|
+
try {
|
|
1348
|
+
const { runRewindCommand } = await import('../../runtime/commands/rewind.js');
|
|
1349
|
+
await runRewindCommand(verdict.args, {
|
|
1350
|
+
workspaceRoot: process.cwd(),
|
|
1351
|
+
sessionId: this.localSessionId,
|
|
1352
|
+
store: this.store,
|
|
1353
|
+
writeOutput: (_payload, text) => {
|
|
1354
|
+
if (text.length > 0)
|
|
1355
|
+
this.appendSystemLine(text);
|
|
1356
|
+
},
|
|
1357
|
+
});
|
|
1358
|
+
}
|
|
1359
|
+
catch (error) {
|
|
1360
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1361
|
+
this.appendSystemLine(`/rewind failed: ${message}`);
|
|
1362
|
+
}
|
|
1363
|
+
return verdict;
|
|
1364
|
+
}
|
|
1365
|
+
case 'share': {
|
|
1366
|
+
// /share forwards to the same runner the
|
|
1367
|
+
// top-level `pugi share` command uses. The session module
|
|
1368
|
+
// wires writeOutput to appendSystemLine so the upload result +
|
|
1369
|
+
// privacy gate banner land in the REPL transcript inline.
|
|
1370
|
+
// Confirmation prompt + readline still use stdio because the
|
|
1371
|
+
// Ink frame is held by the input box; operators wanting fully
|
|
1372
|
+
// scripted shares pass `--yes` so no prompt fires.
|
|
1373
|
+
try {
|
|
1374
|
+
const { runShareCommand } = await import('../../runtime/commands/share.js');
|
|
1375
|
+
const lines = [];
|
|
1376
|
+
await runShareCommand(verdict.args, {
|
|
1377
|
+
workspaceRoot: process.cwd(),
|
|
1378
|
+
cliVersion: this.options.cliVersion,
|
|
1379
|
+
sessionId: this.localSessionId ?? undefined,
|
|
1380
|
+
writeOutput: (_payload, text) => {
|
|
1381
|
+
const trimmed = text.replace(/\n+$/u, '');
|
|
1382
|
+
if (trimmed.length > 0)
|
|
1383
|
+
lines.push(trimmed);
|
|
1384
|
+
},
|
|
1385
|
+
});
|
|
1386
|
+
for (const line of lines)
|
|
1387
|
+
this.appendSystemLine(line);
|
|
1388
|
+
if (lines.length === 0) {
|
|
1389
|
+
this.appendSystemLine('/share: no output.');
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
catch (error) {
|
|
1393
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1394
|
+
this.appendSystemLine(`/share failed: ${message}`);
|
|
1395
|
+
}
|
|
1396
|
+
return verdict;
|
|
1397
|
+
}
|
|
1398
|
+
case 'plan': {
|
|
1399
|
+
// handle `/plan [--back | --persist] [<prompt>]`.
|
|
1400
|
+
// The session module forwards the mode-switch portion to the
|
|
1401
|
+
// shared runtime helper so the workspace + global-config writes
|
|
1402
|
+
// share one code path with `pugi plan`. When the operator
|
|
1403
|
+
// typed a prompt alongside (`/plan write me X`), the prompt is
|
|
1404
|
+
// forwarded through the dispatch FSM exactly as if they had
|
|
1405
|
+
// typed it directly — the only difference is the gate now
|
|
1406
|
+
// refuses write/dispatch tools because the workspace mode flipped
|
|
1407
|
+
// to plan first. Same dynamic-import trick as /permissions to
|
|
1408
|
+
// avoid pulling the engine adapter graph into the dispatcher.
|
|
1409
|
+
try {
|
|
1410
|
+
const { runPlanCommand } = await import('../../runtime/commands/plan.js');
|
|
1411
|
+
const lines = [];
|
|
1412
|
+
await runPlanCommand({ back: verdict.back, persist: verdict.persist }, {
|
|
1413
|
+
workspaceRoot: process.cwd(),
|
|
1414
|
+
writeOutput: (line) => {
|
|
1415
|
+
const trimmed = line.replace(/\n+$/u, '');
|
|
1416
|
+
if (trimmed.length > 0)
|
|
1417
|
+
lines.push(trimmed);
|
|
1418
|
+
},
|
|
1419
|
+
});
|
|
1420
|
+
for (const line of lines)
|
|
1421
|
+
this.appendSystemLine(line);
|
|
1422
|
+
// Optional one-shot engine dispatch: when the operator typed
|
|
1423
|
+
// a prompt alongside the slash, route it through the existing
|
|
1424
|
+
// dispatch path. We rewrite the verdict into a synthetic
|
|
1425
|
+
// `dispatch` result so the engine sees the user's prompt with
|
|
1426
|
+
// the plan-mode gate already in place. `--auto-back` is NOT
|
|
1427
|
+
// honoured in the slash surface today — operators stay in
|
|
1428
|
+
// plan mode and revert manually with `/plan --back`. The CLI
|
|
1429
|
+
// top-level `pugi plan --auto-back` exists for scripted use.
|
|
1430
|
+
if (verdict.prompt.length > 0 && !verdict.back) {
|
|
1431
|
+
return { kind: 'dispatch', brief: verdict.prompt };
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
catch (error) {
|
|
1435
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1436
|
+
this.appendSystemLine(`/plan failed: ${message}`);
|
|
1437
|
+
}
|
|
1438
|
+
return verdict;
|
|
1439
|
+
}
|
|
1440
|
+
case 'release-notes': {
|
|
1441
|
+
// changelog diff between the operator's
|
|
1442
|
+
// last-seen + installed CLI versions. Delegate к the shared
|
|
1443
|
+
// `runReleaseNotesCommand` runner so the slash + top-level
|
|
1444
|
+
// paths stay single-sourced. The renderer collects each line
|
|
1445
|
+
// into the system pane via `appendSystemLine` — no fresh Ink
|
|
1446
|
+
// mount, no boxed render. `--reset` is honoured via the
|
|
1447
|
+
// `verdict.reset` field parsed in slash-commands.ts.
|
|
1448
|
+
try {
|
|
1449
|
+
const { runReleaseNotesCommand, defaultReleaseNotesHome } = await import('../../runtime/commands/release-notes.js');
|
|
1450
|
+
const lines = [];
|
|
1451
|
+
runReleaseNotesCommand({
|
|
1452
|
+
home: defaultReleaseNotesHome(),
|
|
1453
|
+
json: false,
|
|
1454
|
+
reset: verdict.reset,
|
|
1455
|
+
writeOutput: (_payload, text) => {
|
|
1456
|
+
for (const line of text.split('\n')) {
|
|
1457
|
+
lines.push(line.replace(/\s+$/u, ''));
|
|
1458
|
+
}
|
|
1459
|
+
},
|
|
1460
|
+
});
|
|
1461
|
+
if (lines.length === 0) {
|
|
1462
|
+
this.appendSystemLine('/release-notes: no output.');
|
|
1463
|
+
}
|
|
1464
|
+
else {
|
|
1465
|
+
for (const line of lines)
|
|
1466
|
+
this.appendSystemLine(line);
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
catch (error) {
|
|
1470
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1471
|
+
this.appendSystemLine(`/release-notes failed: ${message}`);
|
|
1472
|
+
}
|
|
1473
|
+
return verdict;
|
|
1474
|
+
}
|
|
1475
|
+
case 'stickers': {
|
|
1476
|
+
// brand-personality gimmick. Delegate to
|
|
1477
|
+
// the shared `runStickersCommand` so the slash + top-level
|
|
1478
|
+
// paths stay single-sourced. The renderer routes the text
|
|
1479
|
+
// through the system pane line-buffer (ascii-only — no fresh
|
|
1480
|
+
// Ink mount) so the gimmick lands as a single contiguous
|
|
1481
|
+
// block в the conversation transcript.
|
|
1482
|
+
try {
|
|
1483
|
+
const { runStickersCommand } = await import('../../runtime/commands/stickers.js');
|
|
1484
|
+
// L33 P1 fix : await the runner even though the
|
|
1485
|
+
// current implementation is synchronous. Two reasons:
|
|
1486
|
+
// (1) future-proofs the call site against the runner growing
|
|
1487
|
+
// an async path (e.g. remote stickerpack fetch) — without
|
|
1488
|
+
// this await, a returned promise would resolve AFTER we
|
|
1489
|
+
// flushed `lines` and the gimmick would render blank, and
|
|
1490
|
+
// (2) keeps the slash dispatcher uniform with the other
|
|
1491
|
+
// command runners (style, doctor, permissions, plan), all
|
|
1492
|
+
// of which are awaited.
|
|
1493
|
+
const lines = [];
|
|
1494
|
+
await runStickersCommand({
|
|
1495
|
+
json: false,
|
|
1496
|
+
asciiOnly: true,
|
|
1497
|
+
writeOutput: (_payload, text) => {
|
|
1498
|
+
for (const line of text.split('\n')) {
|
|
1499
|
+
const trimmed = line.replace(/\s+$/u, '');
|
|
1500
|
+
lines.push(trimmed);
|
|
1501
|
+
}
|
|
1502
|
+
},
|
|
1503
|
+
});
|
|
1504
|
+
if (lines.length === 0) {
|
|
1505
|
+
this.appendSystemLine('/stickers: no output.');
|
|
1506
|
+
}
|
|
1507
|
+
else {
|
|
1508
|
+
for (const line of lines)
|
|
1509
|
+
this.appendSystemLine(line);
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
catch (error) {
|
|
1513
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1514
|
+
this.appendSystemLine(`/stickers failed: ${message}`);
|
|
1515
|
+
}
|
|
1516
|
+
return verdict;
|
|
1517
|
+
}
|
|
1518
|
+
case 'update': {
|
|
1519
|
+
// /update probes the npm registry for a
|
|
1520
|
+
// newer @pugi/cli version on the configured channel and prints
|
|
1521
|
+
// the install command. The slash form NEVER spawns `npm install
|
|
1522
|
+
// -g` — that would corrupt the binary we are currently running.
|
|
1523
|
+
// Operators see the install command + run it manually (or run
|
|
1524
|
+
// `pugi update --apply` from a fresh shell after the REPL
|
|
1525
|
+
// exits). The slash + top-level paths share the dispatcher so
|
|
1526
|
+
// channel resolution + last-check persistence stay single-
|
|
1527
|
+
// sourced.
|
|
1528
|
+
try {
|
|
1529
|
+
const { parseUpdateArgs, runUpdateCommand } = await import('../../runtime/commands/update.js');
|
|
1530
|
+
const parsed = parseUpdateArgs(verdict.args);
|
|
1531
|
+
if ('error' in parsed) {
|
|
1532
|
+
this.appendSystemLine(parsed.error);
|
|
1533
|
+
return verdict;
|
|
1534
|
+
}
|
|
1535
|
+
// Force `apply=false` on the slash path — see comment above.
|
|
1536
|
+
const slashFlags = { ...parsed, apply: false };
|
|
1537
|
+
const lines = [];
|
|
1538
|
+
await runUpdateCommand({
|
|
1539
|
+
cwd: process.cwd(),
|
|
1540
|
+
home: homedir(),
|
|
1541
|
+
env: process.env,
|
|
1542
|
+
flags: slashFlags,
|
|
1543
|
+
promptConfirm: async () => false,
|
|
1544
|
+
writeOutput: (_payload, text) => {
|
|
1545
|
+
for (const line of text.split('\n')) {
|
|
1546
|
+
const trimmed = line.replace(/\s+$/u, '');
|
|
1547
|
+
if (trimmed.length > 0)
|
|
1548
|
+
lines.push(trimmed);
|
|
1549
|
+
}
|
|
1550
|
+
},
|
|
1551
|
+
});
|
|
1552
|
+
if (lines.length === 0) {
|
|
1553
|
+
this.appendSystemLine('/update: no output.');
|
|
1554
|
+
}
|
|
1555
|
+
else {
|
|
1556
|
+
for (const line of lines)
|
|
1557
|
+
this.appendSystemLine(line);
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
catch (error) {
|
|
1561
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1562
|
+
this.appendSystemLine(`/update failed: ${message}`);
|
|
1563
|
+
}
|
|
1564
|
+
return verdict;
|
|
1565
|
+
}
|
|
1566
|
+
case 'feedback': {
|
|
1567
|
+
// in-CLI feedback collector. The wizard
|
|
1568
|
+
// mounts a fresh Ink tree (renderFeedbackPrompt) outside the
|
|
1569
|
+
// live REPL input box so the operator can step through
|
|
1570
|
+
// category / rating / comment / context / confirm without
|
|
1571
|
+
// interleaving with persona output. The session module owns
|
|
1572
|
+
// the submit + queue wiring so the slash + top-level CLI
|
|
1573
|
+
// surfaces stay single-sourced through `runFeedbackCommand`.
|
|
1574
|
+
try {
|
|
1575
|
+
await this.runFeedbackSlash();
|
|
1576
|
+
}
|
|
1577
|
+
catch (error) {
|
|
1578
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1579
|
+
this.appendSystemLine(`/feedback failed: ${message}`);
|
|
1580
|
+
}
|
|
1581
|
+
return verdict;
|
|
1582
|
+
}
|
|
1583
|
+
case 'repo-map': {
|
|
1584
|
+
// AST-light workspace summary. Delegate
|
|
1585
|
+
// к the shared `runRepoMapCommand` so the slash + top-level
|
|
1586
|
+
// paths stay single-sourced. The rendered text lands on the
|
|
1587
|
+
// system pane via `appendSystemLine` (no fresh Ink mount) so
|
|
1588
|
+
// the listing flows into the conversation transcript like
|
|
1589
|
+
// any other command output.
|
|
1590
|
+
try {
|
|
1591
|
+
const { runRepoMapCommand } = await import('../../runtime/commands/repo-map.js');
|
|
1592
|
+
const lines = [];
|
|
1593
|
+
await runRepoMapCommand({
|
|
1594
|
+
cwd: process.cwd(),
|
|
1595
|
+
refresh: verdict.refresh,
|
|
1596
|
+
json: false,
|
|
1597
|
+
writeOutput: (_payload, text) => {
|
|
1598
|
+
for (const line of text.split('\n')) {
|
|
1599
|
+
const trimmed = line.replace(/\s+$/u, '');
|
|
1600
|
+
lines.push(trimmed);
|
|
1601
|
+
}
|
|
1602
|
+
},
|
|
1603
|
+
});
|
|
1604
|
+
if (lines.length === 0) {
|
|
1605
|
+
this.appendSystemLine('/repo-map: no output.');
|
|
1606
|
+
}
|
|
1607
|
+
else {
|
|
1608
|
+
for (const line of lines)
|
|
1609
|
+
this.appendSystemLine(line);
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
catch (error) {
|
|
1613
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1614
|
+
this.appendSystemLine(`/repo-map failed: ${message}`);
|
|
1615
|
+
}
|
|
1616
|
+
return verdict;
|
|
1617
|
+
}
|
|
1618
|
+
case 'undo': {
|
|
1619
|
+
// final : graduated from stub. The runtime
|
|
1620
|
+
// command `runUndoCommand` already exists with full Aider walk-
|
|
1621
|
+
// back semantics — single-step revert of the most recent
|
|
1622
|
+
// successful `write` / `edit` / `multi_edit` tool result, with
|
|
1623
|
+
// an mtime+hash gate that refuses to overwrite uncommitted
|
|
1624
|
+
// operator work. We open a fresh PugiSession against the cwd
|
|
1625
|
+
// so the inverse-mutation audit lands on the same NDJSON
|
|
1626
|
+
// events stream the REPL writes to; dynamic-import keeps the
|
|
1627
|
+
// runner + git plumbing out of the REPL hot path.
|
|
1628
|
+
try {
|
|
1629
|
+
const [{ runUndoCommand }, { openSession }] = await Promise.all([
|
|
1630
|
+
import('../../runtime/commands/undo.js'),
|
|
1631
|
+
import('../session.js'),
|
|
1632
|
+
]);
|
|
1633
|
+
const workspaceRoot = process.cwd();
|
|
1634
|
+
const session = openSession(workspaceRoot);
|
|
1635
|
+
this.appendSystemLine('Reverting last write...');
|
|
1636
|
+
await runUndoCommand([], {
|
|
1637
|
+
workspaceRoot,
|
|
1638
|
+
session,
|
|
1639
|
+
writeOutput: (_payload, text) => {
|
|
1640
|
+
const trimmed = text.replace(/\n+$/u, '');
|
|
1641
|
+
if (trimmed.length > 0)
|
|
1642
|
+
this.appendSystemLine(trimmed);
|
|
1643
|
+
},
|
|
1644
|
+
});
|
|
1645
|
+
}
|
|
1646
|
+
catch (error) {
|
|
1647
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1648
|
+
this.appendSystemLine(`/undo failed: ${message}`);
|
|
1649
|
+
}
|
|
1650
|
+
return verdict;
|
|
1651
|
+
}
|
|
1652
|
+
case 'redo': {
|
|
1653
|
+
// cleanup : counterpart к /undo. The runtime
|
|
1654
|
+
// command `runRedoCommand` consumes one entry from the LIFO
|
|
1655
|
+
// undo stack (most recent unconsumed `tool=undo` result), reads
|
|
1656
|
+
// the captured AFTER content from `.pugi/undo-blobs/`, and
|
|
1657
|
+
// re-applies the mutations under the same mtime+hash external-
|
|
1658
|
+
// modification gate the undo runner uses. Same dynamic-import
|
|
1659
|
+
// posture as /undo so the redo + blob-store + git plumbing
|
|
1660
|
+
// stays out of the REPL hot path.
|
|
1661
|
+
try {
|
|
1662
|
+
const [{ runRedoCommand }, { openSession }] = await Promise.all([
|
|
1663
|
+
import('../../runtime/commands/redo.js'),
|
|
1664
|
+
import('../session.js'),
|
|
1665
|
+
]);
|
|
1666
|
+
const workspaceRoot = process.cwd();
|
|
1667
|
+
const session = openSession(workspaceRoot);
|
|
1668
|
+
this.appendSystemLine('Reapplying last undo...');
|
|
1669
|
+
await runRedoCommand([], {
|
|
1670
|
+
workspaceRoot,
|
|
1671
|
+
session,
|
|
1672
|
+
writeOutput: (_payload, text) => {
|
|
1673
|
+
const trimmed = text.replace(/\n+$/u, '');
|
|
1674
|
+
if (trimmed.length > 0)
|
|
1675
|
+
this.appendSystemLine(trimmed);
|
|
1676
|
+
},
|
|
1677
|
+
});
|
|
1678
|
+
}
|
|
1679
|
+
catch (error) {
|
|
1680
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1681
|
+
this.appendSystemLine(`/redo failed: ${message}`);
|
|
1682
|
+
}
|
|
1683
|
+
return verdict;
|
|
1684
|
+
}
|
|
1685
|
+
case 'plan-artifact': {
|
|
1686
|
+
// Pugi backlog : plan-as-FILE artifact surface.
|
|
1687
|
+
// Dynamic-import the core module so the REPL hot path stays free
|
|
1688
|
+
// of the artifact store + diff renderer until the operator
|
|
1689
|
+
// actually exercises a `/plan show|list|diff|prune` invocation.
|
|
1690
|
+
try {
|
|
1691
|
+
const { readPlan, listPlans, diffPlans, prunePlans, PlanNotFoundError, InvalidPlanIdError, } = await import('../plans/plan-artifact.js');
|
|
1692
|
+
const root = process.cwd();
|
|
1693
|
+
const sub = verdict.sub;
|
|
1694
|
+
if (sub.op === 'show') {
|
|
1695
|
+
try {
|
|
1696
|
+
const record = readPlan(sub.planId, { root });
|
|
1697
|
+
this.appendSystemLine(`plan ${record.frontmatter.planId} (task=${record.frontmatter.taskId}, created=${record.frontmatter.createdAt})`);
|
|
1698
|
+
if (record.frontmatter.supersededBy) {
|
|
1699
|
+
this.appendSystemLine(`superseded by ${record.frontmatter.supersededBy}`);
|
|
1700
|
+
}
|
|
1701
|
+
for (const line of record.body.split('\n')) {
|
|
1702
|
+
this.appendSystemLine(line);
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
catch (error) {
|
|
1706
|
+
if (error instanceof PlanNotFoundError) {
|
|
1707
|
+
this.appendSystemLine(`/plan show: plan not found: ${sub.planId}`);
|
|
1708
|
+
}
|
|
1709
|
+
else if (error instanceof InvalidPlanIdError) {
|
|
1710
|
+
this.appendSystemLine(`/plan show: invalid plan-id: ${sub.planId}`);
|
|
1711
|
+
}
|
|
1712
|
+
else {
|
|
1713
|
+
throw error;
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
else if (sub.op === 'list') {
|
|
1718
|
+
const filter = sub.taskId ? { taskId: sub.taskId, root } : { root };
|
|
1719
|
+
const records = listPlans(filter);
|
|
1720
|
+
if (records.length === 0) {
|
|
1721
|
+
this.appendSystemLine('/plan list: no plans yet.');
|
|
1722
|
+
}
|
|
1723
|
+
else {
|
|
1724
|
+
this.appendSystemLine(`plan-id taskId createdAt supersededBy`);
|
|
1725
|
+
for (const rec of records) {
|
|
1726
|
+
const fm = rec.frontmatter;
|
|
1727
|
+
const supers = fm.supersededBy ?? '-';
|
|
1728
|
+
this.appendSystemLine(`${fm.planId} ${fm.taskId.padEnd(15)} ${fm.createdAt} ${supers}`);
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
else if (sub.op === 'diff') {
|
|
1733
|
+
try {
|
|
1734
|
+
const diff = diffPlans(sub.planId, sub.otherId, { root });
|
|
1735
|
+
for (const line of diff.split('\n')) {
|
|
1736
|
+
this.appendSystemLine(line);
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
catch (error) {
|
|
1740
|
+
if (error instanceof PlanNotFoundError) {
|
|
1741
|
+
this.appendSystemLine(`/plan diff: plan not found`);
|
|
1742
|
+
}
|
|
1743
|
+
else if (error instanceof InvalidPlanIdError) {
|
|
1744
|
+
this.appendSystemLine(`/plan diff: invalid plan-id`);
|
|
1745
|
+
}
|
|
1746
|
+
else {
|
|
1747
|
+
throw error;
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
else {
|
|
1752
|
+
// prune
|
|
1753
|
+
const result = prunePlans(sub.maxAgeDays !== undefined
|
|
1754
|
+
? { root, maxAgeDays: sub.maxAgeDays }
|
|
1755
|
+
: { root });
|
|
1756
|
+
this.appendSystemLine(`/plan prune: cleaned ${result.cleaned} plan${result.cleaned === 1 ? '' : 's'}.`);
|
|
1757
|
+
for (const id of result.removedIds) {
|
|
1758
|
+
this.appendSystemLine(` - ${id}`);
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
catch (error) {
|
|
1763
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1764
|
+
this.appendSystemLine(`/plan ${verdict.sub.op} failed: ${message}`);
|
|
1765
|
+
}
|
|
1766
|
+
return verdict;
|
|
1767
|
+
}
|
|
654
1768
|
case 'stub': {
|
|
655
1769
|
this.appendSystemLine(verdict.message);
|
|
656
1770
|
return verdict;
|
|
657
1771
|
}
|
|
658
1772
|
}
|
|
659
1773
|
}
|
|
1774
|
+
/**
|
|
1775
|
+
* drive the `/feedback` wizard from inside
|
|
1776
|
+
* the REPL. Mounts the Ink prompt, collects the draft, hands it to
|
|
1777
|
+
* `runFeedbackCommand` (which routes to submit-now or
|
|
1778
|
+
* queue-locally), then writes the operator-facing toast to the
|
|
1779
|
+
* conversation system pane.
|
|
1780
|
+
*
|
|
1781
|
+
* The session module owns the wiring (cwd, cliVersion, apiUrl,
|
|
1782
|
+
* apiKey, transcript provider) so the slash + top-level CLI paths
|
|
1783
|
+
* stay single-sourced through `runFeedbackCommand`.
|
|
1784
|
+
*/
|
|
1785
|
+
async runFeedbackSlash() {
|
|
1786
|
+
const { renderFeedbackPrompt } = await import('../../tui/feedback-prompt.js');
|
|
1787
|
+
const { runFeedbackCommand, renderFeedbackToast } = await import('../../runtime/commands/feedback.js');
|
|
1788
|
+
const { submitFeedback, redactSessionContext } = await import('../feedback/submitter.js');
|
|
1789
|
+
const verdict = await renderFeedbackPrompt();
|
|
1790
|
+
if (verdict.cancelled || !verdict.draft) {
|
|
1791
|
+
this.appendSystemLine('Feedback cancelled. Nothing was sent.');
|
|
1792
|
+
return;
|
|
1793
|
+
}
|
|
1794
|
+
// Build a session-context provider that reads the LAST 5 turns
|
|
1795
|
+
// from the live transcript + applies the redactor. Only invoked
|
|
1796
|
+
// when the operator opted in on step 4.
|
|
1797
|
+
const sessionContextProvider = () => {
|
|
1798
|
+
const last5 = this.state.transcript
|
|
1799
|
+
.filter((row) => row.source !== 'system')
|
|
1800
|
+
.slice(-5)
|
|
1801
|
+
.map((row) => ({
|
|
1802
|
+
role: row.source === 'operator' ? 'user' : 'assistant',
|
|
1803
|
+
text: row.text,
|
|
1804
|
+
}));
|
|
1805
|
+
// The workspace context exposed to the session does not carry
|
|
1806
|
+
// a git branch field today, so we omit `gitBranch` here. When
|
|
1807
|
+
// `ReplWorkspaceContext` gains the field we can forward it via
|
|
1808
|
+
// an extra options entry without changing the redactor contract.
|
|
1809
|
+
return redactSessionContext(last5);
|
|
1810
|
+
};
|
|
1811
|
+
const result = await runFeedbackCommand({
|
|
1812
|
+
cwd: process.cwd(),
|
|
1813
|
+
cliVersion: this.options.cliVersion,
|
|
1814
|
+
submit: async (env) => submitFeedback(env, {
|
|
1815
|
+
apiUrl: this.options.apiUrl,
|
|
1816
|
+
apiKey: this.options.apiKey,
|
|
1817
|
+
}),
|
|
1818
|
+
draft: verdict.draft,
|
|
1819
|
+
sessionContext: sessionContextProvider,
|
|
1820
|
+
});
|
|
1821
|
+
this.appendSystemLine(renderFeedbackToast(result));
|
|
1822
|
+
}
|
|
1823
|
+
/**
|
|
1824
|
+
* drive the `/compact` flow from inside the
|
|
1825
|
+
* REPL. Reuses the standalone runner so the wire shape + reason
|
|
1826
|
+
* codes stay single-sourced. The result is echoed into the
|
|
1827
|
+
* transcript as a system line; on success the operator sees the
|
|
1828
|
+
* banner sentinel on next render.
|
|
1829
|
+
*
|
|
1830
|
+
* `trigger='manual'` for explicit `/compact` invocations;
|
|
1831
|
+
* `trigger='auto'` for the threshold gate. The runner records the
|
|
1832
|
+
* trigger in the marker payload so the banner can distinguish them.
|
|
1833
|
+
*/
|
|
1834
|
+
async dispatchCompact(trigger, options = {}) {
|
|
1835
|
+
if (!this.store || !this.localSessionId) {
|
|
1836
|
+
this.appendSystemLine('Local session store is disabled — /compact is unavailable.');
|
|
1837
|
+
return;
|
|
1838
|
+
}
|
|
1839
|
+
try {
|
|
1840
|
+
const { runCompactCommand } = await import('../../runtime/commands/compact.js');
|
|
1841
|
+
const result = await runCompactCommand([], {
|
|
1842
|
+
workspaceRoot: process.cwd(),
|
|
1843
|
+
sessionId: this.localSessionId,
|
|
1844
|
+
store: this.store,
|
|
1845
|
+
trigger,
|
|
1846
|
+
force: options.force === true,
|
|
1847
|
+
writeOutput: (_payload, text) => {
|
|
1848
|
+
if (text.length > 0)
|
|
1849
|
+
this.appendSystemLine(text);
|
|
1850
|
+
},
|
|
1851
|
+
});
|
|
1852
|
+
if (result.status === 'compacted') {
|
|
1853
|
+
// L29 : emit a structured `compact-boundary` row so
|
|
1854
|
+
// the conversation pane routes the marker through the dedicated
|
|
1855
|
+
// `<CompactBanner />` Ink component (gray, terminal-width
|
|
1856
|
+
// separator) instead of leaking the raw text into a `system`
|
|
1857
|
+
// row. The plain-text body is kept as a deterministic fallback
|
|
1858
|
+
// for non-Ink consumers (snapshot tests, JSON-mode exports).
|
|
1859
|
+
const turnsBefore = result.turnsBefore ?? 0;
|
|
1860
|
+
this.appendRow({
|
|
1861
|
+
source: 'compact-boundary',
|
|
1862
|
+
text: `─── context compacted (${turnsBefore} turns → 1 summary, ${trigger}) ───`,
|
|
1863
|
+
compaction: {
|
|
1864
|
+
turnsBefore,
|
|
1865
|
+
trigger,
|
|
1866
|
+
summaryTokenCount: result.tokensSummarised,
|
|
1867
|
+
// Fresh in-REPL compaction lands at the head of the
|
|
1868
|
+
// transcript — no turns have followed it yet.
|
|
1869
|
+
turnsAgo: 0,
|
|
1870
|
+
},
|
|
1871
|
+
});
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
catch (error) {
|
|
1875
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1876
|
+
this.appendSystemLine(`/compact failed: ${message}`);
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
660
1879
|
/**
|
|
661
1880
|
* In-REPL `/privacy` - alpha 6.13. Prints the full 3-mode contract
|
|
662
1881
|
* doc + the current mode banner inline. The current mode is fetched
|
|
@@ -667,7 +1886,7 @@ export class ReplSession {
|
|
|
667
1886
|
*/
|
|
668
1887
|
async dispatchPrivacy() {
|
|
669
1888
|
const { renderPrivacyContractDoc } = await import('./privacy-banner.js');
|
|
670
|
-
// Triple-review P1 fix
|
|
1889
|
+
// Triple-review P1 fix : use the bootstrap-cached mode
|
|
671
1890
|
// so the operator sees the LIVE current mode in the banner header
|
|
672
1891
|
// instead of "(unknown)". The fetch happens once on session start;
|
|
673
1892
|
// if it failed (offline / unauth) the cache stays null and the
|
|
@@ -677,7 +1896,7 @@ export class ReplSession {
|
|
|
677
1896
|
this.appendSystemLine(doc);
|
|
678
1897
|
}
|
|
679
1898
|
/**
|
|
680
|
-
* In-REPL `/resume` -
|
|
1899
|
+
* In-REPL `/resume` - . Lists the 10 most recent sessions from
|
|
681
1900
|
* the local SessionStore and prints them as a numbered system menu.
|
|
682
1901
|
* The Ink-side picker UI is deferred to the next sprint; today the
|
|
683
1902
|
* operator gets a deterministic list + the exact command to relaunch
|
|
@@ -708,7 +1927,7 @@ export class ReplSession {
|
|
|
708
1927
|
const title = (row.title ?? '(untitled)').slice(0, 64);
|
|
709
1928
|
const idShort = row.id.slice(0, 13);
|
|
710
1929
|
const branch = row.branch ?? 'no-branch';
|
|
711
|
-
this.appendSystemLine(`
|
|
1930
|
+
this.appendSystemLine(` ${(i + 1).toString().padStart(2)}. ${idShort} ${branch.padEnd(16)} ${title}`);
|
|
712
1931
|
}
|
|
713
1932
|
this.appendSystemLine('Pick one with: pugi resume <id> (paste the 13-char id from above).');
|
|
714
1933
|
}
|
|
@@ -720,7 +1939,7 @@ export class ReplSession {
|
|
|
720
1939
|
clearTranscript() {
|
|
721
1940
|
this.patch({ transcript: [] });
|
|
722
1941
|
}
|
|
723
|
-
/* -------------
|
|
1942
|
+
/* ------------- office-hours surface -------------- */
|
|
724
1943
|
/**
|
|
725
1944
|
* Surface an `<pugi-ask>` modal manually. Returned promise resolves
|
|
726
1945
|
* with the operator's verdict - used by the `pugi ask "<q>"` shell
|
|
@@ -769,7 +1988,7 @@ export class ReplSession {
|
|
|
769
1988
|
* came from a persona stream, cancel ALSO dispatches a literal
|
|
770
1989
|
* `[ASK-RESPONSE:cancelled]` to admin-api so the persona observes the
|
|
771
1990
|
* cancellation rather than hanging indefinitely on the missing
|
|
772
|
-
* follow-up. The matching documentation in the
|
|
1991
|
+
* follow-up. The matching documentation in the Pugi system prompt
|
|
773
1992
|
* teaches the persona to acknowledge cancellation and offer a
|
|
774
1993
|
* different path. Local-origin modals (synthesised via `/ask`) skip
|
|
775
1994
|
* the dispatch entirely - the persona never saw the question.
|
|
@@ -892,22 +2111,87 @@ export class ReplSession {
|
|
|
892
2111
|
try {
|
|
893
2112
|
const registry = getJobRegistry();
|
|
894
2113
|
const entries = await registry.list();
|
|
895
|
-
|
|
2114
|
+
// cleanup : also scan `.pugi/agent-progress/*.json`
|
|
2115
|
+
// so long-running external agents (the JSON pattern from
|
|
2116
|
+
// `feedback_agent_progress_tracking_pattern.md`) show up next к
|
|
2117
|
+
// background-bash entries. The two surfaces are orthogonal — bash
|
|
2118
|
+
// jobs come from the in-process registry, agent-progress comes from
|
|
2119
|
+
// sidecar JSON written by any agent (Pugi-spawned or external) — so
|
|
2120
|
+
// we render both, sorted with running first.
|
|
2121
|
+
const agentProgressRows = await this.collectAgentProgressRows();
|
|
2122
|
+
if (entries.length === 0 && agentProgressRows.length === 0) {
|
|
896
2123
|
this.appendSystemLine('No background jobs tracked.');
|
|
897
2124
|
return;
|
|
898
2125
|
}
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
const
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
2126
|
+
if (entries.length > 0) {
|
|
2127
|
+
this.appendSystemLine(`Background jobs (${entries.length}):`);
|
|
2128
|
+
for (const entry of entries) {
|
|
2129
|
+
const id = entry.id.replace(/^pj-/, '').slice(0, 8);
|
|
2130
|
+
const status = entry.status;
|
|
2131
|
+
const cmd = entry.command.length > 48 ? `${entry.command.slice(0, 47)}…` : entry.command;
|
|
2132
|
+
this.appendSystemLine(` ${id} ${status.padEnd(10)} ${cmd}`);
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
if (agentProgressRows.length > 0) {
|
|
2136
|
+
this.appendSystemLine(`Agent progress (${agentProgressRows.length}):`);
|
|
2137
|
+
for (const row of agentProgressRows) {
|
|
2138
|
+
this.appendSystemLine(` ${row}`);
|
|
2139
|
+
}
|
|
2140
|
+
this.appendSystemLine('Tip: run `pugi jobs --watch` for the live Ink TUI.');
|
|
905
2141
|
}
|
|
906
2142
|
}
|
|
907
2143
|
catch (error) {
|
|
908
2144
|
this.appendSystemLine(`/jobs failed: ${this.errorMessage(error)}`);
|
|
909
2145
|
}
|
|
910
2146
|
}
|
|
2147
|
+
/**
|
|
2148
|
+
* cleanup : scan `.pugi/agent-progress/*.json`
|
|
2149
|
+
* for in-flight long-running agent tasks and emit a one-line per
|
|
2150
|
+
* agent for the `/jobs` snapshot. Sorting matches the live TUI's
|
|
2151
|
+
* `sortProgressEntries` (running first, then by lastUpdate desc).
|
|
2152
|
+
*
|
|
2153
|
+
* Best-effort: a missing dir, malformed JSON, or bad permissions
|
|
2154
|
+
* yields an empty list and a swallowed error — the in-process
|
|
2155
|
+
* registry view is the older well-tested surface and must never be
|
|
2156
|
+
* gated behind a sidecar dir's health.
|
|
2157
|
+
*/
|
|
2158
|
+
async collectAgentProgressRows() {
|
|
2159
|
+
try {
|
|
2160
|
+
const [{ resolveProgressDir }, { readProgressFile, sortProgressEntries }, fs, path] = await Promise.all([
|
|
2161
|
+
import('../agent-progress/writer.js'),
|
|
2162
|
+
import('../../commands/jobs-watch.js'),
|
|
2163
|
+
import('node:fs'),
|
|
2164
|
+
import('node:path'),
|
|
2165
|
+
]);
|
|
2166
|
+
const dir = resolveProgressDir();
|
|
2167
|
+
if (!fs.existsSync(dir))
|
|
2168
|
+
return [];
|
|
2169
|
+
const files = fs
|
|
2170
|
+
.readdirSync(dir)
|
|
2171
|
+
.filter((f) => f.endsWith('.json'))
|
|
2172
|
+
.map((f) => path.join(dir, f));
|
|
2173
|
+
const progress = files
|
|
2174
|
+
.map((p) => readProgressFile(p))
|
|
2175
|
+
.filter((p) => p !== undefined);
|
|
2176
|
+
const sorted = sortProgressEntries(progress);
|
|
2177
|
+
return sorted.map((p) => {
|
|
2178
|
+
const id = p.agentId.length > 24 ? `${p.agentId.slice(0, 23)}…` : p.agentId;
|
|
2179
|
+
const pct = `${String(Math.round(p.percentComplete)).padStart(3, ' ')}%`;
|
|
2180
|
+
const elapsedSec = Math.max(0, Math.floor(p.elapsedMs / 1000));
|
|
2181
|
+
const elapsed = elapsedSec >= 60
|
|
2182
|
+
? `${Math.floor(elapsedSec / 60)}m${String(elapsedSec % 60).padStart(2, '0')}s`
|
|
2183
|
+
: `${elapsedSec}s`;
|
|
2184
|
+
const status = p.status.padEnd(9, ' ');
|
|
2185
|
+
const step = p.stepDescription.length > 36
|
|
2186
|
+
? `${p.stepDescription.slice(0, 35)}…`
|
|
2187
|
+
: p.stepDescription;
|
|
2188
|
+
return `${id.padEnd(24, ' ')} ${status} ${pct} ${elapsed.padStart(6, ' ')} ${step}`;
|
|
2189
|
+
});
|
|
2190
|
+
}
|
|
2191
|
+
catch {
|
|
2192
|
+
return [];
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
911
2195
|
dispatchDiff() {
|
|
912
2196
|
try {
|
|
913
2197
|
const artifactsRoot = resolvePath(process.cwd(), '.pugi', 'artifacts');
|
|
@@ -923,7 +2207,7 @@ export class ReplSession {
|
|
|
923
2207
|
const candidate = resolvePath(artifactsRoot, name, 'diff.patch');
|
|
924
2208
|
if (existsSync(candidate)) {
|
|
925
2209
|
const size = statSync(candidate).size;
|
|
926
|
-
diffs.push(`
|
|
2210
|
+
diffs.push(` ${name}/diff.patch (${size} bytes)`);
|
|
927
2211
|
}
|
|
928
2212
|
}
|
|
929
2213
|
if (diffs.length === 0) {
|
|
@@ -938,38 +2222,233 @@ export class ReplSession {
|
|
|
938
2222
|
this.appendSystemLine(`/diff failed: ${this.errorMessage(error)}`);
|
|
939
2223
|
}
|
|
940
2224
|
}
|
|
941
|
-
dispatchCost() {
|
|
942
|
-
|
|
2225
|
+
async dispatchCost() {
|
|
2226
|
+
// cost-meter sprint — full breakdown matching the TUI status row
|
|
2227
|
+
// footer. The session totals line mirrors the footer format
|
|
2228
|
+
// (`↑ <in> ↓ <out> · $X.XX · <elapsed>`) so the operator scans the
|
|
2229
|
+
// same numbers in two places. Per-turn list shows the last 5 turns
|
|
2230
|
+
// oldest → newest; an empty list renders one system line so the
|
|
2231
|
+
// operator knows the surface is wired (`No completed turns yet.`).
|
|
2232
|
+
//
|
|
2233
|
+
// L19 — after the in-memory recap, also render the
|
|
2234
|
+
// persisted per-model table from `.pugi/cost.json`. That surface
|
|
2235
|
+
// survives a REPL restart and answers the "what did I spend on
|
|
2236
|
+
// claude-opus vs qwen this week?" question the in-memory recap can
|
|
2237
|
+
// not. Errors loading the file collapse to a single warning line so
|
|
2238
|
+
// the in-memory recap (the older, well-tested surface) is never
|
|
2239
|
+
// gated behind a fresh dependency.
|
|
2240
|
+
const { sessionTokensIn, sessionTokensOut, sessionCostUsd, sessionStartedAtEpochMs, recentTurns, agents, } = this.state;
|
|
943
2241
|
const active = agents.filter((a) => a.status === 'queued' || a.status === 'thinking').length;
|
|
944
|
-
const
|
|
945
|
-
const
|
|
946
|
-
this.appendSystemLine(
|
|
947
|
-
this.appendSystemLine(
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
2242
|
+
const elapsedMs = Math.max(0, this.now() - sessionStartedAtEpochMs);
|
|
2243
|
+
const elapsedLabel = formatElapsedShort(elapsedMs);
|
|
2244
|
+
this.appendSystemLine(`Session: ↑ ${formatTokens(sessionTokensIn)} ↓ ${formatTokens(sessionTokensOut)} · ${formatCostUsd(sessionCostUsd)} · ${elapsedLabel}`);
|
|
2245
|
+
this.appendSystemLine(`Active dispatches: ${active} of cap.`);
|
|
2246
|
+
if (recentTurns.length === 0) {
|
|
2247
|
+
this.appendSystemLine('No completed turns yet — brief the workforce to charge the meter.');
|
|
2248
|
+
}
|
|
2249
|
+
else {
|
|
2250
|
+
this.appendSystemLine(`Recent turns (last ${recentTurns.length}):`);
|
|
2251
|
+
for (let i = 0; i < recentTurns.length; i += 1) {
|
|
2252
|
+
const turn = recentTurns[i];
|
|
2253
|
+
const idx = (i + 1).toString().padStart(2, ' ');
|
|
2254
|
+
this.appendSystemLine(` ${idx}. ↑ ${formatTokens(turn.tokensIn)} ↓ ${formatTokens(turn.tokensOut)} · ${formatCostUsd(turn.costUsd)}`);
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
// L19: append the persisted per-model table from .pugi/cost.json.
|
|
2258
|
+
try {
|
|
2259
|
+
const [{ createCostTracker }, { renderCostForSlash }] = await Promise.all([
|
|
2260
|
+
import('../cost/tracker.js'),
|
|
2261
|
+
import('../../runtime/commands/cost.js'),
|
|
2262
|
+
]);
|
|
2263
|
+
const workspaceRoot = this.options.workspace?.workspaceCwd ?? process.cwd();
|
|
2264
|
+
const sessionId = this.state.sessionId ?? 'no-session';
|
|
2265
|
+
const tracker = createCostTracker({
|
|
2266
|
+
workspaceRoot,
|
|
2267
|
+
sessionIdProvider: () => sessionId,
|
|
2268
|
+
now: () => this.now(),
|
|
2269
|
+
});
|
|
2270
|
+
const current = tracker.current();
|
|
2271
|
+
if (current && Object.keys(current.models).length > 0) {
|
|
2272
|
+
this.appendSystemLine('');
|
|
2273
|
+
const { lines } = renderCostForSlash({
|
|
2274
|
+
tracker,
|
|
2275
|
+
allSessions: false,
|
|
2276
|
+
windowDays: 30,
|
|
2277
|
+
now: () => this.now(),
|
|
2278
|
+
});
|
|
2279
|
+
for (const line of lines)
|
|
2280
|
+
this.appendSystemLine(line);
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
2283
|
+
catch {
|
|
2284
|
+
// best-effort — the persisted view is additive; failure never
|
|
2285
|
+
// breaks the in-memory recap above
|
|
2286
|
+
}
|
|
2287
|
+
}
|
|
2288
|
+
/**
|
|
2289
|
+
* cost-meter sprint — `/quota` slash handler. Fetches the live
|
|
2290
|
+
* `/api/pugi/usage` snapshot and renders three lines: plan tier,
|
|
2291
|
+
* monthly window, and per-counter `used/cap (pct%)`. Failure modes
|
|
2292
|
+
* (offline, unauth, older admin-api) collapse to a single one-line
|
|
2293
|
+
* `Could not fetch quota…` system message so the surface never throws
|
|
2294
|
+
* from a keystroke handler.
|
|
2295
|
+
*
|
|
2296
|
+
* The fetch is best-effort with a 4s timeout — mirrors the `whoami`
|
|
2297
|
+
* pattern in `runtime/cli.ts` so the operator gets the same UX on the
|
|
2298
|
+
* REPL slash and the CLI command.
|
|
2299
|
+
*/
|
|
2300
|
+
async dispatchQuota() {
|
|
2301
|
+
const controller = new AbortController();
|
|
2302
|
+
const timer = setTimeout(() => controller.abort(), 4000);
|
|
2303
|
+
try {
|
|
2304
|
+
const url = `${this.options.apiUrl.replace(/\/+$/, '')}/api/pugi/usage`;
|
|
2305
|
+
const res = await fetch(url, {
|
|
2306
|
+
method: 'GET',
|
|
2307
|
+
headers: {
|
|
2308
|
+
authorization: `Bearer ${this.options.apiKey}`,
|
|
2309
|
+
accept: 'application/json',
|
|
2310
|
+
},
|
|
2311
|
+
signal: controller.signal,
|
|
2312
|
+
});
|
|
2313
|
+
if (!res.ok) {
|
|
2314
|
+
this.appendSystemLine(`Could not fetch quota: HTTP ${res.status}.`);
|
|
2315
|
+
return;
|
|
2316
|
+
}
|
|
2317
|
+
const body = (await res.json());
|
|
2318
|
+
const tier = typeof body.tier === 'string' ? body.tier : '(unknown)';
|
|
2319
|
+
const tierLabel = QUOTA_TIER_LABELS[tier] ?? tier;
|
|
2320
|
+
const month = typeof body.billingMonth === 'string' ? body.billingMonth : '(unknown month)';
|
|
2321
|
+
const resetAt = typeof body.resetAt === 'string' ? body.resetAt : null;
|
|
2322
|
+
const resetLine = resetAt ? ` · resets ${formatResetWindow(resetAt, this.now())}` : '';
|
|
2323
|
+
this.appendSystemLine(`Plan: ${tierLabel} · ${month}${resetLine}`);
|
|
2324
|
+
const used = body.used ?? {};
|
|
2325
|
+
const caps = body.quotas ?? {};
|
|
2326
|
+
const counters = [
|
|
2327
|
+
['sync', used.sync, caps.sync],
|
|
2328
|
+
['review', used.review, caps.review],
|
|
2329
|
+
['engine', used.engine, caps.engine],
|
|
2330
|
+
];
|
|
2331
|
+
// cleanup : color-code each counter row by
|
|
2332
|
+
// utilisation. The thresholds match the upstream tool's tier-meter
|
|
2333
|
+
// convention so operators trained on that surface read the same
|
|
2334
|
+
// signal here. ANSI codes wrap the WHOLE row (not just the
|
|
2335
|
+
// percent) so the line wraps as one visual unit; the cost-quota
|
|
2336
|
+
// spec regex still matches because anchors are inside the
|
|
2337
|
+
// wrapped substring.
|
|
2338
|
+
for (const [name, value, cap] of counters) {
|
|
2339
|
+
const v = typeof value === 'number' ? value : 0;
|
|
2340
|
+
if (cap === null || cap === undefined) {
|
|
2341
|
+
// Unlimited counters never trip the gauge — leave them
|
|
2342
|
+
// uncolored so the eye does not register an alarm signal
|
|
2343
|
+
// where there is no cap к exhaust.
|
|
2344
|
+
this.appendSystemLine(` ${name.padEnd(7, ' ')} ${v.toLocaleString()} / unlimited`);
|
|
2345
|
+
}
|
|
2346
|
+
else {
|
|
2347
|
+
const pct = cap > 0 ? Math.round((v / cap) * 100) : 0;
|
|
2348
|
+
const row = ` ${name.padEnd(7, ' ')} ${v.toLocaleString()} / ${cap.toLocaleString()} (${pct}%)`;
|
|
2349
|
+
this.appendSystemLine(colorizeQuotaRow(row, pct));
|
|
2350
|
+
}
|
|
2351
|
+
}
|
|
2352
|
+
}
|
|
2353
|
+
catch (error) {
|
|
2354
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
2355
|
+
this.appendSystemLine(`Could not fetch quota: ${msg}.`);
|
|
2356
|
+
}
|
|
2357
|
+
finally {
|
|
2358
|
+
clearTimeout(timer);
|
|
2359
|
+
}
|
|
2360
|
+
}
|
|
2361
|
+
/**
|
|
2362
|
+
* In-REPL `/status` — . Surfaces the full
|
|
2363
|
+
* session snapshot (id + age, cwd, permission mode, CLI version,
|
|
2364
|
+
* tokens, dispatches, last cmd, compact boundaries, auth identity,
|
|
2365
|
+
* connection) by delegating к the same `runStatusCommand` the
|
|
2366
|
+
* top-level `pugi status` shell uses. Live REPL state (session
|
|
2367
|
+
* id, token totals, last operator command) flows in through the
|
|
2368
|
+
* context so the slash variant shows MORE than the shell path.
|
|
2369
|
+
*
|
|
2370
|
+
* The renderer routes к the system pane via `appendSystemLine`
|
|
2371
|
+
* so the snapshot lands as a single contiguous block в the
|
|
2372
|
+
* conversation transcript. Migrating к the Ink `<StatusTable>`
|
|
2373
|
+
* mounted directly в the REPL frame is a follow-up sprint —
|
|
2374
|
+
* keeping the line-buffered path here avoids cycling the
|
|
2375
|
+
* conversation pane's render model mid-.
|
|
2376
|
+
*/
|
|
2377
|
+
async dispatchStatus() {
|
|
2378
|
+
try {
|
|
2379
|
+
const { runStatusCommand, defaultStatusHome } = await import('../../runtime/commands/status.js');
|
|
2380
|
+
// Find the most-recent operator transcript row + its timestamp
|
|
2381
|
+
// so the snapshot's `Last cmd` field has real content в REPL
|
|
2382
|
+
// mode. Walking от newest end is O(transcript) worst case but
|
|
2383
|
+
// bounded by MAX_TRANSCRIPT_ROWS so this stays cheap.
|
|
2384
|
+
let lastCommand = null;
|
|
2385
|
+
let lastCommandAtEpochMs = null;
|
|
2386
|
+
for (let i = this.state.transcript.length - 1; i >= 0; i -= 1) {
|
|
2387
|
+
const row = this.state.transcript[i];
|
|
2388
|
+
if (row.source === 'operator') {
|
|
2389
|
+
lastCommand = row.text;
|
|
2390
|
+
lastCommandAtEpochMs = row.timestampEpochMs;
|
|
2391
|
+
break;
|
|
2392
|
+
}
|
|
2393
|
+
}
|
|
2394
|
+
const liveTokens = this.state.sessionTokensIn + this.state.sessionTokensOut;
|
|
2395
|
+
const lines = [];
|
|
2396
|
+
await runStatusCommand({
|
|
2397
|
+
cwd: process.cwd(),
|
|
2398
|
+
home: defaultStatusHome(),
|
|
2399
|
+
env: process.env,
|
|
2400
|
+
json: false,
|
|
2401
|
+
liveSessionId: this.state.sessionId ?? null,
|
|
2402
|
+
sessionStartedAtEpochMs: this.state.sessionStartedAtEpochMs,
|
|
2403
|
+
liveTokensUsed: liveTokens >= 0 ? liveTokens : 0,
|
|
2404
|
+
lastCommand,
|
|
2405
|
+
lastCommandAtEpochMs,
|
|
2406
|
+
// Repl-mode context: the session knows both the live
|
|
2407
|
+
// transport URL and the operator's workspace label, so we
|
|
2408
|
+
// forward them as authoritative inputs к the snapshot.
|
|
2409
|
+
// The status snapshot used к infer these from the
|
|
2410
|
+
// credentials file, which was wrong in two cases:
|
|
2411
|
+
// (a) the operator was inside a REPL talking к Anvil dev
|
|
2412
|
+
// (port 4100) but credentials still pointed к
|
|
2413
|
+
// api.pugi.io — the `Backend` row mis-reported;
|
|
2414
|
+
// (b) `workspaceLabel` was никогда rendered at all.
|
|
2415
|
+
liveApiUrl: this.options.apiUrl,
|
|
2416
|
+
workspaceLabel: this.options.workspaceLabel,
|
|
2417
|
+
writeOutput: (_payload, text) => {
|
|
2418
|
+
for (const line of text.split('\n')) {
|
|
2419
|
+
const trimmed = line.replace(/\s+$/u, '');
|
|
2420
|
+
if (trimmed.length > 0)
|
|
2421
|
+
lines.push(trimmed);
|
|
2422
|
+
}
|
|
2423
|
+
},
|
|
2424
|
+
});
|
|
2425
|
+
if (lines.length === 0) {
|
|
2426
|
+
this.appendSystemLine('/status: no output.');
|
|
2427
|
+
return;
|
|
2428
|
+
}
|
|
2429
|
+
for (const line of lines)
|
|
2430
|
+
this.appendSystemLine(line);
|
|
2431
|
+
}
|
|
2432
|
+
catch (error) {
|
|
2433
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2434
|
+
this.appendSystemLine(`/status failed: ${message}`);
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
2437
|
+
/**
|
|
2438
|
+
* `/context` slash handler. Surfaces the three-tier context
|
|
960
2439
|
* summary as a stack of system lines. Sections (in order):
|
|
961
2440
|
*
|
|
962
|
-
*
|
|
963
|
-
*
|
|
964
|
-
*
|
|
2441
|
+
* 1. Tier 0 (repo skeleton) - size in bytes, branch, package
|
|
2442
|
+
* manager, languages. Skipped when no skeleton was injected
|
|
2443
|
+
* (REPL launched outside a workspace or with --no-context).
|
|
965
2444
|
*
|
|
966
|
-
*
|
|
967
|
-
*
|
|
968
|
-
*
|
|
969
|
-
*
|
|
2445
|
+
* 2. Tier 1 (working set) - `count / capacity` plus the total
|
|
2446
|
+
* size in bytes plus the oldest entry's age in seconds.
|
|
2447
|
+
* Always emits even when empty so the operator can confirm
|
|
2448
|
+
* the tier is wired.
|
|
970
2449
|
*
|
|
971
|
-
*
|
|
972
|
-
*
|
|
2450
|
+
* 3. Tier 2 (RAG) - one-line heads-up that the Anvil-side
|
|
2451
|
+
* workspace lands in .
|
|
973
2452
|
*
|
|
974
2453
|
* The renderer never mutates state.
|
|
975
2454
|
*/
|
|
@@ -998,10 +2477,10 @@ export class ReplSession {
|
|
|
998
2477
|
else {
|
|
999
2478
|
this.appendSystemLine('Tier 1 working set: not wired.');
|
|
1000
2479
|
}
|
|
1001
|
-
this.appendSystemLine('Tier 2 RAG: deferred to
|
|
2480
|
+
this.appendSystemLine('Tier 2 RAG: deferred to (Anvil-side per-tenant workspace).');
|
|
1002
2481
|
}
|
|
1003
2482
|
/**
|
|
1004
|
-
*
|
|
2483
|
+
* chokidar batch handler. Forwards each event to the working
|
|
1005
2484
|
* set tracker (so `unlink` evicts and `add`/`change` bump the
|
|
1006
2485
|
* recency) and emits at most one throttled system line per
|
|
1007
2486
|
* `FILEWATCH_SYSTEM_LINE_GAP_MS` window.
|
|
@@ -1009,7 +2488,7 @@ export class ReplSession {
|
|
|
1009
2488
|
* The transcript surface intentionally shows ONE filename + the
|
|
1010
2489
|
* count of additional changes (`file changed: src/foo.ts (+3 more)`).
|
|
1011
2490
|
* The full event list is preserved in the buffer for future
|
|
1012
|
-
* `/context --files` deep-dive (not in
|
|
2491
|
+
* `/context --files` deep-dive (not in Phase 1).
|
|
1013
2492
|
*/
|
|
1014
2493
|
recordFilewatchBatch(batch) {
|
|
1015
2494
|
// Hard-guard against post-close invocation. close() detaches the
|
|
@@ -1018,7 +2497,7 @@ export class ReplSession {
|
|
|
1018
2497
|
// listener captured at the start of emit(). If the session closes
|
|
1019
2498
|
// mid-emit, the handler can still fire on a dead session. Returning
|
|
1020
2499
|
// early keeps the working set + transcript untouched.
|
|
1021
|
-
// triple-review P1 (PR
|
|
2500
|
+
// triple-review P1 (PR).
|
|
1022
2501
|
if (this.closed)
|
|
1023
2502
|
return;
|
|
1024
2503
|
if (this.workingSet) {
|
|
@@ -1038,7 +2517,7 @@ export class ReplSession {
|
|
|
1038
2517
|
// do not emit a system line. Cap the buffer at
|
|
1039
2518
|
// PENDING_FILEWATCH_BATCH_CAP and drop the oldest on overflow so
|
|
1040
2519
|
// a noisy filewatch source cannot drive unbounded memory growth
|
|
1041
|
-
// across a long REPL session. triple-review P1 (PR
|
|
2520
|
+
// across a long REPL session. triple-review P1 (PR).
|
|
1042
2521
|
if (this.pendingFilewatchBatches.length >= PENDING_FILEWATCH_BATCH_CAP) {
|
|
1043
2522
|
this.pendingFilewatchBatches.shift();
|
|
1044
2523
|
if (!this.pendingFilewatchOverflowWarned) {
|
|
@@ -1066,14 +2545,14 @@ export class ReplSession {
|
|
|
1066
2545
|
this.pendingFilewatchBatches = [];
|
|
1067
2546
|
}
|
|
1068
2547
|
/**
|
|
1069
|
-
*
|
|
2548
|
+
* chokidar cap-exceeded handler. The watcher closes itself
|
|
1070
2549
|
* when it crosses the watched-paths cap; the session surfaces a
|
|
1071
2550
|
* single system line so the operator knows live updates are off.
|
|
1072
2551
|
* The conversation stays usable - we just lose the file-changed
|
|
1073
2552
|
* badge for the rest of the session.
|
|
1074
2553
|
*/
|
|
1075
2554
|
recordFilewatchCapExceeded(info) {
|
|
1076
|
-
// Same post-close guard as recordFilewatchBatch. triple-review P1 (PR
|
|
2555
|
+
// Same post-close guard as recordFilewatchBatch. triple-review P1 (PR).
|
|
1077
2556
|
if (this.closed)
|
|
1078
2557
|
return;
|
|
1079
2558
|
this.appendSystemLine(`Filewatch off: ${info.watchedCount} watched paths exceeded cap (${info.cap}). Falling back to manual stat-on-read.`);
|
|
@@ -1081,7 +2560,7 @@ export class ReplSession {
|
|
|
1081
2560
|
/**
|
|
1082
2561
|
* Fetch one URL via the web_fetch tool and inject the resulting
|
|
1083
2562
|
* Markdown into the transcript as an operator-attributed brief. The
|
|
1084
|
-
* `<untrusted-content>` sentinel travels with the body so the
|
|
2563
|
+
* `<untrusted-content>` sentinel travels with the body so the Pugi
|
|
1085
2564
|
* system prompt can refuse to follow instructions inside it.
|
|
1086
2565
|
*
|
|
1087
2566
|
* Gating: the dispatcher reads PugiSettings from disk on every
|
|
@@ -1136,33 +2615,36 @@ export class ReplSession {
|
|
|
1136
2615
|
this.appendSystemLine(capLine);
|
|
1137
2616
|
}
|
|
1138
2617
|
this.appendOperatorLine(brief);
|
|
1139
|
-
|
|
1140
|
-
//
|
|
2618
|
+
// Reset `lastCompletedOutcome` so a fresh dispatch does not
|
|
2619
|
+
// inherit the prior turn's status-bar label (e.g. a stale
|
|
2620
|
+
// "replied" sticking around while the next dispatch is in flight).
|
|
2621
|
+
this.patch({ briefStartedAtEpochMs: this.now(), lastCompletedOutcome: null });
|
|
2622
|
+
// + R3 P1 (Codex triple-review): supersede the
|
|
1141
2623
|
// prior dispatch when one is in flight. Steps in order:
|
|
1142
2624
|
//
|
|
1143
|
-
//
|
|
1144
|
-
//
|
|
1145
|
-
//
|
|
1146
|
-
//
|
|
1147
|
-
//
|
|
1148
|
-
//
|
|
1149
|
-
//
|
|
1150
|
-
//
|
|
1151
|
-
//
|
|
1152
|
-
//
|
|
1153
|
-
//
|
|
1154
|
-
//
|
|
1155
|
-
//
|
|
1156
|
-
//
|
|
1157
|
-
//
|
|
1158
|
-
//
|
|
1159
|
-
//
|
|
1160
|
-
//
|
|
1161
|
-
//
|
|
1162
|
-
//
|
|
1163
|
-
//
|
|
1164
|
-
//
|
|
1165
|
-
//
|
|
2625
|
+
// 1. Abort the old CancellationToken so any in-flight tool
|
|
2626
|
+
// holding `ctx.cancellation` sees `isAborted = true` and bails
|
|
2627
|
+
// (the R2 fix; preserves the file-tools cancellation gate).
|
|
2628
|
+
// 2. Drive the OLD FSM through `aborting -> aborted` terminal.
|
|
2629
|
+
// This is load-bearing for the R3 race: a LATE event arriving
|
|
2630
|
+
// on the old FSM (`agent.spawned`, `agent.step`, terminal,
|
|
2631
|
+
// etc.) before the timestamp gate trips would otherwise still
|
|
2632
|
+
// attempt to transition the new FSM. Driving the old FSM to a
|
|
2633
|
+
// terminal state means the FSM check in
|
|
2634
|
+
// `advanceFsmOnDispatchEnd` (`isTerminal`) short-circuits as a
|
|
2635
|
+
// defense-in-depth layer.
|
|
2636
|
+
// 3. `resetFsmToIdle()` mints a fresh FSM so the new dispatch
|
|
2637
|
+
// starts clean. The FSM legal-transition matrix forbids
|
|
2638
|
+
// `aborted -> awaiting_response`, so the reset is required.
|
|
2639
|
+
// 4. Record `currentDispatchStartTime` BEFORE bumping
|
|
2640
|
+
// `dispatchSeq` + clearing `taskDispatchSeq`. The timestamp
|
|
2641
|
+
// gate in `handleServerEvent` checks
|
|
2642
|
+
// `event.timestamp < currentDispatchStartTime` to drop late
|
|
2643
|
+
// events from any superseded dispatch (including the late
|
|
2644
|
+
// `agent.spawned` that the R2 seq gate could not catch).
|
|
2645
|
+
// 5. Clear `taskDispatchSeq` so any stamp left over from the old
|
|
2646
|
+
// dispatch cannot influence seq comparisons for the new turn.
|
|
2647
|
+
// 6. Bump `dispatchSeq` and mint a fresh `CancellationToken`.
|
|
1166
2648
|
//
|
|
1167
2649
|
// If no prior dispatch is in flight (clean idle / terminal entry),
|
|
1168
2650
|
// the supersede block is skipped; we only reset the FSM if it sits
|
|
@@ -1214,7 +2696,7 @@ export class ReplSession {
|
|
|
1214
2696
|
if (this.fsm.current === 'idle') {
|
|
1215
2697
|
this.fsm.transition('awaiting_response', 'brief_dispatched');
|
|
1216
2698
|
}
|
|
1217
|
-
//
|
|
2699
|
+
// : re-open the SSE stream if a prior `cancel()` tore it
|
|
1218
2700
|
// down. Without this, the new brief would dispatch on admin-api
|
|
1219
2701
|
// but the client would never observe `agent.spawned` / `step` /
|
|
1220
2702
|
// `completed` — the operator would see a stalled status bar
|
|
@@ -1233,14 +2715,14 @@ export class ReplSession {
|
|
|
1233
2715
|
}
|
|
1234
2716
|
catch (error) {
|
|
1235
2717
|
this.appendSystemLine(`Brief dispatch refused: ${this.errorMessage(error)}`);
|
|
1236
|
-
//
|
|
2718
|
+
// : a failed brief POST never produced a turn, so we move
|
|
1237
2719
|
// the FSM straight to `failed` so the bottom-bar surfaces the
|
|
1238
2720
|
// outcome and the next brief can mint a fresh token.
|
|
1239
2721
|
this.markDispatchFailed('post_brief_failed');
|
|
1240
2722
|
}
|
|
1241
2723
|
}
|
|
1242
2724
|
/**
|
|
1243
|
-
*
|
|
2725
|
+
* : reset the FSM to `idle` after a terminal transition so the
|
|
1244
2726
|
* next brief can start. The FSM does not allow direct
|
|
1245
2727
|
* `completed -> awaiting_response`, so we mint a fresh FSM by
|
|
1246
2728
|
* overwriting the field. Listeners on the old FSM are dropped (they
|
|
@@ -1269,7 +2751,7 @@ export class ReplSession {
|
|
|
1269
2751
|
this.patch({ dispatchState: 'idle', dispatchToolLabel: null });
|
|
1270
2752
|
}
|
|
1271
2753
|
/**
|
|
1272
|
-
*
|
|
2754
|
+
* : short-circuit the FSM to `failed` on a non-recoverable
|
|
1273
2755
|
* dispatch error (network refusal, malformed event, etc). Idempotent
|
|
1274
2756
|
* — a second call from a terminal state is a no-op.
|
|
1275
2757
|
*/
|
|
@@ -1285,7 +2767,7 @@ export class ReplSession {
|
|
|
1285
2767
|
if (this.fsm.current === 'aborting')
|
|
1286
2768
|
return;
|
|
1287
2769
|
this.fsm.transition('failed', reason);
|
|
1288
|
-
//
|
|
2770
|
+
// P1 fix (Claude triple-review): postBrief threw between
|
|
1289
2771
|
// openStream() and dispatch registration server-side. The local
|
|
1290
2772
|
// SSE handle is open but listening for events under a dispatchId
|
|
1291
2773
|
// the admin-api never created. If we leave it open, any inbound
|
|
@@ -1294,7 +2776,7 @@ export class ReplSession {
|
|
|
1294
2776
|
// IllegalDispatchTransitionError. Tear down so the next brief
|
|
1295
2777
|
// re-opens cleanly via dispatchBrief's openStream() gate.
|
|
1296
2778
|
//
|
|
1297
|
-
// R2 P2 fix (Claude triple-review
|
|
2779
|
+
// R2 P2 fix (Claude triple-review): tear down the
|
|
1298
2780
|
// stream BEFORE nulling the token. Same ordering contract as
|
|
1299
2781
|
// `cancel()`: any onAbort listener fired during teardown should
|
|
1300
2782
|
// observe the (now-aborted) token via getCurrentDispatchToken()
|
|
@@ -1352,7 +2834,7 @@ export class ReplSession {
|
|
|
1352
2834
|
onError: (error) => {
|
|
1353
2835
|
if (this.closed)
|
|
1354
2836
|
return;
|
|
1355
|
-
//
|
|
2837
|
+
// wave 5: when admin-api restarts it drops the in-memory
|
|
1356
2838
|
// session store, so subscribe returns HTTP 404 forever on the
|
|
1357
2839
|
// saved sessionId. Detect that case and mint a fresh server
|
|
1358
2840
|
// session silently rather than spamming the operator with
|
|
@@ -1382,7 +2864,7 @@ export class ReplSession {
|
|
|
1382
2864
|
void this.recreateSessionSilently();
|
|
1383
2865
|
return;
|
|
1384
2866
|
}
|
|
1385
|
-
//
|
|
2867
|
+
// CEO dogfood (parity with the upstream tool):
|
|
1386
2868
|
// collapse the repeated "Stream interrupted (fetch failed).
|
|
1387
2869
|
// Reconnecting." spam. The status bar already shows
|
|
1388
2870
|
// connection='reconnecting' AND the attempt counter; pushing
|
|
@@ -1407,7 +2889,7 @@ export class ReplSession {
|
|
|
1407
2889
|
* `Error("HTTP 404 on SSE stream")`. We pattern-match on the status
|
|
1408
2890
|
* 404 so a different transport (e.g. a test fake or a future polling
|
|
1409
2891
|
* fallback) can surface the same intent with the same shape.
|
|
1410
|
-
*
|
|
2892
|
+
*
|
|
1411
2893
|
*/
|
|
1412
2894
|
isSessionNotFoundError(error) {
|
|
1413
2895
|
const msg = this.errorMessage(error);
|
|
@@ -1420,7 +2902,7 @@ export class ReplSession {
|
|
|
1420
2902
|
* a permanently down admin-api fails loud after a few seconds of
|
|
1421
2903
|
* trying. Logged once per attempt at debug level (we surface a
|
|
1422
2904
|
* single visible line on first auto-recreate so the operator knows
|
|
1423
|
-
* what happened, then stay quiet).
|
|
2905
|
+
* what happened, then stay quiet).
|
|
1424
2906
|
*/
|
|
1425
2907
|
async recreateSessionSilently() {
|
|
1426
2908
|
if (this.closed)
|
|
@@ -1463,6 +2945,7 @@ export class ReplSession {
|
|
|
1463
2945
|
apiUrl: this.options.apiUrl,
|
|
1464
2946
|
apiKey: this.options.apiKey,
|
|
1465
2947
|
workspace: this.options.workspace,
|
|
2948
|
+
cyberZoo: this.options.cyberZoo,
|
|
1466
2949
|
});
|
|
1467
2950
|
this.patch({ sessionId, connection: 'connecting' });
|
|
1468
2951
|
this.openStream();
|
|
@@ -1494,7 +2977,7 @@ export class ReplSession {
|
|
|
1494
2977
|
}
|
|
1495
2978
|
/* ------------- event reducer -------------- */
|
|
1496
2979
|
handleServerEvent(event) {
|
|
1497
|
-
// R3 P1 fix (Codex triple-review
|
|
2980
|
+
// R3 P1 fix (Codex triple-review): wall-clock gate that
|
|
1498
2981
|
// drops events from a SUPERSEDED dispatch. The R2 seq gate alone
|
|
1499
2982
|
// could not catch a LATE `agent.spawned` for an old taskId arriving
|
|
1500
2983
|
// AFTER `dispatchBrief` already bumped `dispatchSeq`. The late
|
|
@@ -1522,16 +3005,16 @@ export class ReplSession {
|
|
|
1522
3005
|
switch (event.type) {
|
|
1523
3006
|
case 'agent.spawned': {
|
|
1524
3007
|
const persona = safePersonaName(event.role);
|
|
1525
|
-
//
|
|
1526
|
-
// persona slug. The
|
|
3008
|
+
// fix: the roster collapses to one row per
|
|
3009
|
+
// persona slug. The reducer pushed a fresh row on every
|
|
1527
3010
|
// spawn, so after three turns the bottom panel stacked
|
|
1528
3011
|
// "Pugi orchestrator shipped" three times. The new contract:
|
|
1529
|
-
//
|
|
1530
|
-
//
|
|
1531
|
-
//
|
|
1532
|
-
//
|
|
1533
|
-
//
|
|
1534
|
-
//
|
|
3012
|
+
// - If a row already exists for this personaSlug, REUSE it.
|
|
3013
|
+
// Replace its taskId, reset status to 'queued', clear the
|
|
3014
|
+
// detail line, restart the duration clock, zero the token
|
|
3015
|
+
// counters. The persona name + slug + role stay stable
|
|
3016
|
+
// (they are the row identity).
|
|
3017
|
+
// - If no row exists yet, push a new one.
|
|
1535
3018
|
// Per-task lifecycle (step/tokens/completed/blocked/failed) is
|
|
1536
3019
|
// keyed off `taskId` everywhere, so the reused row still folds
|
|
1537
3020
|
// the latest task's events correctly.
|
|
@@ -1555,7 +3038,7 @@ export class ReplSession {
|
|
|
1555
3038
|
else {
|
|
1556
3039
|
this.patch({ agents: [node, ...this.state.agents] });
|
|
1557
3040
|
}
|
|
1558
|
-
// R2 P1 fix (Codex triple-review
|
|
3041
|
+
// R2 P1 fix (Codex triple-review): stamp the live
|
|
1559
3042
|
// dispatch sequence onto this taskId so terminal handlers can
|
|
1560
3043
|
// tell apart a "current dispatch" event from a "superseded
|
|
1561
3044
|
// dispatch" event. See `dispatchSeq` + `taskDispatchSeq`
|
|
@@ -1567,7 +3050,7 @@ export class ReplSession {
|
|
|
1567
3050
|
// double-print. `void persona` keeps the resolved name in scope
|
|
1568
3051
|
// for the agent tree node above without leaking it into the
|
|
1569
3052
|
// transcript body.
|
|
1570
|
-
//
|
|
3053
|
+
// CEO dogfood: drop the "dispatched (X)"
|
|
1571
3054
|
// transcript echo. The agent tree pane already shows the
|
|
1572
3055
|
// spawned state; printing it as a persona row is pure noise
|
|
1573
3056
|
// between the operator's brief and the persona's real reply.
|
|
@@ -1575,7 +3058,7 @@ export class ReplSession {
|
|
|
1575
3058
|
return;
|
|
1576
3059
|
}
|
|
1577
3060
|
case 'agent.step': {
|
|
1578
|
-
//
|
|
3061
|
+
// office-hours: scan the running buffer for `<pugi-ask>` /
|
|
1579
3062
|
// `<pugi-plan-review>` envelopes BEFORE we cache the detail.
|
|
1580
3063
|
// The parser returns the cleaned remainder with the raw XML
|
|
1581
3064
|
// stripped, so the operator never sees the envelope as prose.
|
|
@@ -1588,7 +3071,7 @@ export class ReplSession {
|
|
|
1588
3071
|
if (sanitised && sanitised.trim().length > 0) {
|
|
1589
3072
|
this.lastStepDetail.set(event.taskId, sanitised);
|
|
1590
3073
|
}
|
|
1591
|
-
//
|
|
3074
|
+
// : synthesise a tool call entry when the step detail
|
|
1592
3075
|
// matches a tool-invocation grammar. The pattern is generous
|
|
1593
3076
|
// (Read(path) / Edit(path:lines) / Bash(cmd) / Grep(pat) /
|
|
1594
3077
|
// Glob(pat) / WebFetch(url)) so the pane has rows to render
|
|
@@ -1603,7 +3086,7 @@ export class ReplSession {
|
|
|
1603
3086
|
});
|
|
1604
3087
|
if (synthesised) {
|
|
1605
3088
|
this.appendToolCall(synthesised);
|
|
1606
|
-
//
|
|
3089
|
+
// : a fresh tool call moves the FSM to `tool_running`
|
|
1607
3090
|
// when the dispatch is still active. The status-bar surface
|
|
1608
3091
|
// also gets a short label (`tool: read`, `tool: bash`, etc).
|
|
1609
3092
|
// Aborting / terminal states are not allowed to transition
|
|
@@ -1619,8 +3102,22 @@ export class ReplSession {
|
|
|
1619
3102
|
}
|
|
1620
3103
|
case 'agent.tokens': {
|
|
1621
3104
|
const delta = event.tokensIn + event.tokensOut;
|
|
3105
|
+
// cost-meter sprint — bind a client-side USD figure to this
|
|
3106
|
+
// frame. The model slug rides on the event (optional for back-
|
|
3107
|
+
// compat); the price ladder in `model-pricing.ts` falls back to
|
|
3108
|
+
// a Sonnet-tier rate when the slug is missing, so the meter is
|
|
3109
|
+
// always populated. Negative / NaN values are clamped to zero
|
|
3110
|
+
// inside `computeCostUsd` so a buggy upstream never credits the
|
|
3111
|
+
// meter.
|
|
3112
|
+
const deltaCostUsd = computeCostUsd(event.tokensIn, event.tokensOut, event.model);
|
|
1622
3113
|
this.patch({
|
|
1623
3114
|
tokensDownstreamTotal: this.state.tokensDownstreamTotal + delta,
|
|
3115
|
+
sessionTokensIn: this.state.sessionTokensIn + event.tokensIn,
|
|
3116
|
+
sessionTokensOut: this.state.sessionTokensOut + event.tokensOut,
|
|
3117
|
+
sessionCostUsd: this.state.sessionCostUsd + deltaCostUsd,
|
|
3118
|
+
turnTokensIn: this.state.turnTokensIn + event.tokensIn,
|
|
3119
|
+
turnTokensOut: this.state.turnTokensOut + event.tokensOut,
|
|
3120
|
+
turnCostUsd: this.state.turnCostUsd + deltaCostUsd,
|
|
1624
3121
|
agents: this.state.agents.map((a) => a.taskId === event.taskId
|
|
1625
3122
|
? {
|
|
1626
3123
|
...a,
|
|
@@ -1640,17 +3137,49 @@ export class ReplSession {
|
|
|
1640
3137
|
}
|
|
1641
3138
|
this.askBuffer.delete(event.taskId);
|
|
1642
3139
|
this.askBufferPending.delete(event.taskId);
|
|
3140
|
+
// Honour the work-done signal from admin-api.
|
|
3141
|
+
// `outcome === 'replied'` means the turn was a pure text reply
|
|
3142
|
+
// with no delegate XML and no tool call — render it as
|
|
3143
|
+
// "replied" so the operator can tell the difference between
|
|
3144
|
+
// "the orchestrator just talked" and "real work shipped".
|
|
3145
|
+
// Older servers omit the field; default to 'shipped' so the
|
|
3146
|
+
// existing wire stays back-compat.
|
|
3147
|
+
const completedStatus = event.outcome === 'replied' ? 'replied' : 'shipped';
|
|
1643
3148
|
this.patch({
|
|
1644
3149
|
agents: this.state.agents.map((a) => a.taskId === event.taskId
|
|
1645
|
-
? { ...a, status:
|
|
3150
|
+
? { ...a, status: completedStatus, detail: completedStatus }
|
|
1646
3151
|
: a),
|
|
3152
|
+
// Mirror the outcome to top-level state so the status-bar
|
|
3153
|
+
// can render `replied` instead of the legacy `shipped`
|
|
3154
|
+
// label when the FSM lands in `completed`. Without this
|
|
3155
|
+
// the bottom-bar would still say "shipped" while the
|
|
3156
|
+
// agent-tree said "replied", restoring the same
|
|
3157
|
+
// contradiction this PR is fixing (Codex triple-review P2).
|
|
3158
|
+
//
|
|
3159
|
+
// r2: gate on the same stale-dispatch check that
|
|
3160
|
+
// advanceFsmOnDispatchEnd applies. If this completion
|
|
3161
|
+
// belongs to a SUPERSEDED dispatch (a newer dispatchBrief
|
|
3162
|
+
// already bumped dispatchSeq before this late terminal
|
|
3163
|
+
// arrived), don't let the status-bar label flip to the
|
|
3164
|
+
// stale outcome — the current turn is the live one.
|
|
3165
|
+
// The agent-tree row patch above is still safe because
|
|
3166
|
+
// it only updates the row keyed by taskId.
|
|
3167
|
+
...(this.isStaleTaskEvent(event.taskId)
|
|
3168
|
+
? {}
|
|
3169
|
+
: { lastCompletedOutcome: completedStatus }),
|
|
1647
3170
|
});
|
|
1648
|
-
//
|
|
3171
|
+
// : transition the FSM to `completed` when no other
|
|
1649
3172
|
// dispatch is still in flight. The check uses the agents list
|
|
1650
3173
|
// POST-patch so any sibling task in `queued` / `thinking` keeps
|
|
1651
3174
|
// the dispatch alive; the FSM only goes terminal when the last
|
|
1652
3175
|
// agent ships.
|
|
1653
3176
|
this.advanceFsmOnDispatchEnd('completed', 'agent_completed', event.taskId);
|
|
3177
|
+
// cost-meter sprint — flush the per-turn delta when the
|
|
3178
|
+
// LAST agent settles. Decoupled from the FSM gate so a test
|
|
3179
|
+
// fixture (or a single-agent dispatch that never reached
|
|
3180
|
+
// `awaiting_response` — happens on instant SSE replay) still
|
|
3181
|
+
// gets the row written into recentTurns + lastTurnDelta.
|
|
3182
|
+
this.maybeFlushTurnOnAgentSettle(event.taskId);
|
|
1654
3183
|
if (target) {
|
|
1655
3184
|
// If the persona actually produced a reply via incremental
|
|
1656
3185
|
// agent.step events, render that reply in the transcript so
|
|
@@ -1663,16 +3192,16 @@ export class ReplSession {
|
|
|
1663
3192
|
if (finalDetail
|
|
1664
3193
|
&& finalDetail !== 'queued for dispatch'
|
|
1665
3194
|
&& finalDetail.trim().length > 4) {
|
|
1666
|
-
//
|
|
3195
|
+
// : ship the WHOLE body as one transcript row when the
|
|
1667
3196
|
// reply contains ANY Markdown structure (code fence, bullet
|
|
1668
3197
|
// list, numbered list, headings). The conversation pane
|
|
1669
3198
|
// routes it through Markdown renderer в one pass, preserving
|
|
1670
3199
|
// grouped bullets + heading hierarchy. Plain prose still
|
|
1671
3200
|
// splits per line so word-wrap stays correct.
|
|
1672
3201
|
//
|
|
1673
|
-
// Claude triple-review P1 (PR
|
|
3202
|
+
// Claude triple-review P1 (PR): the prior `includes('```')`
|
|
1674
3203
|
// gate only caught fences - multi-line bullets fragmented
|
|
1675
|
-
// per row showed as `▸
|
|
3204
|
+
// per row showed as `▸ Pugi • read PUGI.md / ▸ Pugi • patched
|
|
1676
3205
|
// bug / ...` instead of a single grouped bullet block.
|
|
1677
3206
|
if (looksLikeMarkdown(finalDetail)) {
|
|
1678
3207
|
this.appendPersonaLine(target.personaSlug, finalDetail);
|
|
@@ -1687,7 +3216,7 @@ export class ReplSession {
|
|
|
1687
3216
|
}
|
|
1688
3217
|
}
|
|
1689
3218
|
else {
|
|
1690
|
-
//
|
|
3219
|
+
// CEO dogfood: drop the literal
|
|
1691
3220
|
// "shipped." fallback row. If we have no cached detail to
|
|
1692
3221
|
// surface, stay silent. The agent tree pane already shows
|
|
1693
3222
|
// the green check + duration.
|
|
@@ -1711,11 +3240,15 @@ export class ReplSession {
|
|
|
1711
3240
|
if (target) {
|
|
1712
3241
|
this.appendPersonaLine(target.personaSlug, `blocked: ${event.detail}`);
|
|
1713
3242
|
}
|
|
1714
|
-
//
|
|
3243
|
+
// : `blocked` is a graceful refusal, not a crash — treat it
|
|
1715
3244
|
// as a `completed` outcome from the FSM's perspective so the
|
|
1716
3245
|
// operator sees the bottom-bar settle back to `idle` after the
|
|
1717
3246
|
// last block clears.
|
|
1718
3247
|
this.advanceFsmOnDispatchEnd('completed', 'agent_blocked', event.taskId);
|
|
3248
|
+
// cost-meter sprint — flush the per-turn delta (blocked
|
|
3249
|
+
// still counts as a billable turn — the operator paid for the
|
|
3250
|
+
// tokens that landed before the refusal).
|
|
3251
|
+
this.maybeFlushTurnOnAgentSettle(event.taskId);
|
|
1719
3252
|
return;
|
|
1720
3253
|
}
|
|
1721
3254
|
case 'agent.failed': {
|
|
@@ -1734,17 +3267,21 @@ export class ReplSession {
|
|
|
1734
3267
|
if (target) {
|
|
1735
3268
|
this.appendPersonaLine(target.personaSlug, `failed: ${event.error}`);
|
|
1736
3269
|
}
|
|
1737
|
-
//
|
|
3270
|
+
// : terminal `failed` transition when no sibling task
|
|
1738
3271
|
// remains. Same defer-until-last-agent semantics as
|
|
1739
3272
|
// `completed` so the bottom-bar surface tracks the dispatch
|
|
1740
3273
|
// collectively.
|
|
1741
3274
|
this.advanceFsmOnDispatchEnd('failed', 'agent_failed', event.taskId);
|
|
3275
|
+
// cost-meter sprint — flush the per-turn delta when the
|
|
3276
|
+
// dispatch fails (the operator still paid for whatever tokens
|
|
3277
|
+
// landed before the failure).
|
|
3278
|
+
this.maybeFlushTurnOnAgentSettle(event.taskId);
|
|
1742
3279
|
return;
|
|
1743
3280
|
}
|
|
1744
3281
|
}
|
|
1745
3282
|
}
|
|
1746
3283
|
/**
|
|
1747
|
-
*
|
|
3284
|
+
* helper: advance the FSM to `tool_running` when a tool call
|
|
1748
3285
|
* lands mid-dispatch. Guarded against terminal / aborting states so
|
|
1749
3286
|
* a late tool event after `cancel()` does not throw on an illegal
|
|
1750
3287
|
* transition. The `tool` label drives the bottom-bar's
|
|
@@ -1768,7 +3305,7 @@ export class ReplSession {
|
|
|
1768
3305
|
this.patch({ dispatchToolLabel: `tool: ${tool}` });
|
|
1769
3306
|
}
|
|
1770
3307
|
/**
|
|
1771
|
-
*
|
|
3308
|
+
* helper: advance the FSM toward a terminal outcome when the
|
|
1772
3309
|
* LAST in-flight agent's lifecycle ends. The dispatch is "still
|
|
1773
3310
|
* running" when any other agent in the tree is in `queued` /
|
|
1774
3311
|
* `thinking`; the FSM only goes terminal when the last one settles.
|
|
@@ -1777,13 +3314,25 @@ export class ReplSession {
|
|
|
1777
3314
|
* after a manual `cancel()` finds the FSM already in `aborted` and
|
|
1778
3315
|
* is silently dropped.
|
|
1779
3316
|
*/
|
|
3317
|
+
/**
|
|
3318
|
+
* — shared stale-task check used by both the FSM advance
|
|
3319
|
+
* gate AND the status-bar `lastCompletedOutcome` mirror. Lifts the
|
|
3320
|
+
* R2 dispatchSeq compare out of `advanceFsmOnDispatchEnd` so other
|
|
3321
|
+
* agent.completed-handler side-effects (status-bar label, future
|
|
3322
|
+
* metric counters) can apply the same guard without duplicating it.
|
|
3323
|
+
* Returns true iff the task's stamped dispatchSeq is older than the
|
|
3324
|
+
* current dispatchSeq — i.e. a newer dispatchBrief() superseded it
|
|
3325
|
+
* and the late terminal event must not corrupt live-turn state.
|
|
3326
|
+
*/
|
|
3327
|
+
isStaleTaskEvent(taskId) {
|
|
3328
|
+
const taskSeq = this.taskDispatchSeq.get(taskId);
|
|
3329
|
+
return taskSeq !== undefined && taskSeq < this.dispatchSeq;
|
|
3330
|
+
}
|
|
1780
3331
|
advanceFsmOnDispatchEnd(outcome, reason, taskId) {
|
|
1781
|
-
// R2 P1 fix (Codex triple-review
|
|
3332
|
+
// R2 P1 fix (Codex triple-review): a terminal event
|
|
1782
3333
|
// for a SUPERSEDED dispatch must NOT advance the live FSM or null
|
|
1783
|
-
// the live token.
|
|
1784
|
-
//
|
|
1785
|
-
// the event belongs to a prior dispatch that was replaced by a
|
|
1786
|
-
// newer `dispatchBrief()`. Silently drop the FSM advance.
|
|
3334
|
+
// the live token. Delegates to isStaleTaskEvent so the agent.completed
|
|
3335
|
+
// status-bar mirror in the handler above uses the same gate.
|
|
1787
3336
|
if (taskId !== undefined) {
|
|
1788
3337
|
const taskSeq = this.taskDispatchSeq.get(taskId);
|
|
1789
3338
|
if (taskSeq !== undefined && taskSeq < this.dispatchSeq) {
|
|
@@ -1815,6 +3364,63 @@ export class ReplSession {
|
|
|
1815
3364
|
this.currentDispatchToken = null;
|
|
1816
3365
|
this.patch({ briefStartedAtEpochMs: undefined });
|
|
1817
3366
|
}
|
|
3367
|
+
/**
|
|
3368
|
+
* cost-meter sprint — gate the per-turn flush on "this was the
|
|
3369
|
+
* LAST in-flight agent". Mirrors the `stillActive` guard inside
|
|
3370
|
+
* `advanceFsmOnDispatchEnd` so a multi-agent dispatch only emits a
|
|
3371
|
+
* single recentTurns row + a single lastTurnDelta flash.
|
|
3372
|
+
*
|
|
3373
|
+
* Idempotent: if no tokens have been billed this turn, the inner
|
|
3374
|
+
* `flushTurnAccumulator` short-circuits without pushing an empty row.
|
|
3375
|
+
*/
|
|
3376
|
+
maybeFlushTurnOnAgentSettle(taskId) {
|
|
3377
|
+
const stillActive = this.state.agents.some((a) => a.status === 'queued' || a.status === 'thinking');
|
|
3378
|
+
if (stillActive)
|
|
3379
|
+
return;
|
|
3380
|
+
this.flushTurnAccumulator(taskId);
|
|
3381
|
+
}
|
|
3382
|
+
/**
|
|
3383
|
+
* cost-meter sprint — flush the per-turn accumulator into
|
|
3384
|
+
* `recentTurns` + `lastTurnDelta`. Idempotent + safe to call from any
|
|
3385
|
+
* terminal-state branch (`agent.completed` / `agent.blocked` /
|
|
3386
|
+
* `agent.failed`). When no tokens have been billed this turn
|
|
3387
|
+
* (instant abort, cap-warning gate), the helper short-circuits
|
|
3388
|
+
* without pushing an empty row.
|
|
3389
|
+
*/
|
|
3390
|
+
flushTurnAccumulator(taskId) {
|
|
3391
|
+
const turnTokensIn = this.state.turnTokensIn;
|
|
3392
|
+
const turnTokensOut = this.state.turnTokensOut;
|
|
3393
|
+
const turnCostUsd = this.state.turnCostUsd;
|
|
3394
|
+
if (turnTokensIn === 0 && turnTokensOut === 0) {
|
|
3395
|
+
// Idempotent zero-flush — never push an empty row into recentTurns.
|
|
3396
|
+
return;
|
|
3397
|
+
}
|
|
3398
|
+
const turnId = taskId !== undefined ? taskId : `turn-${this.dispatchSeq}-${this.now()}`;
|
|
3399
|
+
const newTurn = {
|
|
3400
|
+
id: turnId,
|
|
3401
|
+
tokensIn: turnTokensIn,
|
|
3402
|
+
tokensOut: turnTokensOut,
|
|
3403
|
+
costUsd: turnCostUsd,
|
|
3404
|
+
completedAt: new Date(this.now()).toISOString(),
|
|
3405
|
+
};
|
|
3406
|
+
// Keep the buffer capped at 5 entries (oldest first). The push
|
|
3407
|
+
// order matches the surface contract: `/cost` paginates oldest →
|
|
3408
|
+
// newest so the operator scans top-down chronologically.
|
|
3409
|
+
const recent = [...this.state.recentTurns, newTurn];
|
|
3410
|
+
const trimmed = recent.length > 5 ? recent.slice(-5) : recent;
|
|
3411
|
+
this.patch({
|
|
3412
|
+
recentTurns: trimmed,
|
|
3413
|
+
lastTurnDelta: {
|
|
3414
|
+
tokensIn: turnTokensIn,
|
|
3415
|
+
tokensOut: turnTokensOut,
|
|
3416
|
+
costUsd: turnCostUsd,
|
|
3417
|
+
completedAtEpochMs: this.now(),
|
|
3418
|
+
},
|
|
3419
|
+
turnTokensIn: 0,
|
|
3420
|
+
turnTokensOut: 0,
|
|
3421
|
+
turnCostUsd: 0,
|
|
3422
|
+
});
|
|
3423
|
+
}
|
|
1818
3424
|
/* ------------- transcript helpers -------------- */
|
|
1819
3425
|
/**
|
|
1820
3426
|
* Look up the persona slug for a running task. Used by the tool call
|
|
@@ -1827,6 +3433,73 @@ export class ReplSession {
|
|
|
1827
3433
|
const agent = this.state.agents.find((a) => a.taskId === taskId);
|
|
1828
3434
|
return agent?.personaSlug ?? 'unknown';
|
|
1829
3435
|
}
|
|
3436
|
+
/**
|
|
3437
|
+
* small-CC-parity batch : public ingest path for
|
|
3438
|
+
* a backend-driven `tool.call.delta` event. Appends the delta tail
|
|
3439
|
+
* onto the row's `streamingDelta` (capped at
|
|
3440
|
+
* `STREAMING_DELTA_MAX_CHARS` so the row stays single-line) when the
|
|
3441
|
+
* id matches a `running` row. No-op when the id is unknown OR when
|
|
3442
|
+
* the row already transitioned to a terminal status — late deltas
|
|
3443
|
+
* from a completed call must not overwrite the final detail.
|
|
3444
|
+
*
|
|
3445
|
+
* The renderer in `tool-stream-pane.tsx` reads `streamingDelta` to
|
|
3446
|
+
* paint the inline preview after the canonical args. This method is
|
|
3447
|
+
* the seam the future admin-api SSE consumer hooks into; until then
|
|
3448
|
+
* the spec drives it directly so the delta-append branch is locked
|
|
3449
|
+
* down behaviourally.
|
|
3450
|
+
*/
|
|
3451
|
+
appendToolCallDelta(id, deltaChunk) {
|
|
3452
|
+
if (!id || !deltaChunk)
|
|
3453
|
+
return;
|
|
3454
|
+
const idx = this.state.toolCalls.findIndex((c) => c.id === id);
|
|
3455
|
+
if (idx < 0)
|
|
3456
|
+
return;
|
|
3457
|
+
const existing = this.state.toolCalls[idx];
|
|
3458
|
+
if (existing.status !== 'running')
|
|
3459
|
+
return;
|
|
3460
|
+
const current = existing.streamingDelta ?? '';
|
|
3461
|
+
let combined = current + deltaChunk;
|
|
3462
|
+
if (combined.length > STREAMING_DELTA_MAX_CHARS) {
|
|
3463
|
+
// Keep the TAIL — the operator wants the freshest bytes (the
|
|
3464
|
+
// line being written right now), not the stale head. The leading
|
|
3465
|
+
// ellipsis signals truncation.
|
|
3466
|
+
combined = `…${combined.slice(combined.length - STREAMING_DELTA_MAX_CHARS + 1)}`;
|
|
3467
|
+
}
|
|
3468
|
+
const next = this.state.toolCalls.slice();
|
|
3469
|
+
next[idx] = { ...existing, streamingDelta: combined };
|
|
3470
|
+
this.patch({ toolCalls: next });
|
|
3471
|
+
}
|
|
3472
|
+
/**
|
|
3473
|
+
* small-CC-parity batch : public ingest path for
|
|
3474
|
+
* the terminal `tool.call.end` event. Flips the row to `ok` / `error`
|
|
3475
|
+
* with the resolved duration + optional result preview. Cleans up the
|
|
3476
|
+
* transient `streamingDelta` so the completed row renders cleanly
|
|
3477
|
+
* without the live tail. No-op when the id is unknown.
|
|
3478
|
+
*/
|
|
3479
|
+
endToolCall(input) {
|
|
3480
|
+
if (!input.id)
|
|
3481
|
+
return;
|
|
3482
|
+
const idx = this.state.toolCalls.findIndex((c) => c.id === input.id);
|
|
3483
|
+
if (idx < 0)
|
|
3484
|
+
return;
|
|
3485
|
+
const existing = this.state.toolCalls[idx];
|
|
3486
|
+
const endedAt = input.endedAtEpochMs ?? Date.now();
|
|
3487
|
+
const durationMs = Math.max(0, endedAt - existing.startedAtEpochMs);
|
|
3488
|
+
const preview = input.resultPreview
|
|
3489
|
+
? truncatePreview(input.resultPreview, RESULT_PREVIEW_MAX_CHARS)
|
|
3490
|
+
: undefined;
|
|
3491
|
+
const next = this.state.toolCalls.slice();
|
|
3492
|
+
next[idx] = {
|
|
3493
|
+
...existing,
|
|
3494
|
+
status: input.status,
|
|
3495
|
+
detail: input.detail ?? existing.detail,
|
|
3496
|
+
resultLines: input.resultLines ?? existing.resultLines,
|
|
3497
|
+
durationMs,
|
|
3498
|
+
resultPreview: preview,
|
|
3499
|
+
streamingDelta: undefined,
|
|
3500
|
+
};
|
|
3501
|
+
this.patch({ toolCalls: next });
|
|
3502
|
+
}
|
|
1830
3503
|
/**
|
|
1831
3504
|
* Fold a tool call entry into the rolling list. If the entry id
|
|
1832
3505
|
* already exists, replace it in-place (so a synthesised `running` →
|
|
@@ -1856,10 +3529,10 @@ export class ReplSession {
|
|
|
1856
3529
|
this.appendRow({ source: 'system', text });
|
|
1857
3530
|
}
|
|
1858
3531
|
appendPersonaLine(personaSlug, text) {
|
|
1859
|
-
//
|
|
3532
|
+
// wave 5: dedup the persona display-name prefix. The
|
|
1860
3533
|
// conversation pane already renders `▸ <DisplayName> <text>` from
|
|
1861
3534
|
// the slug → name map; when the model's own reply begins with
|
|
1862
|
-
// the same display name (CEO
|
|
3535
|
+
// the same display name (CEO screenshot: "Pugi Pugi,
|
|
1863
3536
|
// координатор Pugi"), the operator sees the name twice. Strip
|
|
1864
3537
|
// the leading display-name token (with optional trailing comma /
|
|
1865
3538
|
// colon / whitespace) so the prefix the pane adds is the only one
|
|
@@ -1871,13 +3544,14 @@ export class ReplSession {
|
|
|
1871
3544
|
this.appendRow({ source: 'persona', text: stripped, personaSlug });
|
|
1872
3545
|
}
|
|
1873
3546
|
appendRow(input) {
|
|
1874
|
-
if (input.text.length === 0)
|
|
3547
|
+
if (input.text.length === 0 && input.source !== 'compact-boundary')
|
|
1875
3548
|
return;
|
|
1876
3549
|
const row = {
|
|
1877
3550
|
id: randomUUID(),
|
|
1878
3551
|
source: input.source,
|
|
1879
3552
|
text: input.text,
|
|
1880
3553
|
personaSlug: input.personaSlug,
|
|
3554
|
+
compaction: input.compaction,
|
|
1881
3555
|
timestampEpochMs: this.now(),
|
|
1882
3556
|
};
|
|
1883
3557
|
const next = this.state.transcript.concat(row).slice(-MAX_TRANSCRIPT_ROWS);
|
|
@@ -1886,10 +3560,66 @@ export class ReplSession {
|
|
|
1886
3560
|
// Persistence is fail-safe: a single error becomes one system
|
|
1887
3561
|
// line, subsequent errors are silent so a stuck disk does not
|
|
1888
3562
|
// flood the operator. The mapping from row.source -> store kind:
|
|
1889
|
-
//
|
|
1890
|
-
//
|
|
1891
|
-
//
|
|
3563
|
+
// operator -> 'user' (drives turn_count + title)
|
|
3564
|
+
// persona -> 'persona'
|
|
3565
|
+
// system -> 'system'
|
|
1892
3566
|
this.persistRow(row);
|
|
3567
|
+
// evaluate the auto-compact gate after
|
|
3568
|
+
// every appendRow that produces a transcript turn. Wrapped in a
|
|
3569
|
+
// setImmediate so the gate never blocks the input-handling fast
|
|
3570
|
+
// path; if the threshold is tripped, the auto-trigger dispatches
|
|
3571
|
+
// `/compact` in the background while the operator keeps typing.
|
|
3572
|
+
if (row.source === 'operator' || row.source === 'persona') {
|
|
3573
|
+
this.maybeAutoCompact();
|
|
3574
|
+
}
|
|
3575
|
+
}
|
|
3576
|
+
/**
|
|
3577
|
+
* Auto-compact gate. Cheap: builds an in-memory token estimate from
|
|
3578
|
+
* the current transcript and consults `evaluateAutoCompact`. When the
|
|
3579
|
+
* gate fires AND a compaction is not already in flight, we dispatch
|
|
3580
|
+
* `/compact` with `trigger='auto'`. The fire-and-forget shape means
|
|
3581
|
+
* the input box stays responsive while the background round-trip
|
|
3582
|
+
* runs.
|
|
3583
|
+
*
|
|
3584
|
+
* Hysteresis: `compactionInFlight` blocks re-entry. The gate is
|
|
3585
|
+
* cleared when the dispatch promise resolves regardless of outcome
|
|
3586
|
+
* so a transient transport failure does not permanently disable the
|
|
3587
|
+
* auto-trigger.
|
|
3588
|
+
*/
|
|
3589
|
+
compactionInFlight = false;
|
|
3590
|
+
maybeAutoCompact() {
|
|
3591
|
+
if (this.compactionInFlight)
|
|
3592
|
+
return;
|
|
3593
|
+
if (!this.store || !this.localSessionId)
|
|
3594
|
+
return;
|
|
3595
|
+
if (process.env['PUGI_AUTOCOMPACT_DISABLED'] === '1')
|
|
3596
|
+
return;
|
|
3597
|
+
// Token estimate from the in-memory transcript. The estimate is a
|
|
3598
|
+
// lower bound on actual context pressure (server-side system
|
|
3599
|
+
// prompts add overhead) but the 4-char/token heuristic plus the
|
|
3600
|
+
// 0.75 default threshold gives generous headroom.
|
|
3601
|
+
const texts = this.state.transcript.map((r) => r.text);
|
|
3602
|
+
const tokenCount = estimateTokensInMany(texts);
|
|
3603
|
+
// Conservative default: assume the smallest commonly-used window
|
|
3604
|
+
// (32k tokens for deepseek-v3.1). Resolving the live model slug
|
|
3605
|
+
// through DispatchFSM + admin-api adds latency on a hot path; the
|
|
3606
|
+
// 0.75 threshold + smallest-window assumption errs toward
|
|
3607
|
+
// EARLY trigger which is the safe direction.
|
|
3608
|
+
const verdict = evaluateAutoCompact({
|
|
3609
|
+
tokenCount,
|
|
3610
|
+
windowSize: 32_000,
|
|
3611
|
+
});
|
|
3612
|
+
if (verdict.kind !== 'fire')
|
|
3613
|
+
return;
|
|
3614
|
+
this.compactionInFlight = true;
|
|
3615
|
+
void (async () => {
|
|
3616
|
+
try {
|
|
3617
|
+
await this.dispatchCompact('auto');
|
|
3618
|
+
}
|
|
3619
|
+
finally {
|
|
3620
|
+
this.compactionInFlight = false;
|
|
3621
|
+
}
|
|
3622
|
+
})();
|
|
1893
3623
|
}
|
|
1894
3624
|
/**
|
|
1895
3625
|
* Best-effort write of one transcript row into the local
|
|
@@ -1900,6 +3630,15 @@ export class ReplSession {
|
|
|
1900
3630
|
persistRow(row) {
|
|
1901
3631
|
if (!this.store)
|
|
1902
3632
|
return;
|
|
3633
|
+
// L29 : `compact-boundary` transcript rows are echoes of
|
|
3634
|
+
// the JSONL `compaction` event the compact runner already appended
|
|
3635
|
+
// via `appendCompactBoundary`. Persisting them here would double-
|
|
3636
|
+
// write the marker (and worse, with a stripped payload that lacks
|
|
3637
|
+
// `summary` / `coversUntilOffset`) — `isCompactBoundary` would
|
|
3638
|
+
// reject the duplicate but `applyCompactMask` would still index off
|
|
3639
|
+
// the wrong offset. Skip the write.
|
|
3640
|
+
if (row.source === 'compact-boundary')
|
|
3641
|
+
return;
|
|
1903
3642
|
const kind = row.source === 'operator' ? 'user'
|
|
1904
3643
|
: row.source === 'persona' ? 'persona'
|
|
1905
3644
|
: 'system';
|
|
@@ -1927,7 +3666,7 @@ export class ReplSession {
|
|
|
1927
3666
|
});
|
|
1928
3667
|
}
|
|
1929
3668
|
/**
|
|
1930
|
-
* Restore a transcript from a stored event log -
|
|
3669
|
+
* Restore a transcript from a stored event log - . Called by
|
|
1931
3670
|
* the CLI bootstrap when the operator runs `pugi resume <id>` or
|
|
1932
3671
|
* picks an entry from the `/resume` picker. Replays each event into
|
|
1933
3672
|
* the local transcript WITHOUT writing back to the store so the
|
|
@@ -1940,12 +3679,30 @@ export class ReplSession {
|
|
|
1940
3679
|
* write the restored events.
|
|
1941
3680
|
*/
|
|
1942
3681
|
restoreTranscript(events) {
|
|
3682
|
+
// apply compact-boundary masking BEFORE the
|
|
3683
|
+
// row conversion. Events strictly before the latest marker are
|
|
3684
|
+
// condensed into the boundary's `keptTailTurns + marker` slice so
|
|
3685
|
+
// the post-resume transcript starts at the most-recent context
|
|
3686
|
+
// floor rather than re-playing the full pre-compaction history.
|
|
3687
|
+
//
|
|
3688
|
+
// then apply rewind-marker masking. Any
|
|
3689
|
+
// event inside an active rewind range is stripped from the
|
|
3690
|
+
// visible transcript; the on-disk events stay durable so a
|
|
3691
|
+
// follow-up `pugi sessions undo-rewind` can restore them.
|
|
3692
|
+
const masked = applyRewindMask(applyCompactMask(events));
|
|
1943
3693
|
const rows = [];
|
|
1944
|
-
for (const event of
|
|
3694
|
+
for (const event of masked) {
|
|
1945
3695
|
const row = eventToTranscriptRow(event);
|
|
1946
3696
|
if (row)
|
|
1947
3697
|
rows.push(row);
|
|
1948
3698
|
}
|
|
3699
|
+
// L29 : tag each compact-boundary row with the count of
|
|
3700
|
+
// operator + persona turns that landed AFTER it in the replay
|
|
3701
|
+
// window. The banner reads `turnsAgo` to render the "N turns ago"
|
|
3702
|
+
// suffix so a long session that resumes across multiple compactions
|
|
3703
|
+
// stays self-orienting. System rows + sibling boundaries are NOT
|
|
3704
|
+
// counted — they are chrome, not operator-visible turns.
|
|
3705
|
+
annotateBoundaryTurnsAgo(rows);
|
|
1949
3706
|
// Cap at MAX_TRANSCRIPT_ROWS - the same cap appendRow uses so the
|
|
1950
3707
|
// window math stays consistent post-restore.
|
|
1951
3708
|
const capped = rows.slice(-MAX_TRANSCRIPT_ROWS);
|
|
@@ -1959,7 +3716,7 @@ export class ReplSession {
|
|
|
1959
3716
|
getLocalSessionId() {
|
|
1960
3717
|
return this.localSessionId;
|
|
1961
3718
|
}
|
|
1962
|
-
/* -------------
|
|
3719
|
+
/* ------------- buffered tag detection -------------- */
|
|
1963
3720
|
/**
|
|
1964
3721
|
* Scan the running `agent.step.detail` buffer for `<pugi-ask>` /
|
|
1965
3722
|
* `<pugi-plan-review>` envelopes. If a complete envelope is found,
|
|
@@ -2064,7 +3821,7 @@ export class ReplSession {
|
|
|
2064
3821
|
}
|
|
2065
3822
|
}
|
|
2066
3823
|
/* ------------------------------------------------------------------ */
|
|
2067
|
-
/* Helpers
|
|
3824
|
+
/* Helpers */
|
|
2068
3825
|
/* ------------------------------------------------------------------ */
|
|
2069
3826
|
/**
|
|
2070
3827
|
* Resolve role → display name without throwing on unknown roles. The
|
|
@@ -2080,9 +3837,9 @@ export class ReplSession {
|
|
|
2080
3837
|
* tool stream rows, not transcript rows). The shape mirrors the
|
|
2081
3838
|
* `persistRow` mapping in reverse:
|
|
2082
3839
|
*
|
|
2083
|
-
*
|
|
2084
|
-
*
|
|
2085
|
-
*
|
|
3840
|
+
* 'user' -> operator (brief)
|
|
3841
|
+
* 'persona' -> persona (text + personaSlug)
|
|
3842
|
+
* 'system' -> system (text)
|
|
2086
3843
|
*
|
|
2087
3844
|
* Exported indirectly via `restoreTranscript`.
|
|
2088
3845
|
*/
|
|
@@ -2129,13 +3886,76 @@ function eventToTranscriptRow(event) {
|
|
|
2129
3886
|
timestampEpochMs: event.t,
|
|
2130
3887
|
};
|
|
2131
3888
|
}
|
|
3889
|
+
if (event.kind === 'compaction') {
|
|
3890
|
+
// L8 + L29 : render the marker as a structured
|
|
3891
|
+
// `compact-boundary` row so the renderer can route it to the
|
|
3892
|
+
// dedicated <CompactBanner /> Ink component. The full summary text
|
|
3893
|
+
// is intentionally NOT inlined here (a 2k-token summary in the
|
|
3894
|
+
// transcript would defeat the purpose of compacting); the operator
|
|
3895
|
+
// sees the "context compacted" banner and can run `/context` to
|
|
3896
|
+
// inspect the marker payload when they want the details. The plain
|
|
3897
|
+
// text fallback stays in place for non-Ink consumers (snapshot
|
|
3898
|
+
// tests, future JSON exports).
|
|
3899
|
+
const compactionPayload = (event.payload ?? null);
|
|
3900
|
+
const trigger = compactionPayload?.trigger === 'auto' ? 'auto' : 'manual';
|
|
3901
|
+
const turns = typeof compactionPayload?.summaryTurnsBefore === 'number'
|
|
3902
|
+
? compactionPayload.summaryTurnsBefore
|
|
3903
|
+
: 0;
|
|
3904
|
+
const tokens = typeof compactionPayload?.summaryTokenCount === 'number'
|
|
3905
|
+
? compactionPayload.summaryTokenCount
|
|
3906
|
+
: undefined;
|
|
3907
|
+
return {
|
|
3908
|
+
id: randomUUID(),
|
|
3909
|
+
source: 'compact-boundary',
|
|
3910
|
+
text: `─── context compacted (${turns} turns → 1 summary, ${trigger}) ───`,
|
|
3911
|
+
compaction: {
|
|
3912
|
+
turnsBefore: turns,
|
|
3913
|
+
trigger,
|
|
3914
|
+
summaryTokenCount: tokens,
|
|
3915
|
+
},
|
|
3916
|
+
timestampEpochMs: event.t,
|
|
3917
|
+
};
|
|
3918
|
+
}
|
|
2132
3919
|
return null;
|
|
2133
3920
|
}
|
|
3921
|
+
/**
|
|
3922
|
+
* L29 : walk a chronological transcript window and stamp
|
|
3923
|
+
* every `compact-boundary` row's `compaction.turnsAgo` with the count of
|
|
3924
|
+
* operator + persona rows that land AFTER it. The annotation runs in
|
|
3925
|
+
* place on the array — boundaries earlier in time get larger `turnsAgo`
|
|
3926
|
+
* values, the boundary at the head of the window gets zero. System rows
|
|
3927
|
+
* and sibling boundaries are excluded from the count (they are chrome,
|
|
3928
|
+
* not operator-visible turns).
|
|
3929
|
+
*
|
|
3930
|
+
* Exported so a future spec can lock the contract and so the in-REPL
|
|
3931
|
+
* `/compact` path can reuse the same counter on live appends if it ever
|
|
3932
|
+
* needs to. Pure function (mutates only the input slice).
|
|
3933
|
+
*/
|
|
3934
|
+
export function annotateBoundaryTurnsAgo(rows) {
|
|
3935
|
+
let trailingTurns = 0;
|
|
3936
|
+
for (let i = rows.length - 1; i >= 0; i -= 1) {
|
|
3937
|
+
const row = rows[i];
|
|
3938
|
+
if (row.source === 'operator' || row.source === 'persona') {
|
|
3939
|
+
trailingTurns += 1;
|
|
3940
|
+
continue;
|
|
3941
|
+
}
|
|
3942
|
+
if (row.source === 'compact-boundary') {
|
|
3943
|
+
// Re-assign with the live `turnsAgo`. Carry forward the existing
|
|
3944
|
+
// structured payload so we never lose the trigger / token-count
|
|
3945
|
+
// data the renderer needs.
|
|
3946
|
+
const compaction = row.compaction ?? { turnsBefore: 0, trigger: 'manual' };
|
|
3947
|
+
rows[i] = {
|
|
3948
|
+
...row,
|
|
3949
|
+
compaction: { ...compaction, turnsAgo: trailingTurns },
|
|
3950
|
+
};
|
|
3951
|
+
}
|
|
3952
|
+
}
|
|
3953
|
+
}
|
|
2134
3954
|
/**
|
|
2135
3955
|
* Heuristic: does this text contain Markdown structures that benefit
|
|
2136
3956
|
* from atomic grouping? Code fences, bullet lists, numbered lists,
|
|
2137
3957
|
* headings - anything where per-line splitting would fragment visual
|
|
2138
|
-
* grouping (Claude triple-review P1 PR
|
|
3958
|
+
* grouping (Claude triple-review P1 PR).
|
|
2139
3959
|
*/
|
|
2140
3960
|
function looksLikeMarkdown(text) {
|
|
2141
3961
|
if (text.includes('```'))
|
|
@@ -2168,10 +3988,10 @@ function safePersonaName(role) {
|
|
|
2168
3988
|
* Render a millisecond delta as a compact human-readable age. Used by
|
|
2169
3989
|
* `/context` to surface the oldest working-set entry's age:
|
|
2170
3990
|
*
|
|
2171
|
-
*
|
|
2172
|
-
*
|
|
2173
|
-
*
|
|
2174
|
-
*
|
|
3991
|
+
* < 60s -> `45s`
|
|
3992
|
+
* < 1h -> `4m`
|
|
3993
|
+
* < 24h -> `2h`
|
|
3994
|
+
* >= 24h -> `3d`
|
|
2175
3995
|
*
|
|
2176
3996
|
* Negative deltas (clock skew) clamp to `0s`.
|
|
2177
3997
|
*/
|
|
@@ -2197,23 +4017,103 @@ function formatAgeSeconds(deltaMs) {
|
|
|
2197
4017
|
export function knownRoles() {
|
|
2198
4018
|
return listRoles();
|
|
2199
4019
|
}
|
|
4020
|
+
/**
|
|
4021
|
+
* cost-meter sprint — render a session-elapsed ms delta as the
|
|
4022
|
+
* status-row's compact `XmYs` / `XhYm` shape. Distinct from
|
|
4023
|
+
* `formatAgeSeconds` above because `/cost` needs minute-granularity
|
|
4024
|
+
* uniformly (operator wants `2m44s`, not `2m`). Pure / branch-cheap;
|
|
4025
|
+
* the TUI status row + `/cost` both call this on every render.
|
|
4026
|
+
*/
|
|
4027
|
+
function formatElapsedShort(elapsedMs) {
|
|
4028
|
+
if (!Number.isFinite(elapsedMs) || elapsedMs <= 0)
|
|
4029
|
+
return '0s';
|
|
4030
|
+
const totalSec = Math.floor(elapsedMs / 1000);
|
|
4031
|
+
if (totalSec < 60)
|
|
4032
|
+
return `${totalSec}s`;
|
|
4033
|
+
const min = Math.floor(totalSec / 60);
|
|
4034
|
+
const sec = totalSec % 60;
|
|
4035
|
+
if (min < 60)
|
|
4036
|
+
return `${min}m${sec.toString().padStart(2, '0')}s`;
|
|
4037
|
+
const hr = Math.floor(min / 60);
|
|
4038
|
+
const restMin = min % 60;
|
|
4039
|
+
return `${hr}h${restMin.toString().padStart(2, '0')}m`;
|
|
4040
|
+
}
|
|
4041
|
+
/**
|
|
4042
|
+
* cost-meter sprint — public-facing tier labels for the `/quota`
|
|
4043
|
+
* slash. Mirrors `TIER_PRICE_LABEL` in `runtime/cli.ts` (kept in sync
|
|
4044
|
+
* via `pricing.spec.ts` gate). Falls through to the raw slug when an
|
|
4045
|
+
* unknown tier ships from a forward-compat admin-api build.
|
|
4046
|
+
*/
|
|
4047
|
+
const QUOTA_TIER_LABELS = Object.freeze({
|
|
4048
|
+
free: 'Free',
|
|
4049
|
+
founder: 'Founder ($20/mo)',
|
|
4050
|
+
builder: 'Builder ($99/mo)',
|
|
4051
|
+
team: 'Team ($199/mo)',
|
|
4052
|
+
});
|
|
4053
|
+
/**
|
|
4054
|
+
* cost-meter sprint — render the time-until-reset window for the
|
|
4055
|
+
* `/quota` plan line. `resetAt` is the ISO string admin-api returns;
|
|
4056
|
+
* `now` is the current epoch ms (injected for test determinism). Falls
|
|
4057
|
+
* back to the raw ISO string when parsing fails so the operator never
|
|
4058
|
+
* sees an empty hint.
|
|
4059
|
+
*/
|
|
4060
|
+
function formatResetWindow(resetAtIso, nowEpochMs) {
|
|
4061
|
+
const resetMs = Date.parse(resetAtIso);
|
|
4062
|
+
if (!Number.isFinite(resetMs))
|
|
4063
|
+
return resetAtIso;
|
|
4064
|
+
const deltaMs = resetMs - nowEpochMs;
|
|
4065
|
+
if (deltaMs <= 0)
|
|
4066
|
+
return 'now';
|
|
4067
|
+
const days = Math.floor(deltaMs / (24 * 60 * 60 * 1000));
|
|
4068
|
+
if (days >= 2)
|
|
4069
|
+
return `in ${days}d`;
|
|
4070
|
+
const hours = Math.floor(deltaMs / (60 * 60 * 1000));
|
|
4071
|
+
if (hours >= 1)
|
|
4072
|
+
return `in ${hours}h`;
|
|
4073
|
+
const minutes = Math.max(1, Math.floor(deltaMs / (60 * 1000)));
|
|
4074
|
+
return `in ${minutes}m`;
|
|
4075
|
+
}
|
|
4076
|
+
/**
|
|
4077
|
+
* cleanup : wrap a `/quota` counter row in ANSI
|
|
4078
|
+
* color codes by utilisation percent. Thresholds match the upstream tool's
|
|
4079
|
+
* tier-meter convention so operators trained on that surface read the
|
|
4080
|
+
* same signal here:
|
|
4081
|
+
*
|
|
4082
|
+
* - 0..70% → green (32m) — comfortable headroom
|
|
4083
|
+
* - 70..90% → yellow (33m) — approaching cap, plan ahead
|
|
4084
|
+
* - 90..100% → red (31m) — burn rate alarm, throttle now
|
|
4085
|
+
*
|
|
4086
|
+
* The wrap is whole-row (not just the percent) so the eye registers
|
|
4087
|
+
* the level on the line, not just the trailing parenthesis. Tests
|
|
4088
|
+
* that match the inner row text via regex are unaffected because the
|
|
4089
|
+
* regex anchors live inside the wrapped substring; the ANSI codes
|
|
4090
|
+
* sit at the boundaries.
|
|
4091
|
+
*/
|
|
4092
|
+
export function colorizeQuotaRow(row, pct) {
|
|
4093
|
+
const RESET = '\x1b[0m';
|
|
4094
|
+
if (pct >= 90)
|
|
4095
|
+
return `\x1b[31m${row}${RESET}`;
|
|
4096
|
+
if (pct >= 70)
|
|
4097
|
+
return `\x1b[33m${row}${RESET}`;
|
|
4098
|
+
return `\x1b[32m${row}${RESET}`;
|
|
4099
|
+
}
|
|
2200
4100
|
/* ------------------------------------------------------------------ */
|
|
2201
|
-
/* Tool call synthesiser -
|
|
4101
|
+
/* Tool call synthesiser - */
|
|
2202
4102
|
/* ------------------------------------------------------------------ */
|
|
2203
4103
|
/**
|
|
2204
4104
|
* Match canonical tool invocation grammar in an `agent.step.detail`
|
|
2205
4105
|
* string and emit a synthesised `ToolCallEntry`. Returns null when no
|
|
2206
4106
|
* known tool pattern matches.
|
|
2207
4107
|
*
|
|
2208
|
-
* The grammar mirrors the way
|
|
4108
|
+
* The grammar mirrors the way the upstream tool, Codex CLI, and Gemini CLI
|
|
2209
4109
|
* display tool calls in their tool stream panes:
|
|
2210
4110
|
*
|
|
2211
|
-
*
|
|
2212
|
-
*
|
|
2213
|
-
*
|
|
2214
|
-
*
|
|
2215
|
-
*
|
|
2216
|
-
*
|
|
4111
|
+
* Read(path)
|
|
4112
|
+
* Edit(path[:lines])
|
|
4113
|
+
* Bash(command)
|
|
4114
|
+
* Grep("pattern" [in path])
|
|
4115
|
+
* Glob(pattern)
|
|
4116
|
+
* WebFetch(url)
|
|
2217
4117
|
*
|
|
2218
4118
|
* The matcher is case-insensitive on the tool name so a persona that
|
|
2219
4119
|
* spells the tool as `READ(...)` or `web_fetch(...)` still lands in
|
|
@@ -2228,9 +4128,9 @@ export function synthesiseToolCall(input) {
|
|
|
2228
4128
|
if (detail.length === 0)
|
|
2229
4129
|
return null;
|
|
2230
4130
|
// Pattern: ToolName(args) optionally suffixed with a result hint.
|
|
2231
|
-
// We allow the canonical
|
|
4131
|
+
// We allow the canonical the upstream tool casing AND the snake_case
|
|
2232
4132
|
// alias `web_fetch` so the synthesiser matches what personas write.
|
|
2233
|
-
const match = /^(Read|Edit|Bash|Grep|Glob|WebFetch|web_fetch)\s*\(\s*([^)]*)\s*\)\s*(.*)$/i
|
|
4133
|
+
const match = /^(Read|Write|Edit|Bash|Grep|Glob|WebFetch|web_fetch)\s*\(\s*([^)]*)\s*\)\s*(.*)$/i
|
|
2234
4134
|
.exec(detail);
|
|
2235
4135
|
if (!match)
|
|
2236
4136
|
return null;
|
|
@@ -2248,12 +4148,32 @@ export function synthesiseToolCall(input) {
|
|
|
2248
4148
|
startedAtEpochMs: input.now,
|
|
2249
4149
|
};
|
|
2250
4150
|
}
|
|
4151
|
+
/**
|
|
4152
|
+
* small-CC-parity batch : collapse a multi-line
|
|
4153
|
+
* result preview down to a single-line head capped at `max` chars. The
|
|
4154
|
+
* collapsed-result row on a completed tool call uses this so the
|
|
4155
|
+
* preview never expands the row vertically. Exported для the spec so
|
|
4156
|
+
* the truncation behaviour is locked down.
|
|
4157
|
+
*/
|
|
4158
|
+
export function truncatePreview(value, max) {
|
|
4159
|
+
if (!value)
|
|
4160
|
+
return '';
|
|
4161
|
+
// Strip CR/LF + tab so the preview stays single-line. Multiple
|
|
4162
|
+
// whitespace runs collapse to single space — operator wants signal,
|
|
4163
|
+
// not formatting noise.
|
|
4164
|
+
const single = value.replace(/[\r\n\t]+/g, ' ').replace(/\s{2,}/g, ' ').trim();
|
|
4165
|
+
if (single.length <= max)
|
|
4166
|
+
return single;
|
|
4167
|
+
return `${single.slice(0, Math.max(0, max - 1))}…`;
|
|
4168
|
+
}
|
|
2251
4169
|
function normaliseToolName(raw) {
|
|
2252
4170
|
const lower = raw.toLowerCase();
|
|
2253
4171
|
if (lower === 'webfetch' || lower === 'web_fetch')
|
|
2254
4172
|
return 'web_fetch';
|
|
2255
4173
|
if (lower === 'read')
|
|
2256
4174
|
return 'read';
|
|
4175
|
+
if (lower === 'write')
|
|
4176
|
+
return 'write';
|
|
2257
4177
|
if (lower === 'edit')
|
|
2258
4178
|
return 'edit';
|
|
2259
4179
|
if (lower === 'bash')
|
|
@@ -2284,12 +4204,12 @@ function parseStatusFromTail(tail) {
|
|
|
2284
4204
|
return { status: 'ok', detail: tail };
|
|
2285
4205
|
}
|
|
2286
4206
|
/* ------------------------------------------------------------------ */
|
|
2287
|
-
/*
|
|
2288
|
-
/*
|
|
2289
|
-
/* Mirrors `tui/ask-modal.tsx#encodeAskVerdict` so the session can
|
|
2290
|
-
/* synthesise the operator-side echo without dragging an Ink module
|
|
2291
|
-
/* into the test surface. The two encoders MUST agree byte-for-byte -
|
|
2292
|
-
/* a divergence would silently mis-prefix the persona's follow-up.
|
|
4207
|
+
/* office-hours encoders */
|
|
4208
|
+
/* */
|
|
4209
|
+
/* Mirrors `tui/ask-modal.tsx#encodeAskVerdict` so the session can */
|
|
4210
|
+
/* synthesise the operator-side echo without dragging an Ink module */
|
|
4211
|
+
/* into the test surface. The two encoders MUST agree byte-for-byte - */
|
|
4212
|
+
/* a divergence would silently mis-prefix the persona's follow-up. */
|
|
2293
4213
|
/* ------------------------------------------------------------------ */
|
|
2294
4214
|
function encodeAskVerdictLocal(verdict) {
|
|
2295
4215
|
if (verdict.cancelled)
|
|
@@ -2315,7 +4235,7 @@ function encodeAskVerdictLocal(verdict) {
|
|
|
2315
4235
|
* `[ASK-RESPONSE:other] [ASK-RESPONSE:vercel] my real answer` which
|
|
2316
4236
|
* a prefix-greedy persona could read as "operator chose vercel".
|
|
2317
4237
|
*
|
|
2318
|
-
* Claude triple-review P1 (PR
|
|
4238
|
+
* Claude triple-review P1 (PR).
|
|
2319
4239
|
*/
|
|
2320
4240
|
function sanitiseVerdictText(raw) {
|
|
2321
4241
|
let cleaned = raw;
|
|
@@ -2421,7 +4341,7 @@ export function synthesiseLocalAskTag(question) {
|
|
|
2421
4341
|
// Use the single-source signature helper so a persona-emitted ask
|
|
2422
4342
|
// with the same question + same option values does not collide with
|
|
2423
4343
|
// this synthesised one under a divergent algorithm. Claude
|
|
2424
|
-
// triple-review P1 (PR
|
|
4344
|
+
// triple-review P1 (PR).
|
|
2425
4345
|
const signature = signatureForAsk(trimmed, options);
|
|
2426
4346
|
return {
|
|
2427
4347
|
question: trimmed,
|
|
@@ -2438,20 +4358,20 @@ export function synthesiseLocalAskTag(question) {
|
|
|
2438
4358
|
* production callers go through `appendPersonaLine`.
|
|
2439
4359
|
*
|
|
2440
4360
|
* Examples (display name = "Pugi"):
|
|
2441
|
-
*
|
|
2442
|
-
*
|
|
2443
|
-
*
|
|
2444
|
-
*
|
|
4361
|
+
* "Pugi, координатор Pugi. Брифую..." -> "координатор Pugi. Брифую..."
|
|
4362
|
+
* "Pugi: вот результат" -> "вот результат"
|
|
4363
|
+
* "<workspace-context-abc>Pugi, привет" -> "привет"
|
|
4364
|
+
* "обычный ответ без префикса" -> "обычный ответ без префикса"
|
|
2445
4365
|
*
|
|
2446
4366
|
* The strip is conservative - we only remove the display name when it
|
|
2447
4367
|
* is followed by a separator (comma, colon, dash, space) so a sentence
|
|
2448
4368
|
* that legitimately contains the name mid-text ("спроси у Pugi") is
|
|
2449
|
-
* not mangled.
|
|
4369
|
+
* not mangled.
|
|
2450
4370
|
*/
|
|
2451
4371
|
export function stripPersonaPrefixEcho(personaSlug, text) {
|
|
2452
4372
|
let working = text.trimStart();
|
|
2453
4373
|
// Drop any leaked `<workspace-context-...>` / `</workspace-context-...>`
|
|
2454
|
-
// wrapper at the head. The
|
|
4374
|
+
// wrapper at the head. The Pugi prompt v1.1 sometimes echoes the
|
|
2455
4375
|
// scaffolding envelope back when the model is warm-starting the
|
|
2456
4376
|
// first turn; cosmetic noise the operator never needs to see.
|
|
2457
4377
|
// We strip both opening tag and any text up to (and including) the
|
|
@@ -2479,7 +4399,22 @@ export function stripPersonaPrefixEcho(personaSlug, text) {
|
|
|
2479
4399
|
// Escape regex specials in the display name even though THE_TEN
|
|
2480
4400
|
// names are alpha-only today (forward-defense).
|
|
2481
4401
|
const escaped = display.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
4402
|
+
// Match `<DisplayName>` (case-insensitive) followed by EITHER:
|
|
4403
|
+
// - an end-of-string, OR
|
|
4404
|
+
// - a separator (whitespace / comma / colon / dash / period+space).
|
|
4405
|
+
// The `i` flag is needed so a model writing "PUGI:" or "pugi," still
|
|
4406
|
+
// strips. After this match the post-fix `noSepUppercaseRe` handles
|
|
4407
|
+
// the "PugiПринял" / "PugiHello" no-separator emission pattern
|
|
4408
|
+
// (CEO red-alert) using a SEPARATE regex without the `i`
|
|
4409
|
+
// flag so the lookahead is case-strict (Pugineous must NOT strip).
|
|
2482
4410
|
const re = new RegExp(`^${escaped}(?:[\\s,:;\\-—–]+|$)`, 'i');
|
|
4411
|
+
// No-separator case-strict matcher. Display name in either of its
|
|
4412
|
+
// canonical casings ("Pugi" / "PUGI") immediately followed by an
|
|
4413
|
+
// uppercase Cyrillic or Latin letter. The strip is intentionally
|
|
4414
|
+
// narrower than the case-insensitive `re` above because a lowercase
|
|
4415
|
+
// continuation ("Pugineous") is a single word, not a display-name
|
|
4416
|
+
// echo - we must not eat real content.
|
|
4417
|
+
const noSepUppercaseRe = new RegExp(`^(?:${escaped}|${escaped.toUpperCase()})(?=[А-ЯЁA-Z])`);
|
|
2483
4418
|
// Loop the strip so cascading echoes ("Pugi Pugi Pugi, координатор ...")
|
|
2484
4419
|
// collapse to a single name. The model occasionally emits the display
|
|
2485
4420
|
// name two or three times back-to-back when the pane prefix also
|
|
@@ -2491,10 +4426,18 @@ export function stripPersonaPrefixEcho(personaSlug, text) {
|
|
|
2491
4426
|
// matches an empty string (defence-in-depth even though the current
|
|
2492
4427
|
// pattern guarantees at least one consumed char).
|
|
2493
4428
|
for (let i = 0; i < 3; i += 1) {
|
|
2494
|
-
|
|
2495
|
-
if (
|
|
2496
|
-
|
|
2497
|
-
|
|
4429
|
+
let m = re.exec(working);
|
|
4430
|
+
if (m && m[0].length > 0) {
|
|
4431
|
+
working = working.slice(m[0].length).trimStart();
|
|
4432
|
+
continue;
|
|
4433
|
+
}
|
|
4434
|
+
// Fallback: no-separator match for "PugiПринял" / "PugiHello" shape.
|
|
4435
|
+
m = noSepUppercaseRe.exec(working);
|
|
4436
|
+
if (m && m[0].length > 0) {
|
|
4437
|
+
working = working.slice(m[0].length);
|
|
4438
|
+
continue;
|
|
4439
|
+
}
|
|
4440
|
+
break;
|
|
2498
4441
|
}
|
|
2499
4442
|
return working;
|
|
2500
4443
|
}
|