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