@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.
Files changed (57) hide show
  1. package/dist/core/consensus/diff-capture.js +73 -0
  2. package/dist/core/context/index.js +7 -0
  3. package/dist/core/context/markdown-traverse.js +255 -0
  4. package/dist/core/edits/dispatch.js +218 -2
  5. package/dist/core/edits/journal.js +199 -0
  6. package/dist/core/edits/layer-d-ast.js +557 -14
  7. package/dist/core/edits/verify-hook.js +273 -0
  8. package/dist/core/engine/anvil-client.js +80 -5
  9. package/dist/core/engine/context-prefix.js +155 -0
  10. package/dist/core/engine/intent.js +260 -0
  11. package/dist/core/engine/native-pugi.js +663 -249
  12. package/dist/core/engine/prompts.js +52 -2
  13. package/dist/core/engine/tool-bridge.js +311 -9
  14. package/dist/core/lsp/client.js +57 -0
  15. package/dist/core/mcp/client.js +9 -0
  16. package/dist/core/mcp/http-server.js +553 -0
  17. package/dist/core/mcp/permission.js +190 -0
  18. package/dist/core/mcp/server-tools.js +219 -0
  19. package/dist/core/mcp/server.js +397 -0
  20. package/dist/core/repl/history.js +11 -1
  21. package/dist/core/repl/model-pricing.js +135 -0
  22. package/dist/core/repl/session.js +328 -12
  23. package/dist/core/repl/slash-commands.js +18 -4
  24. package/dist/core/settings.js +43 -0
  25. package/dist/core/subagents/dispatcher-real.js +600 -0
  26. package/dist/core/subagents/dispatcher.js +113 -24
  27. package/dist/core/subagents/index.js +18 -5
  28. package/dist/core/subagents/isolation-matrix.js +213 -0
  29. package/dist/core/subagents/spawn.js +19 -4
  30. package/dist/core/transport/version-interceptor.js +166 -0
  31. package/dist/index.js +28 -0
  32. package/dist/runtime/bootstrap.js +190 -0
  33. package/dist/runtime/cli.js +534 -268
  34. package/dist/runtime/commands/lsp.js +165 -5
  35. package/dist/runtime/commands/mcp.js +537 -0
  36. package/dist/runtime/headless.js +543 -0
  37. package/dist/runtime/load-hooks-or-exit.js +71 -0
  38. package/dist/runtime/version.js +65 -0
  39. package/dist/tools/agent-tool.js +192 -0
  40. package/dist/tools/apply-patch.js +62 -1
  41. package/dist/tools/mcp-tool.js +260 -0
  42. package/dist/tools/multi-edit.js +361 -0
  43. package/dist/tools/registry.js +5 -0
  44. package/dist/tools/web-fetch.js +147 -2
  45. package/dist/tools/web-search.js +458 -0
  46. package/dist/tui/agent-tree.js +10 -0
  47. package/dist/tui/ask-modal.js +2 -2
  48. package/dist/tui/conversation-pane.js +1 -1
  49. package/dist/tui/input-box.js +1 -1
  50. package/dist/tui/markdown-render.js +4 -4
  51. package/dist/tui/repl-render.js +105 -15
  52. package/dist/tui/repl-splash.js +2 -2
  53. package/dist/tui/repl.js +10 -4
  54. package/dist/tui/splash.js +1 -1
  55. package/dist/tui/status-bar.js +94 -16
  56. package/dist/tui/update-banner.js +20 -2
  57. 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
- const { tokensDownstreamTotal, agents } = this.state;
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 lineTokens = `Tokens this session: ${tokensDownstreamTotal.toLocaleString()} (in+out).`;
1003
- const lineAgents = `Active dispatches: ${active} of cap.`;
1004
- this.appendSystemLine(lineTokens);
1005
- this.appendSystemLine(lineAgents);
1006
- this.appendSystemLine('Full per-persona budget breakdown lands in α6.5.');
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
- this.patch({ briefStartedAtEpochMs: this.now() });
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: 'shipped', detail: 'shipped' }
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. If the event carries a taskId and the stamped
1842
- // dispatchSeq for that task is older than the current dispatchSeq,
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
- mcp: 'Run `pugi config mcp list` from a fresh shell; in-REPL palette lands in α6.5.',
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: 'Token usage + budget', group: 'Pugi tools' },
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: 'List MCP servers', group: 'Settings', stub: true },
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 {
@@ -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