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