@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
|
@@ -38,20 +38,26 @@ import { listRoles } from '../agents/registry.js';
|
|
|
38
38
|
* silently appear here with empty placeholders.
|
|
39
39
|
*/
|
|
40
40
|
export const SLASH_STUB_MESSAGES = Object.freeze({
|
|
41
|
-
|
|
41
|
+
// Leak L8 (2026-05-27): /compact graduated from stub. The session
|
|
42
|
+
// module now owns the summariser round-trip + boundary marker append
|
|
43
|
+
// via `dispatchCompact`. Keep the type record exhaustive so a future
|
|
44
|
+
// stub addition cannot silently overlap the wired set.
|
|
42
45
|
memory: 'Session memory editor lands in α6.5b.',
|
|
43
46
|
config: 'Run `pugi config list` from a fresh shell for the full surface; in-REPL editor lands in α6.5.',
|
|
44
47
|
// alpha 6.13: /privacy graduated from stub; nothing reads this at
|
|
45
48
|
// runtime but the type record stays exhaustive.
|
|
46
49
|
privacy: '',
|
|
47
50
|
budget: 'Run `pugi budget` from a fresh shell; in-REPL summary lands in α6.5.',
|
|
48
|
-
|
|
51
|
+
// β4 Sl7 (2026-05-26): /mcp graduated from stub to a real handler
|
|
52
|
+
// that forwards to `runMcpCommand`. Stub message removed from the
|
|
53
|
+
// exhaustive record so the type narrows correctly.
|
|
49
54
|
undo: 'Run `pugi undo` from a fresh shell; in-REPL undo lands in α6.5.',
|
|
50
55
|
});
|
|
51
56
|
export const SLASH_COMMAND_HELP = Object.freeze([
|
|
52
57
|
// Workforce dispatch
|
|
53
58
|
{ name: 'brief', args: '<text>', gloss: 'Dispatch a brief to the workforce', group: 'Workforce dispatch' },
|
|
54
59
|
{ name: 'agents', args: '', gloss: 'List the on-watch agent roster', group: 'Workforce dispatch' },
|
|
60
|
+
{ name: 'delegate', args: '<slug> <brief>', gloss: 'Dispatch a brief to one Tier 1 specialist (α7.5)', group: 'Workforce dispatch' },
|
|
55
61
|
{ name: 'stop', args: '<persona>', gloss: 'Stop one agent by persona slug', group: 'Workforce dispatch' },
|
|
56
62
|
{ name: 'jobs', args: '', gloss: 'List background jobs', group: 'Workforce dispatch' },
|
|
57
63
|
{ name: 'ask', args: '<question>', gloss: 'Surface a yes/no modal locally (α6.3 forcing question)', group: 'Workforce dispatch' },
|
|
@@ -59,23 +65,27 @@ export const SLASH_COMMAND_HELP = Object.freeze([
|
|
|
59
65
|
{ name: 'clear', args: '', gloss: 'Clear conversation pane', group: 'Session' },
|
|
60
66
|
{ name: 'resume', args: '', gloss: 'Pick a stored session to restore', group: 'Session' },
|
|
61
67
|
{ name: 'context', args: '', gloss: 'Show three-tier context summary (Tier 0 skeleton + Tier 1 working set)', group: 'Session' },
|
|
62
|
-
{ name: 'compact', args: '', gloss: '
|
|
68
|
+
{ name: 'compact', args: '', gloss: 'Summarise older turns into a boundary marker (leak L8)', group: 'Session' },
|
|
63
69
|
{ name: 'memory', args: '', gloss: 'Session memory editor (α6.5b)', group: 'Session', stub: true },
|
|
70
|
+
{ name: 'init', args: '', gloss: 'Scaffold .pugi/ in the current workspace (β1 Sl11)', group: 'Session' },
|
|
64
71
|
// Pugi tools
|
|
65
72
|
{ name: 'web', args: '<url>', gloss: 'Fetch a URL into context', group: 'Pugi tools' },
|
|
66
73
|
{ name: 'diff', args: '', gloss: 'Show pending diff', group: 'Pugi tools' },
|
|
67
|
-
{ name: 'cost', args: '', gloss: '
|
|
68
|
-
{ name: '
|
|
74
|
+
{ name: 'cost', args: '', gloss: 'Session token + USD totals + last 5 turn breakdown', group: 'Pugi tools' },
|
|
75
|
+
{ name: 'quota', args: '', gloss: 'Plan tier + monthly usage caps (sync / review / engine)', group: 'Pugi tools' },
|
|
76
|
+
{ name: 'status', args: '', gloss: 'Session snapshot — id · cwd · mode · tokens · dispatches · auth', group: 'Pugi tools' },
|
|
69
77
|
{ name: 'consensus', args: '[ref]', gloss: '3-model consensus review (codex · claude · deepseek)', group: 'Pugi tools' },
|
|
70
78
|
// Settings
|
|
71
79
|
{ name: 'config', args: '', gloss: 'Show config', group: 'Settings', stub: true },
|
|
72
80
|
{ name: 'privacy', args: '', gloss: 'Show privacy mode + contract', group: 'Settings' },
|
|
81
|
+
{ name: 'permissions', args: '[mode] [--persist]', gloss: 'Show or flip permission mode (plan / ask / allow / bypass)', group: 'Settings' },
|
|
73
82
|
{ name: 'budget', args: '', gloss: 'Show usage budget', group: 'Settings', stub: true },
|
|
74
|
-
{ name: 'mcp', args: '', gloss: '
|
|
83
|
+
{ name: 'mcp', args: '[sub]', gloss: 'MCP servers — list / trust / deny / install / serve / perms', group: 'Settings' },
|
|
75
84
|
{ name: 'undo', args: '', gloss: 'Undo last write', group: 'Settings', stub: true },
|
|
76
85
|
// Meta
|
|
77
86
|
{ name: 'help', args: '', gloss: 'Show this help overlay', group: 'Meta' },
|
|
78
87
|
{ name: 'version', args: '', gloss: 'Show CLI version', group: 'Meta' },
|
|
88
|
+
{ name: 'doctor', args: '', gloss: 'Environment health report (auth · API · Node · disk · MCP · …)', group: 'Meta' },
|
|
79
89
|
{ name: 'quit', args: '', gloss: 'Exit the REPL', group: 'Meta' },
|
|
80
90
|
]);
|
|
81
91
|
/**
|
|
@@ -135,6 +145,38 @@ export function parseSlashCommand(input) {
|
|
|
135
145
|
case 'roster': {
|
|
136
146
|
return { kind: 'roster' };
|
|
137
147
|
}
|
|
148
|
+
case 'delegate': {
|
|
149
|
+
// tail must start with the persona slug followed by the brief.
|
|
150
|
+
// Slug accepts only the closed-set lowercase ASCII pattern the
|
|
151
|
+
// server-side persona registry enforces; anything else surfaces
|
|
152
|
+
// as a usage error so the operator sees the typo before the
|
|
153
|
+
// round-trip.
|
|
154
|
+
const innerSpace = tail.indexOf(' ');
|
|
155
|
+
if (innerSpace === -1 || innerSpace === 0) {
|
|
156
|
+
return {
|
|
157
|
+
kind: 'error',
|
|
158
|
+
message: 'Usage: /delegate <slug> <one-sentence brief>',
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
const persona = tail.slice(0, innerSpace).toLowerCase();
|
|
162
|
+
const brief = tail.slice(innerSpace + 1).trim();
|
|
163
|
+
// Pattern intentionally mirrors server-side PUGI_DELEGATE_REGEX in
|
|
164
|
+
// sessions.controller.ts (^[a-z]+$). Keeping them lockstep means
|
|
165
|
+
// the REPL surfaces typos locally instead of round-tripping a 4xx.
|
|
166
|
+
if (!/^[a-z]+$/.test(persona)) {
|
|
167
|
+
return {
|
|
168
|
+
kind: 'error',
|
|
169
|
+
message: `/delegate slug must be lowercase ASCII (a-z only); got '${persona}'`,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
if (brief.length === 0) {
|
|
173
|
+
return {
|
|
174
|
+
kind: 'error',
|
|
175
|
+
message: 'Usage: /delegate <slug> <one-sentence brief>',
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
return { kind: 'delegate', persona, brief };
|
|
179
|
+
}
|
|
138
180
|
case 'stop':
|
|
139
181
|
case 'kill': {
|
|
140
182
|
if (tail.length === 0) {
|
|
@@ -181,9 +223,19 @@ export function parseSlashCommand(input) {
|
|
|
181
223
|
case 'diff': {
|
|
182
224
|
return { kind: 'diff' };
|
|
183
225
|
}
|
|
184
|
-
case 'cost':
|
|
226
|
+
case 'cost':
|
|
227
|
+
case 'usage': {
|
|
228
|
+
// L19 (2026-05-27): `/usage` is an alias of `/cost` per the cost-
|
|
229
|
+
// command spec. The previous mapping routed `/usage` to the
|
|
230
|
+
// network-backed `/quota` surface, but operators trained on Claude
|
|
231
|
+
// Code expect `/usage` to surface the per-model token breakdown
|
|
232
|
+
// (same shape as `/cost`). `/quota` remains the canonical name
|
|
233
|
+
// for the tier + monthly-cap fetch.
|
|
185
234
|
return { kind: 'cost' };
|
|
186
235
|
}
|
|
236
|
+
case 'quota': {
|
|
237
|
+
return { kind: 'quota' };
|
|
238
|
+
}
|
|
187
239
|
case 'status': {
|
|
188
240
|
return { kind: 'status' };
|
|
189
241
|
}
|
|
@@ -215,11 +267,83 @@ export function parseSlashCommand(input) {
|
|
|
215
267
|
// device flow + audit identity are wired correctly).
|
|
216
268
|
return { kind: 'privacy' };
|
|
217
269
|
}
|
|
218
|
-
case '
|
|
270
|
+
case 'permissions':
|
|
271
|
+
case 'perms': {
|
|
272
|
+
// Leak L6: `/permissions [mode] [--persist] [--confirm]`.
|
|
273
|
+
//
|
|
274
|
+
// Argument grammar (single line, no quoting):
|
|
275
|
+
// /permissions -> show current mode + table
|
|
276
|
+
// /permissions plan|ask|allow -> flip mode
|
|
277
|
+
// /permissions bypass --confirm -> flip to bypass (refused
|
|
278
|
+
// without --confirm — safety)
|
|
279
|
+
// /permissions <mode> --persist -> also write to ~/.pugi/config.json
|
|
280
|
+
//
|
|
281
|
+
// Anything else returns an `error` result so the runtime can
|
|
282
|
+
// render the usage hint inline.
|
|
283
|
+
const tokens = tail.length === 0 ? [] : tail.split(/\s+/).filter((s) => s.length > 0);
|
|
284
|
+
if (tokens.length === 0) {
|
|
285
|
+
return { kind: 'permissions', persist: false, confirmBypass: false };
|
|
286
|
+
}
|
|
287
|
+
const head0 = tokens[0]?.toLowerCase();
|
|
288
|
+
if (head0 !== 'plan' && head0 !== 'ask' && head0 !== 'allow' && head0 !== 'bypass') {
|
|
289
|
+
return {
|
|
290
|
+
kind: 'error',
|
|
291
|
+
message: `Usage: /permissions [plan|ask|allow|bypass] [--persist] [--confirm]; unknown mode '${tokens[0] ?? ''}'`,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
const flags = tokens.slice(1);
|
|
295
|
+
let persist = false;
|
|
296
|
+
let confirmBypass = false;
|
|
297
|
+
for (const flag of flags) {
|
|
298
|
+
if (flag === '--persist') {
|
|
299
|
+
persist = true;
|
|
300
|
+
}
|
|
301
|
+
else if (flag === '--confirm') {
|
|
302
|
+
confirmBypass = true;
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
return {
|
|
306
|
+
kind: 'error',
|
|
307
|
+
message: `/permissions: unknown flag '${flag}' (allowed: --persist, --confirm)`,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return { kind: 'permissions', mode: head0, persist, confirmBypass };
|
|
312
|
+
}
|
|
313
|
+
case 'init': {
|
|
314
|
+
// β1 Sl11: surface the init flow inside the REPL. Tail args
|
|
315
|
+
// are ignored — the init handler is parameterless today; `pugi
|
|
316
|
+
// init --no-defaults` is the CLI surface for skipping bundled
|
|
317
|
+
// skills.
|
|
318
|
+
return { kind: 'init' };
|
|
319
|
+
}
|
|
320
|
+
case 'mcp': {
|
|
321
|
+
// β4 Sl7: tokenize the tail. Empty tail -> `list` (matches CLI).
|
|
322
|
+
// Quoting / shell-escapes are NOT supported — the slash surface is
|
|
323
|
+
// intentionally simple; complex installs (env vars, multi-word
|
|
324
|
+
// args) go through `pugi mcp install` from a fresh shell.
|
|
325
|
+
const tokens = tail.length === 0 ? [] : tail.split(/\s+/).filter((s) => s.length > 0);
|
|
326
|
+
return { kind: 'mcp', args: tokens };
|
|
327
|
+
}
|
|
328
|
+
case 'doctor':
|
|
329
|
+
case 'health': {
|
|
330
|
+
// L17 (2026-05-27): run the probe sweep inline. Tail is ignored —
|
|
331
|
+
// the doctor command has no operator-facing arguments (every
|
|
332
|
+
// probe runs unconditionally; per-probe disable lives on the CLI
|
|
333
|
+
// shell surface, not the slash one).
|
|
334
|
+
return { kind: 'doctor' };
|
|
335
|
+
}
|
|
336
|
+
case 'compact': {
|
|
337
|
+
// Leak L8 (2026-05-27): graduated from stub. The session module
|
|
338
|
+
// owns the summariser round-trip; tail args are ignored today
|
|
339
|
+
// because the surface is parameterless. Operators wanting a
|
|
340
|
+
// per-session compact run `pugi compact --session <id>` from a
|
|
341
|
+
// fresh shell.
|
|
342
|
+
return { kind: 'compact' };
|
|
343
|
+
}
|
|
219
344
|
case 'memory':
|
|
220
345
|
case 'config':
|
|
221
346
|
case 'budget':
|
|
222
|
-
case 'mcp':
|
|
223
347
|
case 'undo': {
|
|
224
348
|
const stubName = name;
|
|
225
349
|
return {
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Leak L31 — Per-command tool retry budget (Claude Code parity).
|
|
3
|
+
*
|
|
4
|
+
* Claude Code limits the number of times the model may retry the SAME
|
|
5
|
+
* tool with the SAME arguments inside a single operator-input cycle.
|
|
6
|
+
* Once the cap is hit, the dispatcher hard-refuses and surfaces a
|
|
7
|
+
* sentinel string telling the model that this exact call has exhausted
|
|
8
|
+
* its retry budget. The model is expected (via system-prompt rule) to
|
|
9
|
+
* either change approach or ask the operator for guidance instead of
|
|
10
|
+
* looping forever on a transient failure.
|
|
11
|
+
*
|
|
12
|
+
* Why per-cycle, not per-session: a retry budget that persists across
|
|
13
|
+
* operator turns would surprise the operator. After the operator says
|
|
14
|
+
* "try again" the model rightly retries; the budget must reset when a
|
|
15
|
+
* fresh brief arrives. The simplest reset boundary is the executor
|
|
16
|
+
* lifetime — `buildExecutor` is called once per `runEngineLoop` and
|
|
17
|
+
* the loop drives exactly one operator-input cycle. Constructing the
|
|
18
|
+
* budget inside `buildExecutor` therefore gives us per-cycle scoping
|
|
19
|
+
* "for free" via closure lifetime; no external clear() call is needed
|
|
20
|
+
* from production callsites. The exported `clear()` exists so tests
|
|
21
|
+
* and a future hook surface (PreToolUse) can introspect the state.
|
|
22
|
+
*
|
|
23
|
+
* Hash design: same tool + same canonical args = same bucket. We
|
|
24
|
+
* canonicalise the args record by sorting object keys (stable across
|
|
25
|
+
* model output ordering) and then sha256 the JSON. The model emits
|
|
26
|
+
* `arguments` as a raw JSON string; we parse, canonicalise, hash. If
|
|
27
|
+
* parse fails we hash the raw string verbatim — that way an
|
|
28
|
+
* unparseable repeat still counts toward the cap (otherwise the model
|
|
29
|
+
* could loop on syntactic noise variants forever).
|
|
30
|
+
*
|
|
31
|
+
* Env overrides:
|
|
32
|
+
* PUGI_RETRY_BUDGET_<TOOLNAME>=<N> — override a single tool's cap.
|
|
33
|
+
* Toolname matches DEFAULT_CAPS
|
|
34
|
+
* keys verbatim, uppercased
|
|
35
|
+
* (PUGI_RETRY_BUDGET_BASH=8).
|
|
36
|
+
* PUGI_RETRY_BUDGET_DEFAULT=<N> — override the fallback cap for
|
|
37
|
+
* any tool not in DEFAULT_CAPS.
|
|
38
|
+
* PUGI_RETRY_BUDGET_DISABLED=1 — warn-only mode. `shouldAllow`
|
|
39
|
+
* still records but always
|
|
40
|
+
* returns `allowed: true`. The
|
|
41
|
+
* count is preserved so
|
|
42
|
+
* diagnostics can still surface
|
|
43
|
+
* the pattern.
|
|
44
|
+
*/
|
|
45
|
+
import { createHash } from 'node:crypto';
|
|
46
|
+
/**
|
|
47
|
+
* Default per-tool retry caps. Tuned per leak research:
|
|
48
|
+
*
|
|
49
|
+
* bash — 5 (most volatile; transient flakes common)
|
|
50
|
+
* edit — 3 (deterministic; repeat = real bug)
|
|
51
|
+
* write — 3 (same)
|
|
52
|
+
* read — 10 (cheap; legitimate re-reads after edits)
|
|
53
|
+
* search/grep/glob — 10 (cheap; exploration loop)
|
|
54
|
+
* web_fetch — 5 (transient network; not infinite)
|
|
55
|
+
* default — 5 (any tool not in the table)
|
|
56
|
+
*
|
|
57
|
+
* Operators override per-tool via `PUGI_RETRY_BUDGET_<NAME>` env vars.
|
|
58
|
+
* Caps are bounded `[1, 1000]` after override to defend against typo
|
|
59
|
+
* runaway (e.g. `PUGI_RETRY_BUDGET_BASH=5000000`).
|
|
60
|
+
*/
|
|
61
|
+
export const DEFAULT_CAPS = Object.freeze({
|
|
62
|
+
bash: 5,
|
|
63
|
+
edit: 3,
|
|
64
|
+
write: 3,
|
|
65
|
+
read: 10,
|
|
66
|
+
search: 10,
|
|
67
|
+
grep: 10,
|
|
68
|
+
glob: 10,
|
|
69
|
+
web_fetch: 5,
|
|
70
|
+
default: 5,
|
|
71
|
+
});
|
|
72
|
+
/**
|
|
73
|
+
* Lower / upper bound for any resolved cap. Defends against:
|
|
74
|
+
* - PUGI_RETRY_BUDGET_BASH=0 -> first call instantly denied
|
|
75
|
+
* - PUGI_RETRY_BUDGET_BASH=99999 -> effectively unbounded loop
|
|
76
|
+
*/
|
|
77
|
+
export const MIN_CAP = 1;
|
|
78
|
+
export const MAX_CAP = 1000;
|
|
79
|
+
/**
|
|
80
|
+
* Per-cycle retry budget. One instance per `buildExecutor` call.
|
|
81
|
+
*
|
|
82
|
+
* Not thread-safe: the executor is single-threaded by construction
|
|
83
|
+
* (Node event loop + sequential await in dispatcher). If a future
|
|
84
|
+
* executor parallelises tool dispatch it must serialise the budget
|
|
85
|
+
* mutation explicitly.
|
|
86
|
+
*/
|
|
87
|
+
export class RetryBudget {
|
|
88
|
+
counts = new Map();
|
|
89
|
+
capCache = new Map();
|
|
90
|
+
env;
|
|
91
|
+
programmaticCaps;
|
|
92
|
+
constructor(options = {}) {
|
|
93
|
+
this.env = options.env ?? process.env;
|
|
94
|
+
this.programmaticCaps = options.caps ?? {};
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Returns true when PUGI_RETRY_BUDGET_DISABLED=1. In disabled mode
|
|
98
|
+
* `shouldAllow` still records attempts but always allows the
|
|
99
|
+
* dispatch — useful for operators triaging a false-positive without
|
|
100
|
+
* a code change.
|
|
101
|
+
*/
|
|
102
|
+
isDisabled() {
|
|
103
|
+
return this.env.PUGI_RETRY_BUDGET_DISABLED === '1';
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Record one dispatch attempt. Idempotent on the bucket key (tool
|
|
107
|
+
* + argHash). Call this BEFORE the dispatch (or after `shouldAllow`
|
|
108
|
+
* but before `dispatch()` resolves) so a thrown dispatch counts.
|
|
109
|
+
*/
|
|
110
|
+
recordAttempt(toolName, argHash) {
|
|
111
|
+
const key = `${toolName}::${argHash}`;
|
|
112
|
+
const next = (this.counts.get(key) ?? 0) + 1;
|
|
113
|
+
this.counts.set(key, next);
|
|
114
|
+
return next;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Returns the current count for (tool, argHash) WITHOUT mutating.
|
|
118
|
+
*/
|
|
119
|
+
peek(toolName, argHash) {
|
|
120
|
+
return this.counts.get(`${toolName}::${argHash}`) ?? 0;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Resolve the effective cap for a tool.
|
|
124
|
+
*
|
|
125
|
+
* Precedence:
|
|
126
|
+
* 1. PUGI_RETRY_BUDGET_<TOOL_UPPER>=<N> (env)
|
|
127
|
+
* 2. programmaticCaps[toolName] (constructor)
|
|
128
|
+
* 3. DEFAULT_CAPS[toolName] (this module)
|
|
129
|
+
* 4. PUGI_RETRY_BUDGET_DEFAULT=<N> (env fallback)
|
|
130
|
+
* 5. DEFAULT_CAPS.default (final fallback)
|
|
131
|
+
*
|
|
132
|
+
* Bounded by [MIN_CAP, MAX_CAP] post-resolution. Invalid (NaN, ≤0,
|
|
133
|
+
* non-integer) env values are ignored and the next layer wins.
|
|
134
|
+
*/
|
|
135
|
+
capFor(toolName) {
|
|
136
|
+
const cached = this.capCache.get(toolName);
|
|
137
|
+
if (cached !== undefined)
|
|
138
|
+
return cached;
|
|
139
|
+
const envKey = `PUGI_RETRY_BUDGET_${toolName.toUpperCase()}`;
|
|
140
|
+
const envCap = parseCapEnv(this.env[envKey]);
|
|
141
|
+
const programmaticCap = this.programmaticCaps[toolName];
|
|
142
|
+
const defaultCap = DEFAULT_CAPS[toolName];
|
|
143
|
+
const fallbackEnvCap = parseCapEnv(this.env.PUGI_RETRY_BUDGET_DEFAULT);
|
|
144
|
+
// DEFAULT_CAPS.default is hard-coded above; cast keeps the type-
|
|
145
|
+
// narrower happy without leaking `| undefined` through the index
|
|
146
|
+
// access (tsc cannot prove the literal key exists).
|
|
147
|
+
const finalFallback = DEFAULT_CAPS.default;
|
|
148
|
+
let resolved;
|
|
149
|
+
if (envCap !== undefined) {
|
|
150
|
+
resolved = envCap;
|
|
151
|
+
}
|
|
152
|
+
else if (programmaticCap !== undefined) {
|
|
153
|
+
resolved = programmaticCap;
|
|
154
|
+
}
|
|
155
|
+
else if (defaultCap !== undefined) {
|
|
156
|
+
resolved = defaultCap;
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
resolved = fallbackEnvCap ?? finalFallback;
|
|
160
|
+
}
|
|
161
|
+
const bounded = Math.min(MAX_CAP, Math.max(MIN_CAP, resolved));
|
|
162
|
+
this.capCache.set(toolName, bounded);
|
|
163
|
+
return bounded;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Should this dispatch be allowed? Caller passes the current count
|
|
167
|
+
* BEFORE recording — i.e. shouldAllow returns true when count < cap,
|
|
168
|
+
* then recordAttempt fires, bringing count up to cap. The next
|
|
169
|
+
* identical call sees count === cap and is refused.
|
|
170
|
+
*
|
|
171
|
+
* In disabled mode `allowed` is forced to true; `count` and `cap`
|
|
172
|
+
* still reflect reality so logs / diagnostics can spot the pattern.
|
|
173
|
+
*/
|
|
174
|
+
shouldAllow(toolName, argHash) {
|
|
175
|
+
const cap = this.capFor(toolName);
|
|
176
|
+
const count = this.peek(toolName, argHash);
|
|
177
|
+
const disabled = this.isDisabled();
|
|
178
|
+
const allowed = disabled ? true : count < cap;
|
|
179
|
+
return { allowed, count, cap, argHash, disabled };
|
|
180
|
+
}
|
|
181
|
+
/** Reset all state. Used between operator-input cycles when the
|
|
182
|
+
* budget instance is reused (most callers throw the instance away
|
|
183
|
+
* per cycle, so clear() is mostly for tests and hook surfaces). */
|
|
184
|
+
clear() {
|
|
185
|
+
this.counts.clear();
|
|
186
|
+
this.capCache.clear();
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Snapshot the current state for diagnostics. Returns a plain
|
|
190
|
+
* object so it round-trips through JSON.stringify cleanly.
|
|
191
|
+
*/
|
|
192
|
+
snapshot() {
|
|
193
|
+
const out = [];
|
|
194
|
+
for (const [key, count] of this.counts) {
|
|
195
|
+
const sep = key.indexOf('::');
|
|
196
|
+
if (sep < 0)
|
|
197
|
+
continue;
|
|
198
|
+
out.push({ tool: key.slice(0, sep), argHash: key.slice(sep + 2), count });
|
|
199
|
+
}
|
|
200
|
+
return out;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Hash the model's tool-call arguments into a stable key. Same
|
|
205
|
+
* canonical args = same hash regardless of JSON whitespace / key
|
|
206
|
+
* order. Unparseable JSON is hashed verbatim so the budget still
|
|
207
|
+
* catches syntactically degenerate retry loops.
|
|
208
|
+
*/
|
|
209
|
+
export function hashArgs(argsRaw) {
|
|
210
|
+
const canonical = canonicalise(argsRaw);
|
|
211
|
+
return createHash('sha256').update(canonical).digest('hex');
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Canonicalise a raw JSON arg string. Object keys are sorted
|
|
215
|
+
* recursively. Arrays preserve order (semantic). Primitives untouched.
|
|
216
|
+
* On parse failure, returns the original string prefixed with `raw:`
|
|
217
|
+
* so a malformed-args repeat still hashes to the same bucket.
|
|
218
|
+
*/
|
|
219
|
+
function canonicalise(argsRaw) {
|
|
220
|
+
try {
|
|
221
|
+
const parsed = JSON.parse(argsRaw);
|
|
222
|
+
return JSON.stringify(sortKeys(parsed));
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
return `raw:${argsRaw}`;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
function sortKeys(value) {
|
|
229
|
+
if (value === null || typeof value !== 'object')
|
|
230
|
+
return value;
|
|
231
|
+
if (Array.isArray(value))
|
|
232
|
+
return value.map(sortKeys);
|
|
233
|
+
const obj = value;
|
|
234
|
+
const sorted = {};
|
|
235
|
+
for (const k of Object.keys(obj).sort()) {
|
|
236
|
+
sorted[k] = sortKeys(obj[k]);
|
|
237
|
+
}
|
|
238
|
+
return sorted;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Parse and bound a `PUGI_RETRY_BUDGET_*` env var. Returns `undefined`
|
|
242
|
+
* for any non-positive-integer string so the resolver can fall
|
|
243
|
+
* through to the next precedence layer. Bounded by [MIN_CAP, MAX_CAP]
|
|
244
|
+
* is NOT applied here — `capFor` clamps after the final layer wins,
|
|
245
|
+
* matching the "operator typo defends against runaway" requirement
|
|
246
|
+
* without silently swallowing a meaningful low value (e.g.
|
|
247
|
+
* `PUGI_RETRY_BUDGET_BASH=1` should clamp to MIN_CAP=1, which it
|
|
248
|
+
* does naturally since 1 >= MIN_CAP).
|
|
249
|
+
*/
|
|
250
|
+
function parseCapEnv(raw) {
|
|
251
|
+
if (raw === undefined || raw === '')
|
|
252
|
+
return undefined;
|
|
253
|
+
const n = Number(raw);
|
|
254
|
+
if (!Number.isInteger(n) || n <= 0)
|
|
255
|
+
return undefined;
|
|
256
|
+
return n;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Sentinel emitted to the model when the budget is exhausted. The
|
|
260
|
+
* format is stable so the engine adapter, spec layer, and operator
|
|
261
|
+
* dashboards can pattern-match on it.
|
|
262
|
+
*/
|
|
263
|
+
export function retryBudgetExhaustedSentinel(toolName, cap) {
|
|
264
|
+
return `RETRY_BUDGET_EXHAUSTED: ${toolName} exceeded ${cap} attempts with these args. Operator must intervene.`;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Typed error thrown by the tool-bridge when the cap is hit. Carries
|
|
268
|
+
* the sentinel string so the engine loop can pattern-match without
|
|
269
|
+
* re-parsing. `instanceof RetryBudgetExhausted` is the canonical
|
|
270
|
+
* downstream test.
|
|
271
|
+
*/
|
|
272
|
+
export class RetryBudgetExhausted extends Error {
|
|
273
|
+
toolName;
|
|
274
|
+
cap;
|
|
275
|
+
argHash;
|
|
276
|
+
constructor(toolName, cap, argHash) {
|
|
277
|
+
super(retryBudgetExhaustedSentinel(toolName, cap));
|
|
278
|
+
this.name = 'RetryBudgetExhausted';
|
|
279
|
+
this.toolName = toolName;
|
|
280
|
+
this.cap = cap;
|
|
281
|
+
this.argHash = argHash;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
//# sourceMappingURL=budget.js.map
|
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,60 @@ 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(),
|
|
91
|
+
})
|
|
92
|
+
.optional(),
|
|
93
|
+
// β1 Pl9 (#74) — per-command budget overrides. Optional. Partial
|
|
94
|
+
// overrides merge against the β1 defaults in
|
|
95
|
+
// `core/engine/budgets.ts::beta1DefaultBudgets`. The schema is
|
|
96
|
+
// intentionally loose at the leaf (positive integers) so a typo lands
|
|
97
|
+
// a deterministic `BudgetConfigError` at `resolveBudget()` instead of
|
|
98
|
+
// a Zod parse error two layers up.
|
|
99
|
+
budgets: z
|
|
100
|
+
.object({
|
|
101
|
+
code: z
|
|
102
|
+
.object({ maxTokens: z.number().int().positive().optional(), maxToolCalls: z.number().int().positive().optional() })
|
|
103
|
+
.optional(),
|
|
104
|
+
fix: z
|
|
105
|
+
.object({ maxTokens: z.number().int().positive().optional(), maxToolCalls: z.number().int().positive().optional() })
|
|
106
|
+
.optional(),
|
|
107
|
+
build: z
|
|
108
|
+
.object({ maxTokens: z.number().int().positive().optional(), maxToolCalls: z.number().int().positive().optional() })
|
|
109
|
+
.optional(),
|
|
110
|
+
plan: z
|
|
111
|
+
.object({ maxTokens: z.number().int().positive().optional(), maxToolCalls: z.number().int().positive().optional() })
|
|
112
|
+
.optional(),
|
|
113
|
+
explain: z
|
|
114
|
+
.object({ maxTokens: z.number().int().positive().optional(), maxToolCalls: z.number().int().positive().optional() })
|
|
115
|
+
.optional(),
|
|
116
|
+
review_triple: z
|
|
117
|
+
.object({ maxTokens: z.number().int().positive().optional(), maxToolCalls: z.number().int().positive().optional() })
|
|
118
|
+
.optional(),
|
|
48
119
|
})
|
|
49
120
|
.optional(),
|
|
50
121
|
});
|