@pugi/cli 0.1.0-beta.12 → 0.1.0-beta.13
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/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/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/engine/anvil-client.js +80 -5
- package/dist/core/engine/context-prefix.js +155 -0
- package/dist/core/engine/intent.js +260 -0
- package/dist/core/engine/native-pugi.js +663 -249
- package/dist/core/engine/prompts.js +52 -2
- package/dist/core/engine/tool-bridge.js +311 -9
- package/dist/core/lsp/client.js +57 -0
- package/dist/core/mcp/client.js +9 -0
- package/dist/core/mcp/http-server.js +553 -0
- package/dist/core/mcp/permission.js +190 -0
- package/dist/core/mcp/server-tools.js +219 -0
- package/dist/core/mcp/server.js +397 -0
- package/dist/core/repl/history.js +11 -1
- package/dist/core/repl/model-pricing.js +135 -0
- package/dist/core/repl/session.js +328 -12
- package/dist/core/repl/slash-commands.js +18 -4
- package/dist/core/settings.js +43 -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 +534 -268
- package/dist/runtime/commands/lsp.js +165 -5
- package/dist/runtime/commands/mcp.js +537 -0
- package/dist/runtime/headless.js +543 -0
- package/dist/runtime/load-hooks-or-exit.js +71 -0
- package/dist/runtime/version.js +65 -0
- package/dist/tools/agent-tool.js +192 -0
- package/dist/tools/apply-patch.js +62 -1
- package/dist/tools/mcp-tool.js +260 -0
- package/dist/tools/multi-edit.js +361 -0
- package/dist/tools/registry.js +5 -0
- package/dist/tools/web-fetch.js +147 -2
- package/dist/tools/web-search.js +458 -0
- package/dist/tui/agent-tree.js +10 -0
- package/dist/tui/ask-modal.js +2 -2
- package/dist/tui/conversation-pane.js +1 -1
- package/dist/tui/input-box.js +1 -1
- package/dist/tui/markdown-render.js +4 -4
- package/dist/tui/repl-render.js +105 -15
- package/dist/tui/repl-splash.js +2 -2
- package/dist/tui/repl.js +10 -4
- package/dist/tui/splash.js +1 -1
- package/dist/tui/status-bar.js +94 -16
- package/dist/tui/update-banner.js +20 -2
- package/package.json +5 -4
|
@@ -39,6 +39,7 @@ import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
|
39
39
|
import { resolve as resolvePath } from 'node:path';
|
|
40
40
|
import { CancellationToken } from './cancellation.js';
|
|
41
41
|
import { DispatchFSM } from './dispatch-fsm.js';
|
|
42
|
+
import { computeCostUsd, formatCostUsd, formatTokens } from './model-pricing.js';
|
|
42
43
|
const MAX_TRANSCRIPT_ROWS = 500;
|
|
43
44
|
const MAX_TOOL_CALLS = 200;
|
|
44
45
|
const MAX_RECONNECT_ATTEMPTS = 10;
|
|
@@ -315,6 +316,19 @@ export class ReplSession {
|
|
|
315
316
|
toolCalls: [],
|
|
316
317
|
transcript: [],
|
|
317
318
|
tokensDownstreamTotal: 0,
|
|
319
|
+
// α7 cost-meter sprint — cost accumulators land at zero on boot.
|
|
320
|
+
// `sessionStartedAtEpochMs` is set at construction time (vs the
|
|
321
|
+
// server-side `agent.session.opened` event) so the elapsed slot
|
|
322
|
+
// on the status row starts ticking the moment the REPL mounts.
|
|
323
|
+
sessionTokensIn: 0,
|
|
324
|
+
sessionTokensOut: 0,
|
|
325
|
+
sessionCostUsd: 0,
|
|
326
|
+
sessionStartedAtEpochMs: this.now(),
|
|
327
|
+
recentTurns: [],
|
|
328
|
+
turnTokensIn: 0,
|
|
329
|
+
turnTokensOut: 0,
|
|
330
|
+
turnCostUsd: 0,
|
|
331
|
+
lastTurnDelta: null,
|
|
318
332
|
briefStartedAtEpochMs: undefined,
|
|
319
333
|
pendingAsk: null,
|
|
320
334
|
pendingAskSource: null,
|
|
@@ -322,6 +336,7 @@ export class ReplSession {
|
|
|
322
336
|
pendingPlanReviewSource: null,
|
|
323
337
|
dispatchState: 'idle',
|
|
324
338
|
dispatchToolLabel: null,
|
|
339
|
+
lastCompletedOutcome: null,
|
|
325
340
|
};
|
|
326
341
|
// α6.9: mirror every FSM transition into the public state so the
|
|
327
342
|
// status-bar surface can rerender on the next frame. Local listener
|
|
@@ -359,6 +374,7 @@ export class ReplSession {
|
|
|
359
374
|
apiUrl: this.options.apiUrl,
|
|
360
375
|
apiKey: this.options.apiKey,
|
|
361
376
|
workspace: this.options.workspace,
|
|
377
|
+
cyberZoo: this.options.cyberZoo,
|
|
362
378
|
});
|
|
363
379
|
this.patch({ sessionId, connection: 'connecting' });
|
|
364
380
|
this.openStream();
|
|
@@ -617,6 +633,10 @@ export class ReplSession {
|
|
|
617
633
|
this.dispatchCost();
|
|
618
634
|
return verdict;
|
|
619
635
|
}
|
|
636
|
+
case 'quota': {
|
|
637
|
+
await this.dispatchQuota();
|
|
638
|
+
return verdict;
|
|
639
|
+
}
|
|
620
640
|
case 'status': {
|
|
621
641
|
this.dispatchStatus();
|
|
622
642
|
return verdict;
|
|
@@ -709,6 +729,42 @@ export class ReplSession {
|
|
|
709
729
|
}
|
|
710
730
|
return verdict;
|
|
711
731
|
}
|
|
732
|
+
case 'mcp': {
|
|
733
|
+
// β4 Sl7 (2026-05-26): /mcp [sub] [args...] forwards to the
|
|
734
|
+
// runtime command. We deliberately route through the same
|
|
735
|
+
// entry-point used by `pugi mcp` from a fresh shell so the
|
|
736
|
+
// surface stays single-sourced. `serve` is refused inline —
|
|
737
|
+
// booting an MCP server inside an active REPL would compete
|
|
738
|
+
// with the REPL itself for stdio, which is exactly the wrong
|
|
739
|
+
// thing to do.
|
|
740
|
+
if (verdict.args[0] === 'serve') {
|
|
741
|
+
this.appendSystemLine('/mcp serve is not safe inside the REPL (it competes for stdio). ' +
|
|
742
|
+
'Run `pugi mcp serve` from a fresh shell instead.');
|
|
743
|
+
return verdict;
|
|
744
|
+
}
|
|
745
|
+
try {
|
|
746
|
+
const { runMcpCommand } = await import('../../runtime/commands/mcp.js');
|
|
747
|
+
const lines = [];
|
|
748
|
+
await runMcpCommand(verdict.args, {
|
|
749
|
+
workspaceRoot: process.cwd(),
|
|
750
|
+
writeOutput: (_payload, text) => {
|
|
751
|
+
const trimmed = text.replace(/\n+$/u, '');
|
|
752
|
+
if (trimmed.length > 0)
|
|
753
|
+
lines.push(trimmed);
|
|
754
|
+
},
|
|
755
|
+
});
|
|
756
|
+
for (const line of lines)
|
|
757
|
+
this.appendSystemLine(line);
|
|
758
|
+
if (lines.length === 0) {
|
|
759
|
+
this.appendSystemLine('/mcp: no output.');
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
catch (error) {
|
|
763
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
764
|
+
this.appendSystemLine(`/mcp failed: ${message}`);
|
|
765
|
+
}
|
|
766
|
+
return verdict;
|
|
767
|
+
}
|
|
712
768
|
case 'stub': {
|
|
713
769
|
this.appendSystemLine(verdict.message);
|
|
714
770
|
return verdict;
|
|
@@ -997,13 +1053,90 @@ export class ReplSession {
|
|
|
997
1053
|
}
|
|
998
1054
|
}
|
|
999
1055
|
dispatchCost() {
|
|
1000
|
-
|
|
1056
|
+
// α7 cost-meter sprint — full breakdown matching the TUI status row
|
|
1057
|
+
// footer. The session totals line mirrors the footer format
|
|
1058
|
+
// (`↑ <in> ↓ <out> · $X.XX · <elapsed>`) so the operator scans the
|
|
1059
|
+
// same numbers in two places. Per-turn list shows the last 5 turns
|
|
1060
|
+
// oldest → newest; an empty list renders one system line so the
|
|
1061
|
+
// operator knows the surface is wired (`No completed turns yet.`).
|
|
1062
|
+
const { sessionTokensIn, sessionTokensOut, sessionCostUsd, sessionStartedAtEpochMs, recentTurns, agents, } = this.state;
|
|
1001
1063
|
const active = agents.filter((a) => a.status === 'queued' || a.status === 'thinking').length;
|
|
1002
|
-
const
|
|
1003
|
-
const
|
|
1004
|
-
this.appendSystemLine(
|
|
1005
|
-
this.appendSystemLine(
|
|
1006
|
-
|
|
1064
|
+
const elapsedMs = Math.max(0, this.now() - sessionStartedAtEpochMs);
|
|
1065
|
+
const elapsedLabel = formatElapsedShort(elapsedMs);
|
|
1066
|
+
this.appendSystemLine(`Session: ↑ ${formatTokens(sessionTokensIn)} ↓ ${formatTokens(sessionTokensOut)} · ${formatCostUsd(sessionCostUsd)} · ${elapsedLabel}`);
|
|
1067
|
+
this.appendSystemLine(`Active dispatches: ${active} of cap.`);
|
|
1068
|
+
if (recentTurns.length === 0) {
|
|
1069
|
+
this.appendSystemLine('No completed turns yet — brief the workforce to charge the meter.');
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
this.appendSystemLine(`Recent turns (last ${recentTurns.length}):`);
|
|
1073
|
+
for (let i = 0; i < recentTurns.length; i += 1) {
|
|
1074
|
+
const turn = recentTurns[i];
|
|
1075
|
+
const idx = (i + 1).toString().padStart(2, ' ');
|
|
1076
|
+
this.appendSystemLine(` ${idx}. ↑ ${formatTokens(turn.tokensIn)} ↓ ${formatTokens(turn.tokensOut)} · ${formatCostUsd(turn.costUsd)}`);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
/**
|
|
1080
|
+
* α7 cost-meter sprint — `/quota` slash handler. Fetches the live
|
|
1081
|
+
* `/api/pugi/usage` snapshot and renders three lines: plan tier,
|
|
1082
|
+
* monthly window, and per-counter `used/cap (pct%)`. Failure modes
|
|
1083
|
+
* (offline, unauth, older admin-api) collapse to a single one-line
|
|
1084
|
+
* `Could not fetch quota…` system message so the surface never throws
|
|
1085
|
+
* from a keystroke handler.
|
|
1086
|
+
*
|
|
1087
|
+
* The fetch is best-effort with a 4s timeout — mirrors the `whoami`
|
|
1088
|
+
* pattern in `runtime/cli.ts` so the operator gets the same UX on the
|
|
1089
|
+
* REPL slash and the CLI command.
|
|
1090
|
+
*/
|
|
1091
|
+
async dispatchQuota() {
|
|
1092
|
+
const controller = new AbortController();
|
|
1093
|
+
const timer = setTimeout(() => controller.abort(), 4000);
|
|
1094
|
+
try {
|
|
1095
|
+
const url = `${this.options.apiUrl.replace(/\/+$/, '')}/api/pugi/usage`;
|
|
1096
|
+
const res = await fetch(url, {
|
|
1097
|
+
method: 'GET',
|
|
1098
|
+
headers: {
|
|
1099
|
+
authorization: `Bearer ${this.options.apiKey}`,
|
|
1100
|
+
accept: 'application/json',
|
|
1101
|
+
},
|
|
1102
|
+
signal: controller.signal,
|
|
1103
|
+
});
|
|
1104
|
+
if (!res.ok) {
|
|
1105
|
+
this.appendSystemLine(`Could not fetch quota: HTTP ${res.status}.`);
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
const body = (await res.json());
|
|
1109
|
+
const tier = typeof body.tier === 'string' ? body.tier : '(unknown)';
|
|
1110
|
+
const tierLabel = QUOTA_TIER_LABELS[tier] ?? tier;
|
|
1111
|
+
const month = typeof body.billingMonth === 'string' ? body.billingMonth : '(unknown month)';
|
|
1112
|
+
const resetAt = typeof body.resetAt === 'string' ? body.resetAt : null;
|
|
1113
|
+
const resetLine = resetAt ? ` · resets ${formatResetWindow(resetAt, this.now())}` : '';
|
|
1114
|
+
this.appendSystemLine(`Plan: ${tierLabel} · ${month}${resetLine}`);
|
|
1115
|
+
const used = body.used ?? {};
|
|
1116
|
+
const caps = body.quotas ?? {};
|
|
1117
|
+
const counters = [
|
|
1118
|
+
['sync', used.sync, caps.sync],
|
|
1119
|
+
['review', used.review, caps.review],
|
|
1120
|
+
['engine', used.engine, caps.engine],
|
|
1121
|
+
];
|
|
1122
|
+
for (const [name, value, cap] of counters) {
|
|
1123
|
+
const v = typeof value === 'number' ? value : 0;
|
|
1124
|
+
if (cap === null || cap === undefined) {
|
|
1125
|
+
this.appendSystemLine(` ${name.padEnd(7, ' ')} ${v.toLocaleString()} / unlimited`);
|
|
1126
|
+
}
|
|
1127
|
+
else {
|
|
1128
|
+
const pct = cap > 0 ? Math.round((v / cap) * 100) : 0;
|
|
1129
|
+
this.appendSystemLine(` ${name.padEnd(7, ' ')} ${v.toLocaleString()} / ${cap.toLocaleString()} (${pct}%)`);
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
catch (error) {
|
|
1134
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
1135
|
+
this.appendSystemLine(`Could not fetch quota: ${msg}.`);
|
|
1136
|
+
}
|
|
1137
|
+
finally {
|
|
1138
|
+
clearTimeout(timer);
|
|
1139
|
+
}
|
|
1007
1140
|
}
|
|
1008
1141
|
dispatchStatus() {
|
|
1009
1142
|
const sessionId = this.state.sessionId ?? '(unbound)';
|
|
@@ -1194,7 +1327,10 @@ export class ReplSession {
|
|
|
1194
1327
|
this.appendSystemLine(capLine);
|
|
1195
1328
|
}
|
|
1196
1329
|
this.appendOperatorLine(brief);
|
|
1197
|
-
|
|
1330
|
+
// Reset `lastCompletedOutcome` so a fresh dispatch does not
|
|
1331
|
+
// inherit the prior turn's status-bar label (e.g. a stale
|
|
1332
|
+
// "replied" sticking around while the next dispatch is in flight).
|
|
1333
|
+
this.patch({ briefStartedAtEpochMs: this.now(), lastCompletedOutcome: null });
|
|
1198
1334
|
// α6.9 + R3 P1 (Codex triple-review 2026-05-25): supersede the
|
|
1199
1335
|
// prior dispatch when one is in flight. Steps in order:
|
|
1200
1336
|
//
|
|
@@ -1521,6 +1657,7 @@ export class ReplSession {
|
|
|
1521
1657
|
apiUrl: this.options.apiUrl,
|
|
1522
1658
|
apiKey: this.options.apiKey,
|
|
1523
1659
|
workspace: this.options.workspace,
|
|
1660
|
+
cyberZoo: this.options.cyberZoo,
|
|
1524
1661
|
});
|
|
1525
1662
|
this.patch({ sessionId, connection: 'connecting' });
|
|
1526
1663
|
this.openStream();
|
|
@@ -1677,8 +1814,22 @@ export class ReplSession {
|
|
|
1677
1814
|
}
|
|
1678
1815
|
case 'agent.tokens': {
|
|
1679
1816
|
const delta = event.tokensIn + event.tokensOut;
|
|
1817
|
+
// α7 cost-meter sprint — bind a client-side USD figure to this
|
|
1818
|
+
// frame. The model slug rides on the event (optional for back-
|
|
1819
|
+
// compat); the price ladder in `model-pricing.ts` falls back to
|
|
1820
|
+
// a Sonnet-tier rate when the slug is missing, so the meter is
|
|
1821
|
+
// always populated. Negative / NaN values are clamped to zero
|
|
1822
|
+
// inside `computeCostUsd` so a buggy upstream never credits the
|
|
1823
|
+
// meter.
|
|
1824
|
+
const deltaCostUsd = computeCostUsd(event.tokensIn, event.tokensOut, event.model);
|
|
1680
1825
|
this.patch({
|
|
1681
1826
|
tokensDownstreamTotal: this.state.tokensDownstreamTotal + delta,
|
|
1827
|
+
sessionTokensIn: this.state.sessionTokensIn + event.tokensIn,
|
|
1828
|
+
sessionTokensOut: this.state.sessionTokensOut + event.tokensOut,
|
|
1829
|
+
sessionCostUsd: this.state.sessionCostUsd + deltaCostUsd,
|
|
1830
|
+
turnTokensIn: this.state.turnTokensIn + event.tokensIn,
|
|
1831
|
+
turnTokensOut: this.state.turnTokensOut + event.tokensOut,
|
|
1832
|
+
turnCostUsd: this.state.turnCostUsd + deltaCostUsd,
|
|
1682
1833
|
agents: this.state.agents.map((a) => a.taskId === event.taskId
|
|
1683
1834
|
? {
|
|
1684
1835
|
...a,
|
|
@@ -1698,10 +1849,36 @@ export class ReplSession {
|
|
|
1698
1849
|
}
|
|
1699
1850
|
this.askBuffer.delete(event.taskId);
|
|
1700
1851
|
this.askBufferPending.delete(event.taskId);
|
|
1852
|
+
// Honour the work-done signal from admin-api.
|
|
1853
|
+
// `outcome === 'replied'` means the turn was a pure text reply
|
|
1854
|
+
// with no delegate XML and no tool call — render it as
|
|
1855
|
+
// "replied" so the operator can tell the difference between
|
|
1856
|
+
// "the orchestrator just talked" and "real work shipped".
|
|
1857
|
+
// Older servers omit the field; default to 'shipped' so the
|
|
1858
|
+
// existing wire stays back-compat.
|
|
1859
|
+
const completedStatus = event.outcome === 'replied' ? 'replied' : 'shipped';
|
|
1701
1860
|
this.patch({
|
|
1702
1861
|
agents: this.state.agents.map((a) => a.taskId === event.taskId
|
|
1703
|
-
? { ...a, status:
|
|
1862
|
+
? { ...a, status: completedStatus, detail: completedStatus }
|
|
1704
1863
|
: a),
|
|
1864
|
+
// Mirror the outcome to top-level state so the status-bar
|
|
1865
|
+
// can render `replied` instead of the legacy `shipped`
|
|
1866
|
+
// label when the FSM lands in `completed`. Without this
|
|
1867
|
+
// the bottom-bar would still say "shipped" while the
|
|
1868
|
+
// agent-tree said "replied", restoring the same
|
|
1869
|
+
// contradiction this PR is fixing (Codex triple-review P2).
|
|
1870
|
+
//
|
|
1871
|
+
// r2: gate on the same stale-dispatch check that
|
|
1872
|
+
// advanceFsmOnDispatchEnd applies. If this completion
|
|
1873
|
+
// belongs to a SUPERSEDED dispatch (a newer dispatchBrief
|
|
1874
|
+
// already bumped dispatchSeq before this late terminal
|
|
1875
|
+
// arrived), don't let the status-bar label flip to the
|
|
1876
|
+
// stale outcome — the current turn is the live one.
|
|
1877
|
+
// The agent-tree row patch above is still safe because
|
|
1878
|
+
// it only updates the row keyed by taskId.
|
|
1879
|
+
...(this.isStaleTaskEvent(event.taskId)
|
|
1880
|
+
? {}
|
|
1881
|
+
: { lastCompletedOutcome: completedStatus }),
|
|
1705
1882
|
});
|
|
1706
1883
|
// α6.9: transition the FSM to `completed` when no other
|
|
1707
1884
|
// dispatch is still in flight. The check uses the agents list
|
|
@@ -1709,6 +1886,12 @@ export class ReplSession {
|
|
|
1709
1886
|
// the dispatch alive; the FSM only goes terminal when the last
|
|
1710
1887
|
// agent ships.
|
|
1711
1888
|
this.advanceFsmOnDispatchEnd('completed', 'agent_completed', event.taskId);
|
|
1889
|
+
// α7 cost-meter sprint — flush the per-turn delta when the
|
|
1890
|
+
// LAST agent settles. Decoupled from the FSM gate so a test
|
|
1891
|
+
// fixture (or a single-agent dispatch that never reached
|
|
1892
|
+
// `awaiting_response` — happens on instant SSE replay) still
|
|
1893
|
+
// gets the row written into recentTurns + lastTurnDelta.
|
|
1894
|
+
this.maybeFlushTurnOnAgentSettle(event.taskId);
|
|
1712
1895
|
if (target) {
|
|
1713
1896
|
// If the persona actually produced a reply via incremental
|
|
1714
1897
|
// agent.step events, render that reply in the transcript so
|
|
@@ -1774,6 +1957,10 @@ export class ReplSession {
|
|
|
1774
1957
|
// operator sees the bottom-bar settle back to `idle` after the
|
|
1775
1958
|
// last block clears.
|
|
1776
1959
|
this.advanceFsmOnDispatchEnd('completed', 'agent_blocked', event.taskId);
|
|
1960
|
+
// α7 cost-meter sprint — flush the per-turn delta (blocked
|
|
1961
|
+
// still counts as a billable turn — the operator paid for the
|
|
1962
|
+
// tokens that landed before the refusal).
|
|
1963
|
+
this.maybeFlushTurnOnAgentSettle(event.taskId);
|
|
1777
1964
|
return;
|
|
1778
1965
|
}
|
|
1779
1966
|
case 'agent.failed': {
|
|
@@ -1797,6 +1984,10 @@ export class ReplSession {
|
|
|
1797
1984
|
// `completed` so the bottom-bar surface tracks the dispatch
|
|
1798
1985
|
// collectively.
|
|
1799
1986
|
this.advanceFsmOnDispatchEnd('failed', 'agent_failed', event.taskId);
|
|
1987
|
+
// α7 cost-meter sprint — flush the per-turn delta when the
|
|
1988
|
+
// dispatch fails (the operator still paid for whatever tokens
|
|
1989
|
+
// landed before the failure).
|
|
1990
|
+
this.maybeFlushTurnOnAgentSettle(event.taskId);
|
|
1800
1991
|
return;
|
|
1801
1992
|
}
|
|
1802
1993
|
}
|
|
@@ -1835,13 +2026,25 @@ export class ReplSession {
|
|
|
1835
2026
|
* after a manual `cancel()` finds the FSM already in `aborted` and
|
|
1836
2027
|
* is silently dropped.
|
|
1837
2028
|
*/
|
|
2029
|
+
/**
|
|
2030
|
+
* 2026-05-26 — shared stale-task check used by both the FSM advance
|
|
2031
|
+
* gate AND the status-bar `lastCompletedOutcome` mirror. Lifts the
|
|
2032
|
+
* R2 dispatchSeq compare out of `advanceFsmOnDispatchEnd` so other
|
|
2033
|
+
* agent.completed-handler side-effects (status-bar label, future
|
|
2034
|
+
* metric counters) can apply the same guard without duplicating it.
|
|
2035
|
+
* Returns true iff the task's stamped dispatchSeq is older than the
|
|
2036
|
+
* current dispatchSeq — i.e. a newer dispatchBrief() superseded it
|
|
2037
|
+
* and the late terminal event must not corrupt live-turn state.
|
|
2038
|
+
*/
|
|
2039
|
+
isStaleTaskEvent(taskId) {
|
|
2040
|
+
const taskSeq = this.taskDispatchSeq.get(taskId);
|
|
2041
|
+
return taskSeq !== undefined && taskSeq < this.dispatchSeq;
|
|
2042
|
+
}
|
|
1838
2043
|
advanceFsmOnDispatchEnd(outcome, reason, taskId) {
|
|
1839
2044
|
// R2 P1 fix (Codex triple-review 2026-05-25): a terminal event
|
|
1840
2045
|
// for a SUPERSEDED dispatch must NOT advance the live FSM or null
|
|
1841
|
-
// the live token.
|
|
1842
|
-
//
|
|
1843
|
-
// the event belongs to a prior dispatch that was replaced by a
|
|
1844
|
-
// newer `dispatchBrief()`. Silently drop the FSM advance.
|
|
2046
|
+
// the live token. Delegates to isStaleTaskEvent so the agent.completed
|
|
2047
|
+
// status-bar mirror in the handler above uses the same gate.
|
|
1845
2048
|
if (taskId !== undefined) {
|
|
1846
2049
|
const taskSeq = this.taskDispatchSeq.get(taskId);
|
|
1847
2050
|
if (taskSeq !== undefined && taskSeq < this.dispatchSeq) {
|
|
@@ -1873,6 +2076,63 @@ export class ReplSession {
|
|
|
1873
2076
|
this.currentDispatchToken = null;
|
|
1874
2077
|
this.patch({ briefStartedAtEpochMs: undefined });
|
|
1875
2078
|
}
|
|
2079
|
+
/**
|
|
2080
|
+
* α7 cost-meter sprint — gate the per-turn flush on "this was the
|
|
2081
|
+
* LAST in-flight agent". Mirrors the `stillActive` guard inside
|
|
2082
|
+
* `advanceFsmOnDispatchEnd` so a multi-agent dispatch only emits a
|
|
2083
|
+
* single recentTurns row + a single lastTurnDelta flash.
|
|
2084
|
+
*
|
|
2085
|
+
* Idempotent: if no tokens have been billed this turn, the inner
|
|
2086
|
+
* `flushTurnAccumulator` short-circuits without pushing an empty row.
|
|
2087
|
+
*/
|
|
2088
|
+
maybeFlushTurnOnAgentSettle(taskId) {
|
|
2089
|
+
const stillActive = this.state.agents.some((a) => a.status === 'queued' || a.status === 'thinking');
|
|
2090
|
+
if (stillActive)
|
|
2091
|
+
return;
|
|
2092
|
+
this.flushTurnAccumulator(taskId);
|
|
2093
|
+
}
|
|
2094
|
+
/**
|
|
2095
|
+
* α7 cost-meter sprint — flush the per-turn accumulator into
|
|
2096
|
+
* `recentTurns` + `lastTurnDelta`. Idempotent + safe to call from any
|
|
2097
|
+
* terminal-state branch (`agent.completed` / `agent.blocked` /
|
|
2098
|
+
* `agent.failed`). When no tokens have been billed this turn
|
|
2099
|
+
* (instant abort, cap-warning gate), the helper short-circuits
|
|
2100
|
+
* without pushing an empty row.
|
|
2101
|
+
*/
|
|
2102
|
+
flushTurnAccumulator(taskId) {
|
|
2103
|
+
const turnTokensIn = this.state.turnTokensIn;
|
|
2104
|
+
const turnTokensOut = this.state.turnTokensOut;
|
|
2105
|
+
const turnCostUsd = this.state.turnCostUsd;
|
|
2106
|
+
if (turnTokensIn === 0 && turnTokensOut === 0) {
|
|
2107
|
+
// Idempotent zero-flush — never push an empty row into recentTurns.
|
|
2108
|
+
return;
|
|
2109
|
+
}
|
|
2110
|
+
const turnId = taskId !== undefined ? taskId : `turn-${this.dispatchSeq}-${this.now()}`;
|
|
2111
|
+
const newTurn = {
|
|
2112
|
+
id: turnId,
|
|
2113
|
+
tokensIn: turnTokensIn,
|
|
2114
|
+
tokensOut: turnTokensOut,
|
|
2115
|
+
costUsd: turnCostUsd,
|
|
2116
|
+
completedAt: new Date(this.now()).toISOString(),
|
|
2117
|
+
};
|
|
2118
|
+
// Keep the buffer capped at 5 entries (oldest first). The push
|
|
2119
|
+
// order matches the surface contract: `/cost` paginates oldest →
|
|
2120
|
+
// newest so the operator scans top-down chronologically.
|
|
2121
|
+
const recent = [...this.state.recentTurns, newTurn];
|
|
2122
|
+
const trimmed = recent.length > 5 ? recent.slice(-5) : recent;
|
|
2123
|
+
this.patch({
|
|
2124
|
+
recentTurns: trimmed,
|
|
2125
|
+
lastTurnDelta: {
|
|
2126
|
+
tokensIn: turnTokensIn,
|
|
2127
|
+
tokensOut: turnTokensOut,
|
|
2128
|
+
costUsd: turnCostUsd,
|
|
2129
|
+
completedAtEpochMs: this.now(),
|
|
2130
|
+
},
|
|
2131
|
+
turnTokensIn: 0,
|
|
2132
|
+
turnTokensOut: 0,
|
|
2133
|
+
turnCostUsd: 0,
|
|
2134
|
+
});
|
|
2135
|
+
}
|
|
1876
2136
|
/* ------------- transcript helpers -------------- */
|
|
1877
2137
|
/**
|
|
1878
2138
|
* Look up the persona slug for a running task. Used by the tool call
|
|
@@ -2255,6 +2515,62 @@ function formatAgeSeconds(deltaMs) {
|
|
|
2255
2515
|
export function knownRoles() {
|
|
2256
2516
|
return listRoles();
|
|
2257
2517
|
}
|
|
2518
|
+
/**
|
|
2519
|
+
* α7 cost-meter sprint — render a session-elapsed ms delta as the
|
|
2520
|
+
* status-row's compact `XmYs` / `XhYm` shape. Distinct from
|
|
2521
|
+
* `formatAgeSeconds` above because `/cost` needs minute-granularity
|
|
2522
|
+
* uniformly (operator wants `2m44s`, not `2m`). Pure / branch-cheap;
|
|
2523
|
+
* the TUI status row + `/cost` both call this on every render.
|
|
2524
|
+
*/
|
|
2525
|
+
function formatElapsedShort(elapsedMs) {
|
|
2526
|
+
if (!Number.isFinite(elapsedMs) || elapsedMs <= 0)
|
|
2527
|
+
return '0s';
|
|
2528
|
+
const totalSec = Math.floor(elapsedMs / 1000);
|
|
2529
|
+
if (totalSec < 60)
|
|
2530
|
+
return `${totalSec}s`;
|
|
2531
|
+
const min = Math.floor(totalSec / 60);
|
|
2532
|
+
const sec = totalSec % 60;
|
|
2533
|
+
if (min < 60)
|
|
2534
|
+
return `${min}m${sec.toString().padStart(2, '0')}s`;
|
|
2535
|
+
const hr = Math.floor(min / 60);
|
|
2536
|
+
const restMin = min % 60;
|
|
2537
|
+
return `${hr}h${restMin.toString().padStart(2, '0')}m`;
|
|
2538
|
+
}
|
|
2539
|
+
/**
|
|
2540
|
+
* α7 cost-meter sprint — public-facing tier labels for the `/quota`
|
|
2541
|
+
* slash. Mirrors `TIER_PRICE_LABEL` in `runtime/cli.ts` (kept in sync
|
|
2542
|
+
* via `pricing.spec.ts` gate). Falls through to the raw slug when an
|
|
2543
|
+
* unknown tier ships from a forward-compat admin-api build.
|
|
2544
|
+
*/
|
|
2545
|
+
const QUOTA_TIER_LABELS = Object.freeze({
|
|
2546
|
+
free: 'Free',
|
|
2547
|
+
founder: 'Founder ($20/mo)',
|
|
2548
|
+
builder: 'Builder ($99/mo)',
|
|
2549
|
+
team: 'Team ($199/mo)',
|
|
2550
|
+
});
|
|
2551
|
+
/**
|
|
2552
|
+
* α7 cost-meter sprint — render the time-until-reset window for the
|
|
2553
|
+
* `/quota` plan line. `resetAt` is the ISO string admin-api returns;
|
|
2554
|
+
* `now` is the current epoch ms (injected for test determinism). Falls
|
|
2555
|
+
* back to the raw ISO string when parsing fails so the operator never
|
|
2556
|
+
* sees an empty hint.
|
|
2557
|
+
*/
|
|
2558
|
+
function formatResetWindow(resetAtIso, nowEpochMs) {
|
|
2559
|
+
const resetMs = Date.parse(resetAtIso);
|
|
2560
|
+
if (!Number.isFinite(resetMs))
|
|
2561
|
+
return resetAtIso;
|
|
2562
|
+
const deltaMs = resetMs - nowEpochMs;
|
|
2563
|
+
if (deltaMs <= 0)
|
|
2564
|
+
return 'now';
|
|
2565
|
+
const days = Math.floor(deltaMs / (24 * 60 * 60 * 1000));
|
|
2566
|
+
if (days >= 2)
|
|
2567
|
+
return `in ${days}d`;
|
|
2568
|
+
const hours = Math.floor(deltaMs / (60 * 60 * 1000));
|
|
2569
|
+
if (hours >= 1)
|
|
2570
|
+
return `in ${hours}h`;
|
|
2571
|
+
const minutes = Math.max(1, Math.floor(deltaMs / (60 * 1000)));
|
|
2572
|
+
return `in ${minutes}m`;
|
|
2573
|
+
}
|
|
2258
2574
|
/* ------------------------------------------------------------------ */
|
|
2259
2575
|
/* Tool call synthesiser - α6.12 */
|
|
2260
2576
|
/* ------------------------------------------------------------------ */
|
|
@@ -45,7 +45,9 @@ export const SLASH_STUB_MESSAGES = Object.freeze({
|
|
|
45
45
|
// runtime but the type record stays exhaustive.
|
|
46
46
|
privacy: '',
|
|
47
47
|
budget: 'Run `pugi budget` from a fresh shell; in-REPL summary lands in α6.5.',
|
|
48
|
-
|
|
48
|
+
// β4 Sl7 (2026-05-26): /mcp graduated from stub to a real handler
|
|
49
|
+
// that forwards to `runMcpCommand`. Stub message removed from the
|
|
50
|
+
// exhaustive record so the type narrows correctly.
|
|
49
51
|
undo: 'Run `pugi undo` from a fresh shell; in-REPL undo lands in α6.5.',
|
|
50
52
|
});
|
|
51
53
|
export const SLASH_COMMAND_HELP = Object.freeze([
|
|
@@ -66,14 +68,15 @@ export const SLASH_COMMAND_HELP = Object.freeze([
|
|
|
66
68
|
// Pugi tools
|
|
67
69
|
{ name: 'web', args: '<url>', gloss: 'Fetch a URL into context', group: 'Pugi tools' },
|
|
68
70
|
{ name: 'diff', args: '', gloss: 'Show pending diff', group: 'Pugi tools' },
|
|
69
|
-
{ name: 'cost', args: '', gloss: '
|
|
71
|
+
{ name: 'cost', args: '', gloss: 'Session token + USD totals + last 5 turn breakdown', group: 'Pugi tools' },
|
|
72
|
+
{ name: 'quota', args: '', gloss: 'Plan tier + monthly usage caps (sync / review / engine)', group: 'Pugi tools' },
|
|
70
73
|
{ name: 'status', args: '', gloss: 'Backend + tenant status', group: 'Pugi tools' },
|
|
71
74
|
{ name: 'consensus', args: '[ref]', gloss: '3-model consensus review (codex · claude · deepseek)', group: 'Pugi tools' },
|
|
72
75
|
// Settings
|
|
73
76
|
{ name: 'config', args: '', gloss: 'Show config', group: 'Settings', stub: true },
|
|
74
77
|
{ name: 'privacy', args: '', gloss: 'Show privacy mode + contract', group: 'Settings' },
|
|
75
78
|
{ name: 'budget', args: '', gloss: 'Show usage budget', group: 'Settings', stub: true },
|
|
76
|
-
{ name: 'mcp', args: '', gloss: '
|
|
79
|
+
{ name: 'mcp', args: '[sub]', gloss: 'MCP servers — list / trust / deny / install / serve / perms', group: 'Settings' },
|
|
77
80
|
{ name: 'undo', args: '', gloss: 'Undo last write', group: 'Settings', stub: true },
|
|
78
81
|
// Meta
|
|
79
82
|
{ name: 'help', args: '', gloss: 'Show this help overlay', group: 'Meta' },
|
|
@@ -218,6 +221,10 @@ export function parseSlashCommand(input) {
|
|
|
218
221
|
case 'cost': {
|
|
219
222
|
return { kind: 'cost' };
|
|
220
223
|
}
|
|
224
|
+
case 'quota':
|
|
225
|
+
case 'usage': {
|
|
226
|
+
return { kind: 'quota' };
|
|
227
|
+
}
|
|
221
228
|
case 'status': {
|
|
222
229
|
return { kind: 'status' };
|
|
223
230
|
}
|
|
@@ -256,11 +263,18 @@ export function parseSlashCommand(input) {
|
|
|
256
263
|
// skills.
|
|
257
264
|
return { kind: 'init' };
|
|
258
265
|
}
|
|
266
|
+
case 'mcp': {
|
|
267
|
+
// β4 Sl7: tokenize the tail. Empty tail -> `list` (matches CLI).
|
|
268
|
+
// Quoting / shell-escapes are NOT supported — the slash surface is
|
|
269
|
+
// intentionally simple; complex installs (env vars, multi-word
|
|
270
|
+
// args) go through `pugi mcp install` from a fresh shell.
|
|
271
|
+
const tokens = tail.length === 0 ? [] : tail.split(/\s+/).filter((s) => s.length > 0);
|
|
272
|
+
return { kind: 'mcp', args: tokens };
|
|
273
|
+
}
|
|
259
274
|
case 'compact':
|
|
260
275
|
case 'memory':
|
|
261
276
|
case 'config':
|
|
262
277
|
case 'budget':
|
|
263
|
-
case 'mcp':
|
|
264
278
|
case 'undo': {
|
|
265
279
|
const stubName = name;
|
|
266
280
|
return {
|
package/dist/core/settings.js
CHANGED
|
@@ -28,6 +28,17 @@ const pugiSettingsSchema = z.object({
|
|
|
28
28
|
telemetry: z.enum(['off', 'anonymous', 'community']).default('off'),
|
|
29
29
|
})
|
|
30
30
|
.default({}),
|
|
31
|
+
// beta.13 P1 fix 2026-05-26: ui.cyberZoo gates the cyber-zoo splash +
|
|
32
|
+
// ambient art in the REPL. Schema must declare the key explicitly
|
|
33
|
+
// because Zod's strip pass swallows unknown keys, which is how the
|
|
34
|
+
// initial `pugi init` write (which serialises `ui.cyberZoo`) was
|
|
35
|
+
// bypassed by the runtime reader — the value never made it past the
|
|
36
|
+
// schema gate so admin-api always saw the historical 'on' default.
|
|
37
|
+
ui: z
|
|
38
|
+
.object({
|
|
39
|
+
cyberZoo: z.enum(['on', 'off']).default('on'),
|
|
40
|
+
})
|
|
41
|
+
.default({}),
|
|
31
42
|
artifacts: z
|
|
32
43
|
.object({
|
|
33
44
|
defaultPath: z.string().default('.pugi/artifacts'),
|
|
@@ -38,6 +49,12 @@ const pugiSettingsSchema = z.object({
|
|
|
38
49
|
// fetcher. Default-off matches the spec posture; the schema must
|
|
39
50
|
// declare it explicitly because Zod's strict-pass strips unknown
|
|
40
51
|
// keys and would silently swallow the operator's intent.
|
|
52
|
+
//
|
|
53
|
+
// β1b T4 (2026-05-26): added `web.search.enabled` to gate the
|
|
54
|
+
// Brave-Search-backed `web_search` tool. Distinct from `web.fetch`
|
|
55
|
+
// because search queries themselves are an egress event that can
|
|
56
|
+
// leak operator intent — an operator may want fetch without
|
|
57
|
+
// implicitly enabling search-as-egress.
|
|
41
58
|
web: z
|
|
42
59
|
.object({
|
|
43
60
|
fetch: z
|
|
@@ -45,6 +62,32 @@ const pugiSettingsSchema = z.object({
|
|
|
45
62
|
enabled: z.boolean().optional(),
|
|
46
63
|
})
|
|
47
64
|
.optional(),
|
|
65
|
+
search: z
|
|
66
|
+
.object({
|
|
67
|
+
enabled: z.boolean().optional(),
|
|
68
|
+
})
|
|
69
|
+
.optional(),
|
|
70
|
+
})
|
|
71
|
+
.optional(),
|
|
72
|
+
// β7 L9 — per-language LSP toggle. When omitted, every supported
|
|
73
|
+
// server is available subject to binary detection on PATH. When
|
|
74
|
+
// present, only languages set to `true` are launched (false silently
|
|
75
|
+
// skips that language even if the binary is installed). Use this in
|
|
76
|
+
// workspaces where a heavyweight server (rust-analyzer indexing a
|
|
77
|
+
// monorepo, pyright on a fresh venv) wastes resources for the
|
|
78
|
+
// current task. The `pugi lsp servers` subcommand surfaces the
|
|
79
|
+
// current toggle state per server.
|
|
80
|
+
//
|
|
81
|
+
// Schema is intentionally permissive (`optional()` on the section AND
|
|
82
|
+
// on every per-language flag) so a partial config keeps the
|
|
83
|
+
// backwards-compatible "every language enabled" default.
|
|
84
|
+
lsp: z
|
|
85
|
+
.object({
|
|
86
|
+
typescript: z.boolean().optional(),
|
|
87
|
+
javascript: z.boolean().optional(),
|
|
88
|
+
python: z.boolean().optional(),
|
|
89
|
+
go: z.boolean().optional(),
|
|
90
|
+
rust: z.boolean().optional(),
|
|
48
91
|
})
|
|
49
92
|
.optional(),
|
|
50
93
|
// β1 Pl9 (#74) — per-command budget overrides. Optional. Partial
|