@pugi/cli 0.1.0-beta.11 → 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/repl-render.js +109 -1
- package/dist/tui/repl.js +7 -1
- package/dist/tui/status-bar.js +94 -16
- package/dist/tui/update-banner.js +20 -2
- package/package.json +5 -4
|
@@ -34,6 +34,50 @@ const COMMON_LOCAL_FIRST_PREAMBLE = [
|
|
|
34
34
|
'Cite file paths relative to the workspace root. Keep edits minimal and reversible.',
|
|
35
35
|
'When you are done, return a single final text answer that the operator can read on the CLI.',
|
|
36
36
|
].join(' ');
|
|
37
|
+
/**
|
|
38
|
+
* β5a P1+P6 (2026-05-26): prompt v2 — intent marker contract +
|
|
39
|
+
* definitional examples + jargon ban. Fixes the dominant Pugi loss
|
|
40
|
+
* mode in the α7.X Phase 2 comparative eval: tool-use on pure
|
|
41
|
+
* knowledge questions ("What is grep?" → bash man grep).
|
|
42
|
+
*
|
|
43
|
+
* The CLI-side `classifyIntent` (apps/pugi-cli/src/core/engine/intent.ts)
|
|
44
|
+
* wraps definitional questions with `<intent kind="definitional">` on
|
|
45
|
+
* the user message before send. The rules below teach the model what
|
|
46
|
+
* to do with that marker.
|
|
47
|
+
*
|
|
48
|
+
* Voice constraint: same banned-jargon list as the cabinet Mira
|
|
49
|
+
* persona (брифую / диспатчу / шипаю and the English jargon list
|
|
50
|
+
* from BANNED_WORDS in mira.system-prompt.ts). Repeated here verbatim
|
|
51
|
+
* so the CLI surface has its own enforcement copy; the cabinet copy
|
|
52
|
+
* is the source of truth and ships through the runtime persona
|
|
53
|
+
* prompt for the cabinet UI. CLI runs DO NOT load the cabinet
|
|
54
|
+
* persona prompt — engine prompts are the only place to enforce
|
|
55
|
+
* voice for `pugi explain` / `pugi code` callers.
|
|
56
|
+
*/
|
|
57
|
+
const INTENT_MARKER_CONTRACT = [
|
|
58
|
+
'# Intent contract',
|
|
59
|
+
'When the operator\'s message starts with `<intent kind="definitional">`, treat it as a knowledge question:',
|
|
60
|
+
' - Answer in prose. Do NOT call any tools.',
|
|
61
|
+
' - Cite a file from the repo only when it directly supports the explanation.',
|
|
62
|
+
' - Keep the answer to one short paragraph unless the operator asked for depth.',
|
|
63
|
+
'When no intent marker is present OR the marker is operational, use tools as needed.',
|
|
64
|
+
'',
|
|
65
|
+
'# Definitional examples',
|
|
66
|
+
'Operator: `<intent kind="definitional">What is grep?</intent>`',
|
|
67
|
+
'You: `grep is a Unix command-line tool that searches plain text for lines matching a regular expression. It reads stdin or files and prints matching lines to stdout.`',
|
|
68
|
+
'(No tool calls. One paragraph. No file paths because grep is a generic Unix tool, not a repo artefact.)',
|
|
69
|
+
'',
|
|
70
|
+
'Operator: `<intent kind="definitional">что такое pgvector?</intent>`',
|
|
71
|
+
'You: `pgvector - это расширение PostgreSQL для хранения и поиска векторных эмбеддингов. Поддерживает ivfflat и hnsw индексы; используется для RAG и semantic search.`',
|
|
72
|
+
'(One short paragraph. No tools.)',
|
|
73
|
+
].join('\n');
|
|
74
|
+
const JARGON_BAN = [
|
|
75
|
+
'# Voice',
|
|
76
|
+
'Brand voice: terse, operator-grade, English in code paths / Russian-Ukrainian permitted in chat answers.',
|
|
77
|
+
'Banned words (CLI customer-facing output): "брифую", "диспатчу", "шипаю", "journey", "explore", "delight", "magical", "friendly", "let me help", "I\'d be happy to", "pug-tastic". Use neutral verbs instead: brief / dispatch / ship / build / read / write.',
|
|
78
|
+
'No em-dashes. No emoji. No "AI assistant" framing.',
|
|
79
|
+
].join('\n');
|
|
80
|
+
const PROMPT_V2_APPENDIX = [INTENT_MARKER_CONTRACT, JARGON_BAN].join('\n\n');
|
|
37
81
|
const PLAN_TOOLS_NOTE = 'PLAN MODE IS READ-ONLY. You may call read, grep, glob. Calls to write, edit, or bash will be refused and end the run. Produce a written plan, not changes.';
|
|
38
82
|
const EDIT_FLOW_RULES = [
|
|
39
83
|
'Before calling edit on a file you have not yet read this session, call read first — the edit tool fails otherwise.',
|
|
@@ -42,10 +86,16 @@ const EDIT_FLOW_RULES = [
|
|
|
42
86
|
].join(' ');
|
|
43
87
|
export function systemPromptFor(kind) {
|
|
44
88
|
const base = baseSystemPromptFor(kind);
|
|
89
|
+
// β5a P1+P6: append the prompt-v2 intent-marker contract +
|
|
90
|
+
// definitional examples + jargon ban to every command kind. The
|
|
91
|
+
// appendix is shared across kinds because the marker contract +
|
|
92
|
+
// voice gate are command-agnostic — a definitional question lands
|
|
93
|
+
// the same way under `pugi explain` and `pugi code`.
|
|
94
|
+
const withV2 = `${base}\n\n${PROMPT_V2_APPENDIX}`;
|
|
45
95
|
const snapshot = formatBackgroundJobsSnapshot(getJobRegistrySafely());
|
|
46
96
|
if (!snapshot)
|
|
47
|
-
return
|
|
48
|
-
return `${
|
|
97
|
+
return withV2;
|
|
98
|
+
return `${withV2}\n\n${snapshot}`;
|
|
49
99
|
}
|
|
50
100
|
function baseSystemPromptFor(kind) {
|
|
51
101
|
switch (kind) {
|
|
@@ -4,6 +4,10 @@ import { askUser } from '../../tools/ask-user.js';
|
|
|
4
4
|
import { skillInvoke, skillList } from '../../tools/skill-tool.js';
|
|
5
5
|
import { taskCreate, taskGet, taskList, taskUpdate, } from '../../tools/tasks.js';
|
|
6
6
|
import { webFetchTool } from '../../tools/web-fetch.js';
|
|
7
|
+
import { webSearchTool } from '../../tools/web-search.js';
|
|
8
|
+
import { agentTool } from '../../tools/agent-tool.js';
|
|
9
|
+
import { multiEdit } from '../../tools/multi-edit.js';
|
|
10
|
+
import { buildMcpToolDefs, defaultNonInteractiveMcpPrompt, dispatchMcpTool, MCP_TOOL_PREFIX, } from '../../tools/mcp-tool.js';
|
|
7
11
|
/**
|
|
8
12
|
* Tool-bridge: turns the abstract tool registry into:
|
|
9
13
|
* 1. An OpenAI-shaped tools schema for `EngineLoopClient.send`.
|
|
@@ -45,6 +49,12 @@ const READ_ONLY_TOOLS = new Set([
|
|
|
45
49
|
'task_list',
|
|
46
50
|
'task_update',
|
|
47
51
|
'web_fetch',
|
|
52
|
+
// β1b T4 (2026-05-26): web_search is read-only from the workspace's
|
|
53
|
+
// perspective (no file writes, no shell). Egress goes through the
|
|
54
|
+
// Anvil-proxied Brave Search API, gated by the same opt-in posture as
|
|
55
|
+
// web_fetch. Plan mode keeps the tool available because reading the
|
|
56
|
+
// web is part of how a plan is researched.
|
|
57
|
+
'web_search',
|
|
48
58
|
]);
|
|
49
59
|
/**
|
|
50
60
|
* Tools the engine loop dispatches. β1 expands the M1 cornerstone six
|
|
@@ -68,9 +78,25 @@ const WIRED_TOOLS = new Set([
|
|
|
68
78
|
'task_list',
|
|
69
79
|
'task_update',
|
|
70
80
|
'web_fetch',
|
|
81
|
+
// β1b T4: see READ_ONLY_TOOLS above.
|
|
82
|
+
'web_search',
|
|
83
|
+
// β2 S3 (2026-05-26): real subagent spawn primitive. Only advertised
|
|
84
|
+
// when buildToolsSchema is called with allowAgent=true (orchestrator
|
|
85
|
+
// / root Mira context); plan-mode also excludes it because spawning
|
|
86
|
+
// a write-capable child violates plan-mode's read-only contract.
|
|
87
|
+
'agent',
|
|
88
|
+
// β7 L5+T11 (2026-05-26): transactional multi-file edit. Routes
|
|
89
|
+
// through the same security gate as Layer A/B/C; not advertised in
|
|
90
|
+
// plan mode (mutation surface).
|
|
91
|
+
'multi_edit',
|
|
71
92
|
]);
|
|
72
|
-
export function buildToolsSchema(kind, options = { allowFetch: false }) {
|
|
93
|
+
export function buildToolsSchema(kind, options = { allowFetch: false, allowSearch: false }) {
|
|
73
94
|
const planMode = kind === 'plan';
|
|
95
|
+
// β4 M1/M3: splice MCP tools BEFORE the native list assembly so the
|
|
96
|
+
// engine-loop sees them in stable alphabetical order alongside native
|
|
97
|
+
// tools. We keep the entries appended after the native push so plan-
|
|
98
|
+
// mode can be filtered by namespace prefix in one place at the end.
|
|
99
|
+
const mcpDefs = buildMcpToolDefs(options.mcpRegistry);
|
|
74
100
|
const toolDefs = [
|
|
75
101
|
{
|
|
76
102
|
name: 'read',
|
|
@@ -224,6 +250,85 @@ export function buildToolsSchema(kind, options = { allowFetch: false }) {
|
|
|
224
250
|
},
|
|
225
251
|
});
|
|
226
252
|
}
|
|
253
|
+
// β1b T4 (2026-05-26): web_search advertisement. Same off-by-default
|
|
254
|
+
// privacy posture as web_fetch — the query string itself is an egress
|
|
255
|
+
// event that can leak operator intent to the upstream Brave Search
|
|
256
|
+
// backend. The tool dispatcher applies SSRF guards (no localhost via
|
|
257
|
+
// the Anvil proxy URL), rate-limits (5 req/min per session), and caps
|
|
258
|
+
// the result payload at 1 MiB. Sentinel-wrapped results so the model
|
|
259
|
+
// treats every snippet as data, not instructions.
|
|
260
|
+
if (options.allowSearch) {
|
|
261
|
+
toolDefs.push({
|
|
262
|
+
name: 'web_search',
|
|
263
|
+
description: 'Search the web via Brave Search (Anvil-proxied). Returns up to 10 sentinel-wrapped {title, url, snippet} results. Rate-limited to 5 calls/min per session. Gated off by default.',
|
|
264
|
+
parameters: {
|
|
265
|
+
type: 'object',
|
|
266
|
+
additionalProperties: false,
|
|
267
|
+
required: ['query'],
|
|
268
|
+
properties: {
|
|
269
|
+
query: {
|
|
270
|
+
type: 'string',
|
|
271
|
+
description: 'Search query, max 256 chars. Plain text — no operators.',
|
|
272
|
+
},
|
|
273
|
+
count: {
|
|
274
|
+
type: 'integer',
|
|
275
|
+
description: 'Optional result count (1..10, default 10).',
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
// β2 S3 (2026-05-26): `agent` tool — subagent spawn primitive.
|
|
282
|
+
// Off by default; surfaced only when the caller explicitly opts in
|
|
283
|
+
// (orchestrator parents pass allowAgent=true via the engine adapter).
|
|
284
|
+
// Plan mode FORCES the tool off regardless because a write-capable
|
|
285
|
+
// child would violate plan-mode's read-only contract.
|
|
286
|
+
if (options.allowAgent && !planMode) {
|
|
287
|
+
toolDefs.push({
|
|
288
|
+
name: 'agent',
|
|
289
|
+
description: 'Spawn a specialist subagent under a Cyber-Zoo brand persona. '
|
|
290
|
+
+ 'Role selects the persona + isolation tier: '
|
|
291
|
+
+ 'researcher/reviewer/architect are read-only, verifier reads + runs tests, '
|
|
292
|
+
+ 'coder/release/devops/design_qa get write + bash. '
|
|
293
|
+
+ 'The child runs a fresh Anvil engine loop with its own transcript and '
|
|
294
|
+
+ 'returns a JSON envelope (filesChanged, toolCallCount, status, summary). '
|
|
295
|
+
+ 'Use this when the work needs a specialist persona OR write isolation via a scratch worktree.',
|
|
296
|
+
parameters: {
|
|
297
|
+
type: 'object',
|
|
298
|
+
additionalProperties: false,
|
|
299
|
+
required: ['role', 'brief'],
|
|
300
|
+
properties: {
|
|
301
|
+
role: {
|
|
302
|
+
type: 'string',
|
|
303
|
+
enum: [
|
|
304
|
+
'orchestrator',
|
|
305
|
+
'architect',
|
|
306
|
+
'coder',
|
|
307
|
+
'verifier',
|
|
308
|
+
'reviewer',
|
|
309
|
+
'researcher',
|
|
310
|
+
'release',
|
|
311
|
+
'devops',
|
|
312
|
+
'design_qa',
|
|
313
|
+
],
|
|
314
|
+
description: 'SubagentRole — selects persona + isolation tier.',
|
|
315
|
+
},
|
|
316
|
+
brief: {
|
|
317
|
+
type: 'string',
|
|
318
|
+
maxLength: 8000,
|
|
319
|
+
description: 'One-paragraph task description forwarded to the child as the user prompt. '
|
|
320
|
+
+ 'Be concrete: include filenames, expected behavior, and acceptance criteria.',
|
|
321
|
+
},
|
|
322
|
+
isolation: {
|
|
323
|
+
type: 'string',
|
|
324
|
+
enum: ['worktree', 'shared_fs', 'auto'],
|
|
325
|
+
description: 'Optional override. `worktree` forces a scratch git worktree for write isolation; '
|
|
326
|
+
+ '`shared_fs` forces same-tree execution; `auto` (default) defers to the role tier.',
|
|
327
|
+
},
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
});
|
|
331
|
+
}
|
|
227
332
|
if (!planMode) {
|
|
228
333
|
toolDefs.push({
|
|
229
334
|
name: 'write',
|
|
@@ -261,8 +366,57 @@ export function buildToolsSchema(kind, options = { allowFetch: false }) {
|
|
|
261
366
|
command: { type: 'string', description: 'Single shell command to execute.' },
|
|
262
367
|
},
|
|
263
368
|
},
|
|
369
|
+
},
|
|
370
|
+
// β7 L5+T11 (2026-05-26): transactional multi-file edit. Either
|
|
371
|
+
// all entries land or none do — failures roll the workspace back
|
|
372
|
+
// via the same journal + snapshot machinery the dispatcher uses.
|
|
373
|
+
// Cap is 50 entries; beyond that the operator (or model) should
|
|
374
|
+
// split the refactor or use Layer C rewrites.
|
|
375
|
+
{
|
|
376
|
+
name: 'multi_edit',
|
|
377
|
+
description: 'Apply an ordered batch of single-occurrence file edits as one transaction. ' +
|
|
378
|
+
'Each entry is {file, oldString, newString} like the `edit` tool. Either every ' +
|
|
379
|
+
'edit lands or none do — a failure rolls the workspace back to the pre-dispatch ' +
|
|
380
|
+
'state via journal + snapshot. Cap 50 edits per call. Use this for coordinated ' +
|
|
381
|
+
'refactors (rename across files, add an import to many modules).',
|
|
382
|
+
parameters: {
|
|
383
|
+
type: 'object',
|
|
384
|
+
additionalProperties: false,
|
|
385
|
+
required: ['edits'],
|
|
386
|
+
properties: {
|
|
387
|
+
edits: {
|
|
388
|
+
type: 'array',
|
|
389
|
+
minItems: 1,
|
|
390
|
+
maxItems: 50,
|
|
391
|
+
items: {
|
|
392
|
+
type: 'object',
|
|
393
|
+
additionalProperties: false,
|
|
394
|
+
required: ['file', 'oldString', 'newString'],
|
|
395
|
+
properties: {
|
|
396
|
+
file: { type: 'string', description: 'Workspace-relative file path.' },
|
|
397
|
+
oldString: { type: 'string', description: 'Verbatim substring; must be unique in the pre-edit file.' },
|
|
398
|
+
newString: { type: 'string', description: 'Replacement string. Empty string means delete.' },
|
|
399
|
+
},
|
|
400
|
+
},
|
|
401
|
+
},
|
|
402
|
+
},
|
|
403
|
+
},
|
|
264
404
|
});
|
|
265
405
|
}
|
|
406
|
+
// β4 M1/M3: append MCP tools last. Plan mode skips them because every
|
|
407
|
+
// MCP tool is treated as medium-risk until per-tool annotations land
|
|
408
|
+
// in the MCP spec; treating MCP read-as-read would require server-
|
|
409
|
+
// side metadata we cannot trust today (a misconfigured server could
|
|
410
|
+
// claim `read` while running a destructive op).
|
|
411
|
+
if (!planMode) {
|
|
412
|
+
for (const def of mcpDefs) {
|
|
413
|
+
toolDefs.push({
|
|
414
|
+
name: def.name,
|
|
415
|
+
description: def.description,
|
|
416
|
+
parameters: def.parameters,
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
}
|
|
266
420
|
return toolDefs;
|
|
267
421
|
}
|
|
268
422
|
function parseArgs(raw) {
|
|
@@ -287,18 +441,27 @@ function requireString(obj, key) {
|
|
|
287
441
|
return v;
|
|
288
442
|
}
|
|
289
443
|
export function buildExecutor(input) {
|
|
290
|
-
const { kind, ctx, hooks, sessionId, askUserBridge, interactive, allowFetch } = input;
|
|
444
|
+
const { kind, ctx, hooks, sessionId, askUserBridge, interactive, allowFetch, allowSearch, agentDispatch, mcpRegistry } = input;
|
|
445
|
+
const mcpPrompt = input.mcpPrompt ?? defaultNonInteractiveMcpPrompt;
|
|
291
446
|
const workspaceRoot = input.workspaceRoot ?? ctx.root;
|
|
292
447
|
const planMode = kind === 'plan';
|
|
293
448
|
return async ({ name, arguments: argsRaw }) => {
|
|
294
|
-
|
|
449
|
+
// β4 M1/M3: MCP tool names live outside WIRED_TOOLS. They are
|
|
450
|
+
// validated lazily by the dispatcher (the registry knows which
|
|
451
|
+
// names are actually exposed). The namespace check happens FIRST
|
|
452
|
+
// so a bad `mcp__bogus__foo` does not collide with the native
|
|
453
|
+
// unknown-tool branch.
|
|
454
|
+
const isMcpName = name.startsWith(MCP_TOOL_PREFIX);
|
|
455
|
+
if (!isMcpName && !WIRED_TOOLS.has(name)) {
|
|
295
456
|
throw new Error(`unknown tool: ${name}`);
|
|
296
457
|
}
|
|
297
|
-
if (planMode
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
|
|
458
|
+
if (planMode) {
|
|
459
|
+
// MCP tools are uniformly refused in plan mode (see schema-side
|
|
460
|
+
// rationale in buildToolsSchema). Native tools split via
|
|
461
|
+
// READ_ONLY_TOOLS as before.
|
|
462
|
+
if (isMcpName || !READ_ONLY_TOOLS.has(name)) {
|
|
463
|
+
throw new Error(`PLAN_MODE_REFUSED: ${name} is not allowed in plan mode`);
|
|
464
|
+
}
|
|
302
465
|
}
|
|
303
466
|
// α6.9: refuse cancelled-token tool dispatch BEFORE PreToolUse
|
|
304
467
|
// hooks fire so a cancelled brief never reaches user-defined
|
|
@@ -336,8 +499,21 @@ export function buildExecutor(input) {
|
|
|
336
499
|
}
|
|
337
500
|
}
|
|
338
501
|
}
|
|
339
|
-
|
|
502
|
+
// β4 M1/M3: MCP dispatch deferred to the `dispatch` closure below so
|
|
503
|
+
// PostToolUse / PostToolUseFailure hooks observe MCP calls just like
|
|
504
|
+
// native calls. The dispatcher does its own argument parsing — MCP
|
|
505
|
+
// arg errors surface as model-visible `[MCP dispatch error] ...`
|
|
506
|
+
// strings, not throws.
|
|
507
|
+
const args = isMcpName ? {} : parseArgs(argsRaw);
|
|
340
508
|
const dispatch = async () => {
|
|
509
|
+
if (isMcpName) {
|
|
510
|
+
return dispatchMcpTool({
|
|
511
|
+
name,
|
|
512
|
+
argumentsRaw: argsRaw,
|
|
513
|
+
registry: mcpRegistry,
|
|
514
|
+
prompt: mcpPrompt,
|
|
515
|
+
});
|
|
516
|
+
}
|
|
341
517
|
// β1 T1/T2/T3/T5/T6: async-dispatch the new tool surface.
|
|
342
518
|
// task_*, skill, ask_user_question, web_fetch all live behind
|
|
343
519
|
// an async or async-compatible boundary.
|
|
@@ -353,6 +529,33 @@ export function buildExecutor(input) {
|
|
|
353
529
|
if (name === 'web_fetch') {
|
|
354
530
|
return dispatchWebFetch(args, { ctx, allowFetch: Boolean(allowFetch) });
|
|
355
531
|
}
|
|
532
|
+
if (name === 'web_search') {
|
|
533
|
+
return dispatchWebSearch(args, {
|
|
534
|
+
ctx,
|
|
535
|
+
allowSearch: Boolean(allowSearch),
|
|
536
|
+
sessionId,
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
if (name === 'multi_edit') {
|
|
540
|
+
return dispatchMultiEdit(args, ctx);
|
|
541
|
+
}
|
|
542
|
+
if (name === 'agent') {
|
|
543
|
+
// β2a r1 (Backend Architect P1, 2026-05-26): defense in depth.
|
|
544
|
+
// `WIRED_TOOLS` includes `agent`, so a plan-mode model that
|
|
545
|
+
// fabricates an `agent` tool call would otherwise be routed
|
|
546
|
+
// here. The plan-mode refusal at the top of the executor only
|
|
547
|
+
// fires for tools NOT in READ_ONLY_TOOLS; `agent` is
|
|
548
|
+
// intentionally absent from both sets, so we explicitly refuse
|
|
549
|
+
// it here. This pairs with `native-pugi.ts` hard-gating
|
|
550
|
+
// `agentDispatch` itself off in plan mode — without this
|
|
551
|
+
// defensive throw a future schema bug could let a plan-mode
|
|
552
|
+
// model spawn a write-capable child and break the read-only
|
|
553
|
+
// contract.
|
|
554
|
+
if (planMode) {
|
|
555
|
+
throw new Error('PLAN_MODE_REFUSED: agent is not allowed in plan mode');
|
|
556
|
+
}
|
|
557
|
+
return dispatchAgent(args, agentDispatch);
|
|
558
|
+
}
|
|
356
559
|
return dispatchTool(name, args, ctx);
|
|
357
560
|
};
|
|
358
561
|
try {
|
|
@@ -592,6 +795,71 @@ async function dispatchWebFetch(args, opts) {
|
|
|
592
795
|
});
|
|
593
796
|
return JSON.stringify(result);
|
|
594
797
|
}
|
|
798
|
+
async function dispatchWebSearch(args, opts) {
|
|
799
|
+
const query = requireString(args, 'query');
|
|
800
|
+
// `count` is optional integer 1..10. Validate here so the tool layer
|
|
801
|
+
// gets a clean value (the tool clamps internally too — defense in
|
|
802
|
+
// depth, since the model can pass anything).
|
|
803
|
+
let count;
|
|
804
|
+
if (args['count'] !== undefined && args['count'] !== null) {
|
|
805
|
+
const n = args['count'];
|
|
806
|
+
if (typeof n !== 'number' || !Number.isInteger(n)) {
|
|
807
|
+
throw new Error('web_search: count must be an integer');
|
|
808
|
+
}
|
|
809
|
+
count = n;
|
|
810
|
+
}
|
|
811
|
+
const result = await webSearchTool({ query, ...(count !== undefined ? { count } : {}) }, {
|
|
812
|
+
settings: opts.ctx.settings,
|
|
813
|
+
allowSearch: opts.allowSearch,
|
|
814
|
+
sessionId: opts.sessionId,
|
|
815
|
+
});
|
|
816
|
+
return JSON.stringify(result);
|
|
817
|
+
}
|
|
818
|
+
/**
|
|
819
|
+
* β2 S3 dispatch — wire the model-emitted `agent` tool call to the
|
|
820
|
+
* real subagent spawn primitive. When the executor was built without
|
|
821
|
+
* `agentDispatch` (e.g. a child loop, or a parent that explicitly
|
|
822
|
+
* disabled subagent spawn), the call is refused with a structured
|
|
823
|
+
* envelope so the model can adapt instead of crashing the parent loop.
|
|
824
|
+
*/
|
|
825
|
+
async function dispatchAgent(args, opts) {
|
|
826
|
+
if (!opts) {
|
|
827
|
+
// No dispatch context — return a structured refusal envelope.
|
|
828
|
+
// This matches the agent-tool.ts no-engine-client path and lets
|
|
829
|
+
// the model decide whether to retry inline or abandon the
|
|
830
|
+
// delegation. Throwing here would terminate the parent on a tool
|
|
831
|
+
// error frame which is the wrong UX when the issue is config.
|
|
832
|
+
return JSON.stringify({
|
|
833
|
+
ok: false,
|
|
834
|
+
status: 'failed',
|
|
835
|
+
summary: 'agent tool refused: dispatch not wired in this engine adapter. '
|
|
836
|
+
+ 'Re-run from a parent loop with agentDispatch configured.',
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
const parsed = parseAgentArgs(args);
|
|
840
|
+
const result = await agentTool(parsed, {
|
|
841
|
+
session: opts.parentSession,
|
|
842
|
+
engineClient: opts.engineClient,
|
|
843
|
+
...(opts.parentBudgetRemaining
|
|
844
|
+
? { parentBudgetRemaining: opts.parentBudgetRemaining }
|
|
845
|
+
: {}),
|
|
846
|
+
});
|
|
847
|
+
return JSON.stringify(result);
|
|
848
|
+
}
|
|
849
|
+
function parseAgentArgs(args) {
|
|
850
|
+
// Surface a clean error message to the model when the args don't
|
|
851
|
+
// match the schema. agentTool itself also validates via Zod; this
|
|
852
|
+
// pre-parse layer keeps the error stack short.
|
|
853
|
+
const role = requireString(args, 'role');
|
|
854
|
+
const brief = requireString(args, 'brief');
|
|
855
|
+
const isolationRaw = optionalString(args, 'isolation');
|
|
856
|
+
const out = {
|
|
857
|
+
role: role,
|
|
858
|
+
brief,
|
|
859
|
+
...(isolationRaw ? { isolation: isolationRaw } : {}),
|
|
860
|
+
};
|
|
861
|
+
return out;
|
|
862
|
+
}
|
|
595
863
|
function optionalString(obj, key) {
|
|
596
864
|
const v = obj[key];
|
|
597
865
|
if (v === undefined || v === null)
|
|
@@ -601,4 +869,38 @@ function optionalString(obj, key) {
|
|
|
601
869
|
}
|
|
602
870
|
return v;
|
|
603
871
|
}
|
|
872
|
+
/**
|
|
873
|
+
* β7 L5+T11: dispatch the model-emitted `multi_edit` tool call. The
|
|
874
|
+
* tool returns a structured result envelope; we serialize it to JSON
|
|
875
|
+
* for the engine loop. A refused dispatch (security, no_match,
|
|
876
|
+
* ambiguous_match, etc.) surfaces as `ok: false` in the envelope —
|
|
877
|
+
* the model can re-strategise rather than crashing the loop.
|
|
878
|
+
*/
|
|
879
|
+
function dispatchMultiEdit(args, ctx) {
|
|
880
|
+
const raw = args['edits'];
|
|
881
|
+
if (!Array.isArray(raw)) {
|
|
882
|
+
throw new Error('multi_edit: edits must be an array');
|
|
883
|
+
}
|
|
884
|
+
const edits = raw.map((item, i) => {
|
|
885
|
+
if (!item || typeof item !== 'object') {
|
|
886
|
+
throw new Error(`multi_edit: edits[${i}] must be an object`);
|
|
887
|
+
}
|
|
888
|
+
const obj = item;
|
|
889
|
+
const file = obj['file'];
|
|
890
|
+
const oldString = obj['oldString'];
|
|
891
|
+
const newString = obj['newString'];
|
|
892
|
+
if (typeof file !== 'string') {
|
|
893
|
+
throw new Error(`multi_edit: edits[${i}].file must be a string`);
|
|
894
|
+
}
|
|
895
|
+
if (typeof oldString !== 'string') {
|
|
896
|
+
throw new Error(`multi_edit: edits[${i}].oldString must be a string`);
|
|
897
|
+
}
|
|
898
|
+
if (typeof newString !== 'string') {
|
|
899
|
+
throw new Error(`multi_edit: edits[${i}].newString must be a string`);
|
|
900
|
+
}
|
|
901
|
+
return { file, oldString, newString };
|
|
902
|
+
});
|
|
903
|
+
const result = multiEdit(ctx, edits);
|
|
904
|
+
return JSON.stringify(result);
|
|
905
|
+
}
|
|
604
906
|
//# sourceMappingURL=tool-bridge.js.map
|
package/dist/core/lsp/client.js
CHANGED
|
@@ -465,10 +465,59 @@ export class LspClient {
|
|
|
465
465
|
}
|
|
466
466
|
}
|
|
467
467
|
}
|
|
468
|
+
/**
|
|
469
|
+
* Map a short LSP language slug to the settings.json key. β7 L9 — the
|
|
470
|
+
* settings schema spells out the full language name (`typescript`,
|
|
471
|
+
* `python`, ...) for human readability; the short slug (`ts`, `py`) is
|
|
472
|
+
* what every internal call site uses. Keep this map narrow and explicit.
|
|
473
|
+
*/
|
|
474
|
+
const SETTINGS_KEY_BY_LANG = {
|
|
475
|
+
ts: 'typescript',
|
|
476
|
+
js: 'javascript',
|
|
477
|
+
py: 'python',
|
|
478
|
+
go: 'go',
|
|
479
|
+
rust: 'rust',
|
|
480
|
+
};
|
|
481
|
+
/**
|
|
482
|
+
* Report whether the operator has explicitly disabled this language via
|
|
483
|
+
* `.pugi/settings.json::lsp.<language> = false`. Absent section or
|
|
484
|
+
* absent key means "enabled by default" — backwards-compatible with the
|
|
485
|
+
* α7.7 surface that ignored settings entirely. Returns true ONLY when
|
|
486
|
+
* the operator explicitly set the value to false.
|
|
487
|
+
*/
|
|
488
|
+
export function isLspLanguageDisabled(lang, lspSettings) {
|
|
489
|
+
if (!lspSettings)
|
|
490
|
+
return false;
|
|
491
|
+
const key = SETTINGS_KEY_BY_LANG[lang];
|
|
492
|
+
return lspSettings[key] === false;
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* Probe every registered language server. Operator-facing helper for
|
|
496
|
+
* `pugi lsp servers` — returns one row per language with the binary
|
|
497
|
+
* name, whether it was found on PATH, and whether the settings toggle
|
|
498
|
+
* has explicitly disabled it.
|
|
499
|
+
*/
|
|
500
|
+
export function inspectLspServers(lspSettings) {
|
|
501
|
+
const out = [];
|
|
502
|
+
for (const lang of Object.keys(LANGUAGE_SERVERS)) {
|
|
503
|
+
const server = LANGUAGE_SERVERS[lang];
|
|
504
|
+
out.push({
|
|
505
|
+
language: lang,
|
|
506
|
+
command: server.command + (server.args.length > 0 ? ` ${server.args.join(' ')}` : ''),
|
|
507
|
+
available: detectBinary(server.probe),
|
|
508
|
+
enabled: !isLspLanguageDisabled(lang, lspSettings),
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
return out;
|
|
512
|
+
}
|
|
468
513
|
/**
|
|
469
514
|
* Start an LSP client for the given language. Returns either an `LspClient`
|
|
470
515
|
* ready to use, or a structured failure (`lsp_unavailable`,
|
|
471
516
|
* `language_unsupported`).
|
|
517
|
+
*
|
|
518
|
+
* β7 L9: respects `.pugi/settings.json::lsp.<language> = false` —
|
|
519
|
+
* a disabled language reports `lsp_disabled` so the caller surface can
|
|
520
|
+
* tell the operator the binary IS available but settings says no.
|
|
472
521
|
*/
|
|
473
522
|
export async function startLspClient(lang, opts) {
|
|
474
523
|
const server = opts.serverOverride ?? LANGUAGE_SERVERS[lang];
|
|
@@ -479,6 +528,14 @@ export async function startLspClient(lang, opts) {
|
|
|
479
528
|
detail: `no LSP server registered for language: ${lang}`,
|
|
480
529
|
};
|
|
481
530
|
}
|
|
531
|
+
if (!opts.serverOverride && isLspLanguageDisabled(lang, opts.lspSettings)) {
|
|
532
|
+
return {
|
|
533
|
+
ok: false,
|
|
534
|
+
reason: 'lsp_disabled',
|
|
535
|
+
detail: `${lang} is disabled in .pugi/settings.json::lsp.${SETTINGS_KEY_BY_LANG[lang]}. ` +
|
|
536
|
+
`Remove the override (or set it to true) to enable.`,
|
|
537
|
+
};
|
|
538
|
+
}
|
|
482
539
|
if (!opts.serverOverride) {
|
|
483
540
|
const available = detectBinary(server.probe);
|
|
484
541
|
if (!available) {
|
package/dist/core/mcp/client.js
CHANGED
|
@@ -34,6 +34,15 @@ import { z } from 'zod';
|
|
|
34
34
|
*/
|
|
35
35
|
export const mcpServerConfigSchema = z.object({
|
|
36
36
|
command: z.string().min(1),
|
|
37
|
+
/**
|
|
38
|
+
* β4 r2 P1 #1 — operator's literal `command` argument as supplied to
|
|
39
|
+
* `pugi mcp install`. `command` above is now the RESOLVED absolute path
|
|
40
|
+
* we actually spawn. `originalCommand` is preserved for display/audit
|
|
41
|
+
* (`pugi mcp list`, audit logs) so operators can still see how they
|
|
42
|
+
* typed the install. Optional because pre-r2 mcp.json files do not
|
|
43
|
+
* carry the field — the loader treats absence as "use command directly".
|
|
44
|
+
*/
|
|
45
|
+
originalCommand: z.string().min(1).optional(),
|
|
37
46
|
args: z.array(z.string()).default([]),
|
|
38
47
|
env: z.record(z.string()).default({}),
|
|
39
48
|
/**
|