@pugi/cli 0.1.0-beta.12 → 0.1.0-beta.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/consensus/diff-capture.js +73 -0
- package/dist/core/context/index.js +7 -0
- package/dist/core/context/markdown-traverse.js +255 -0
- package/dist/core/edits/dispatch.js +218 -2
- package/dist/core/edits/journal.js +199 -0
- package/dist/core/edits/layer-d-ast.js +557 -14
- package/dist/core/edits/verify-hook.js +273 -0
- package/dist/core/engine/anvil-client.js +80 -5
- package/dist/core/engine/context-prefix.js +155 -0
- package/dist/core/engine/intent.js +260 -0
- package/dist/core/engine/native-pugi.js +663 -249
- package/dist/core/engine/prompts.js +52 -2
- package/dist/core/engine/tool-bridge.js +311 -9
- package/dist/core/lsp/client.js +57 -0
- package/dist/core/mcp/client.js +9 -0
- package/dist/core/mcp/http-server.js +553 -0
- package/dist/core/mcp/permission.js +190 -0
- package/dist/core/mcp/server-tools.js +219 -0
- package/dist/core/mcp/server.js +397 -0
- package/dist/core/repl/history.js +11 -1
- package/dist/core/repl/model-pricing.js +135 -0
- package/dist/core/repl/session.js +328 -12
- package/dist/core/repl/slash-commands.js +18 -4
- package/dist/core/settings.js +43 -0
- package/dist/core/subagents/dispatcher-real.js +600 -0
- package/dist/core/subagents/dispatcher.js +113 -24
- package/dist/core/subagents/index.js +18 -5
- package/dist/core/subagents/isolation-matrix.js +213 -0
- package/dist/core/subagents/spawn.js +19 -4
- package/dist/core/transport/version-interceptor.js +166 -0
- package/dist/index.js +28 -0
- package/dist/runtime/bootstrap.js +190 -0
- package/dist/runtime/cli.js +534 -268
- package/dist/runtime/commands/lsp.js +165 -5
- package/dist/runtime/commands/mcp.js +537 -0
- package/dist/runtime/headless.js +543 -0
- package/dist/runtime/load-hooks-or-exit.js +71 -0
- package/dist/runtime/version.js +65 -0
- package/dist/tools/agent-tool.js +192 -0
- package/dist/tools/apply-patch.js +62 -1
- package/dist/tools/mcp-tool.js +260 -0
- package/dist/tools/multi-edit.js +361 -0
- package/dist/tools/registry.js +5 -0
- package/dist/tools/web-fetch.js +147 -2
- package/dist/tools/web-search.js +458 -0
- package/dist/tui/agent-tree.js +10 -0
- package/dist/tui/ask-modal.js +2 -2
- package/dist/tui/conversation-pane.js +1 -1
- package/dist/tui/input-box.js +1 -1
- package/dist/tui/markdown-render.js +4 -4
- package/dist/tui/repl-render.js +105 -15
- package/dist/tui/repl-splash.js +2 -2
- package/dist/tui/repl.js +10 -4
- package/dist/tui/splash.js +1 -1
- package/dist/tui/status-bar.js +94 -16
- package/dist/tui/update-banner.js +20 -2
- package/package.json +5 -4
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agent` tool — β2 S3 (2026-05-26).
|
|
3
|
+
*
|
|
4
|
+
* Exposes the subagent spawn primitive as a first-class tool call so
|
|
5
|
+
* the root Mira persona (or any orchestrator-capable parent loop) can
|
|
6
|
+
* delegate a brief to a specialist child via the standard tool-use
|
|
7
|
+
* grammar instead of via the legacy `<pugi-delegate>` XML sidechannel.
|
|
8
|
+
*
|
|
9
|
+
* Grammar:
|
|
10
|
+
*
|
|
11
|
+
* {
|
|
12
|
+
* "role": "coder" | "verifier" | "reviewer" | "researcher" | ...,
|
|
13
|
+
* "brief": "one-paragraph task description",
|
|
14
|
+
* "isolation": "worktree" | "shared_fs" | "auto" // optional, default "auto"
|
|
15
|
+
* }
|
|
16
|
+
*
|
|
17
|
+
* Returns a JSON envelope:
|
|
18
|
+
*
|
|
19
|
+
* {
|
|
20
|
+
* "ok": true,
|
|
21
|
+
* "taskId": "subagent-<uuid>",
|
|
22
|
+
* "role": "coder",
|
|
23
|
+
* "personaSlug": "dev",
|
|
24
|
+
* "status": "shipped" | "blocked" | "failed",
|
|
25
|
+
* "summary": "...",
|
|
26
|
+
* "filesChanged": ["src/...", "src/..."],
|
|
27
|
+
* "toolCallCount": N,
|
|
28
|
+
* "tokensIn": N,
|
|
29
|
+
* "tokensOut": N,
|
|
30
|
+
* "durationMs": N,
|
|
31
|
+
* "worktreePath": "/path/.pugi/worktrees/<uuid>" // only when worktree isolation used
|
|
32
|
+
* }
|
|
33
|
+
*
|
|
34
|
+
* Why expose this as a tool rather than baking it into the engine
|
|
35
|
+
* loop directly:
|
|
36
|
+
*
|
|
37
|
+
* - The model's existing tool-use grammar is what every modern Anvil
|
|
38
|
+
* provider speaks natively. Wrapping delegation as a tool means the
|
|
39
|
+
* model can decide WHEN to spawn a child the same way it decides
|
|
40
|
+
* when to read/edit/bash — no special-case prompt engineering.
|
|
41
|
+
* - The `agent` tool is gated by the isolation-matrix capability map
|
|
42
|
+
* (only `orchestrator`-class roles see it in their tools schema).
|
|
43
|
+
* A coder/reviewer/verifier cannot recursively spawn grandchildren
|
|
44
|
+
* because they never see the `agent` tool in the first place.
|
|
45
|
+
* - The audit log threads cleanly: parent's `tool_call: agent(...)`
|
|
46
|
+
* pairs with the child's `subagent.spawned/tool_call/completed`
|
|
47
|
+
* events, and a single SSE replay yields the full tree.
|
|
48
|
+
*/
|
|
49
|
+
import { z } from 'zod';
|
|
50
|
+
import { randomUUID } from 'node:crypto';
|
|
51
|
+
import { relative as relativePath } from 'node:path';
|
|
52
|
+
import { spawnSubagentWithOutcome } from '../core/subagents/spawn.js';
|
|
53
|
+
/**
|
|
54
|
+
* Argument schema. `isolation: 'auto'` defers to the role-default
|
|
55
|
+
* isolation tier (set by `isolationForRole` in dispatcher.ts). The
|
|
56
|
+
* explicit `worktree` opt-in forces worktree isolation even for roles
|
|
57
|
+
* whose default is `shared_fs_serialized`; `shared_fs` does the
|
|
58
|
+
* inverse (forces shared-fs even for roles whose default is `worktree`).
|
|
59
|
+
*
|
|
60
|
+
* The role enum mirrors the SDK's SubagentRole — keep both in lockstep.
|
|
61
|
+
*/
|
|
62
|
+
export const agentToolArgsSchema = z.object({
|
|
63
|
+
role: z.enum([
|
|
64
|
+
'orchestrator',
|
|
65
|
+
'architect',
|
|
66
|
+
'coder',
|
|
67
|
+
'verifier',
|
|
68
|
+
'reviewer',
|
|
69
|
+
'researcher',
|
|
70
|
+
'release',
|
|
71
|
+
'devops',
|
|
72
|
+
'design_qa',
|
|
73
|
+
]),
|
|
74
|
+
brief: z
|
|
75
|
+
.string()
|
|
76
|
+
.min(1, 'brief must not be empty')
|
|
77
|
+
.max(8000, 'brief must be ≤ 8000 chars'),
|
|
78
|
+
isolation: z.enum(['worktree', 'shared_fs', 'auto']).optional(),
|
|
79
|
+
});
|
|
80
|
+
/**
|
|
81
|
+
* Dispatch a subagent via the `agent` tool. Returns the JSON envelope
|
|
82
|
+
* the executor wraps into the tool result frame. Throws when the
|
|
83
|
+
* arguments fail schema validation — the executor catches and feeds
|
|
84
|
+
* the message back to the model so it can correct itself.
|
|
85
|
+
*/
|
|
86
|
+
export async function agentTool(args, ctx) {
|
|
87
|
+
const validated = agentToolArgsSchema.parse(args);
|
|
88
|
+
if (!ctx.engineClient) {
|
|
89
|
+
// Hard refusal: the `agent` tool is real-backend-only. Surfacing a
|
|
90
|
+
// structured envelope (instead of throwing) lets the model decide
|
|
91
|
+
// whether to abandon the delegation or to fall back to in-process
|
|
92
|
+
// work. Throwing here would terminate the parent loop on a tool
|
|
93
|
+
// error frame, which is the wrong UX when the issue is config.
|
|
94
|
+
return {
|
|
95
|
+
ok: false,
|
|
96
|
+
taskId: `subagent-rejected-${randomUUID()}`,
|
|
97
|
+
role: validated.role,
|
|
98
|
+
personaSlug: '',
|
|
99
|
+
status: 'failed',
|
|
100
|
+
summary: 'agent tool unavailable: no engine client wired through the parent dispatch. '
|
|
101
|
+
+ 'Run pugi via the standard CLI entrypoints; the in-memory test harness does '
|
|
102
|
+
+ 'not currently support real subagent spawn.',
|
|
103
|
+
filesChanged: [],
|
|
104
|
+
toolCallCount: 0,
|
|
105
|
+
tokensIn: 0,
|
|
106
|
+
tokensOut: 0,
|
|
107
|
+
durationMs: 0,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
// β2 S10 pre-flight (best-effort): refuse the spawn if the child's
|
|
111
|
+
// role-default token budget exceeds the parent's remaining budget.
|
|
112
|
+
// The check is conservative — it uses the child's DEFAULT envelope
|
|
113
|
+
// because we do not know the actual run cost ahead of time. Roles
|
|
114
|
+
// can downscale via SubagentTask.budget overrides; this gate just
|
|
115
|
+
// catches the gross case (parent has 5k left, child default 80k).
|
|
116
|
+
if (ctx.parentBudgetRemaining?.tokens !== undefined) {
|
|
117
|
+
const { budgetForRole } = await import('../core/subagents/dispatcher.js');
|
|
118
|
+
const childDefault = budgetForRole(validated.role, undefined);
|
|
119
|
+
if (childDefault.tokens > ctx.parentBudgetRemaining.tokens) {
|
|
120
|
+
return {
|
|
121
|
+
ok: false,
|
|
122
|
+
taskId: `subagent-budget-refused-${randomUUID()}`,
|
|
123
|
+
role: validated.role,
|
|
124
|
+
personaSlug: '',
|
|
125
|
+
status: 'blocked',
|
|
126
|
+
summary: `agent spawn refused: child '${validated.role}' default budget is ${childDefault.tokens} tokens `
|
|
127
|
+
+ `but parent has only ${ctx.parentBudgetRemaining.tokens} tokens remaining. `
|
|
128
|
+
+ 'Tighten the child task budget or finish the parent first.',
|
|
129
|
+
filesChanged: [],
|
|
130
|
+
toolCallCount: 0,
|
|
131
|
+
tokensIn: 0,
|
|
132
|
+
tokensOut: 0,
|
|
133
|
+
durationMs: 0,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const task = {
|
|
138
|
+
id: `subagent-${randomUUID()}`,
|
|
139
|
+
role: validated.role,
|
|
140
|
+
prompt: validated.brief,
|
|
141
|
+
// `auto` permission mode matches the parent loop's default; the
|
|
142
|
+
// isolation-matrix capability gate provides the load-bearing
|
|
143
|
+
// restriction layer regardless of permissionMode.
|
|
144
|
+
permissionMode: 'auto',
|
|
145
|
+
};
|
|
146
|
+
const useWorktree = validated.isolation === 'worktree'
|
|
147
|
+
? true
|
|
148
|
+
: validated.isolation === 'shared_fs'
|
|
149
|
+
? false
|
|
150
|
+
: undefined; // 'auto' → defer to role default
|
|
151
|
+
const outcome = await spawnSubagentWithOutcome(task, ctx.session, {
|
|
152
|
+
engineClient: ctx.engineClient,
|
|
153
|
+
...(useWorktree !== undefined ? { useWorktreeIsolation: useWorktree } : {}),
|
|
154
|
+
});
|
|
155
|
+
const envelope = {
|
|
156
|
+
// `ok` = subagent did not crash. Both `shipped` (real work) and
|
|
157
|
+
// `replied` (text-only completion, added 2026-05-26) count as
|
|
158
|
+
// non-crash outcomes; the caller can branch on the explicit
|
|
159
|
+
// `status` field below if it needs to distinguish them.
|
|
160
|
+
ok: outcome.result.status === 'shipped' || outcome.result.status === 'replied',
|
|
161
|
+
taskId: outcome.result.taskId,
|
|
162
|
+
role: outcome.result.role,
|
|
163
|
+
personaSlug: outcome.result.personaSlug,
|
|
164
|
+
status: outcome.result.status,
|
|
165
|
+
summary: outcome.result.summary,
|
|
166
|
+
filesChanged: outcome.result.filesChanged,
|
|
167
|
+
toolCallCount: outcome.result.toolCallCount,
|
|
168
|
+
tokensIn: outcome.result.tokensIn,
|
|
169
|
+
tokensOut: outcome.result.tokensOut,
|
|
170
|
+
durationMs: outcome.result.durationMs,
|
|
171
|
+
};
|
|
172
|
+
if (outcome.worktreeHandle) {
|
|
173
|
+
// β2a r2 (Codex P1, 2026-05-26): emit the worktree path RELATIVE to
|
|
174
|
+
// the parent session's workspace root. The envelope is JSON-stringified
|
|
175
|
+
// into the parent loop's tool_result frame and from there flows to the
|
|
176
|
+
// provider on every subsequent assistant turn — shipping the absolute
|
|
177
|
+
// path (`/Users/<operator>/Web/.../.pugi/worktrees/<uuid>`) leaks the
|
|
178
|
+
// operator's home directory to the upstream provider on every spawn.
|
|
179
|
+
//
|
|
180
|
+
// The composeSummary path (dispatcher-real.ts §β2a r1) already scrubs
|
|
181
|
+
// the summary text via the same `relative()` wrapping; this is the
|
|
182
|
+
// matching fix for the structured envelope field that r1 missed.
|
|
183
|
+
// The relative form (`.pugi/worktrees/<uuid>`) is enough for the
|
|
184
|
+
// operator's local `pugi worktree promote/drop` commands which run
|
|
185
|
+
// resolved against ctx.session.root anyway.
|
|
186
|
+
const relPath = relativePath(ctx.session.root, outcome.worktreeHandle.path)
|
|
187
|
+
|| outcome.worktreeHandle.path;
|
|
188
|
+
envelope.worktreePath = relPath;
|
|
189
|
+
}
|
|
190
|
+
return envelope;
|
|
191
|
+
}
|
|
192
|
+
//# sourceMappingURL=agent-tool.js.map
|
|
@@ -261,6 +261,25 @@ export function applyPatch(ctx, patch, opts = {}) {
|
|
|
261
261
|
recordToolResult(ctx.session, toolCallId, 'error', 'empty_patch');
|
|
262
262
|
return result;
|
|
263
263
|
}
|
|
264
|
+
// β7 L4: pre-flight conflict-marker check. A patch that still carries
|
|
265
|
+
// unresolved `<<<<<<<`/`=======`/`>>>>>>>` lines is almost always
|
|
266
|
+
// operator error (copy-pasted a half-resolved merge instead of the
|
|
267
|
+
// clean diff). `git apply` would reject it with a confusing
|
|
268
|
+
// "corrupt patch" message; the dedicated reason makes the failure
|
|
269
|
+
// obvious. We only check at body line starts so a legitimate diff
|
|
270
|
+
// that adds a string literal containing `<<<<<<<` for tests still
|
|
271
|
+
// applies.
|
|
272
|
+
if (containsConflictMarkers(patch)) {
|
|
273
|
+
const result = {
|
|
274
|
+
ok: false,
|
|
275
|
+
filesChanged: [],
|
|
276
|
+
reason: 'conflict_markers',
|
|
277
|
+
detail: 'patch body contains unresolved git conflict markers (<<<<<<<, =======, >>>>>>>). ' +
|
|
278
|
+
'Resolve the conflict first or use --3way with --base=<sha> to defer to git.',
|
|
279
|
+
};
|
|
280
|
+
recordToolResult(ctx.session, toolCallId, 'error', 'conflict_markers');
|
|
281
|
+
return result;
|
|
282
|
+
}
|
|
264
283
|
const paths = extractPatchPaths(patch);
|
|
265
284
|
if (paths.length === 0) {
|
|
266
285
|
const result = {
|
|
@@ -486,10 +505,52 @@ function runGit(args, cwd, stdin) {
|
|
|
486
505
|
env: { ...process.env, LANG: 'C', LC_ALL: 'C' },
|
|
487
506
|
});
|
|
488
507
|
}
|
|
508
|
+
/**
|
|
509
|
+
* β7 L4: detect unresolved git conflict markers in a patch body.
|
|
510
|
+
*
|
|
511
|
+
* Conflict markers in a unified diff are a sign of operator error —
|
|
512
|
+
* someone copy-pasted a half-merged file instead of the clean diff.
|
|
513
|
+
* `git apply` would reject the patch with a confusing parse error
|
|
514
|
+
* ("corrupt patch at line N"). We check at the START of body lines so
|
|
515
|
+
* a legitimate diff that adds a string literal containing `<<<<<<<`
|
|
516
|
+
* (rare but legitimate for tests) still applies.
|
|
517
|
+
*
|
|
518
|
+
* Conflict marker bytes in a unified diff body look like:
|
|
519
|
+
*
|
|
520
|
+
* +<<<<<<< HEAD
|
|
521
|
+
* +=======
|
|
522
|
+
* +>>>>>>> branch
|
|
523
|
+
*
|
|
524
|
+
* The `+` prefix is the unified-diff line-add marker. We strip it
|
|
525
|
+
* before the marker check; without the strip, an INVERSE diff that
|
|
526
|
+
* REMOVES a real conflict marker (legitimate cleanup commit) would be
|
|
527
|
+
* a false positive.
|
|
528
|
+
*
|
|
529
|
+
* Returns true when ANY conflict marker is detected.
|
|
530
|
+
*/
|
|
531
|
+
export function containsConflictMarkers(patch) {
|
|
532
|
+
for (const line of patch.split('\n')) {
|
|
533
|
+
// Only inspect body lines (start with `+` or `-` — the diff add/del
|
|
534
|
+
// markers). Header lines (`diff --git`, `+++`, `---`, `@@`) are
|
|
535
|
+
// skipped because the marker tokens cannot appear in those positions.
|
|
536
|
+
if (!(line.startsWith('+') || line.startsWith('-')))
|
|
537
|
+
continue;
|
|
538
|
+
// Skip diff header lines (`+++ b/foo` / `--- a/foo`).
|
|
539
|
+
if (line.startsWith('+++') || line.startsWith('---'))
|
|
540
|
+
continue;
|
|
541
|
+
const body = line.slice(1);
|
|
542
|
+
if (body.startsWith('<<<<<<<') ||
|
|
543
|
+
body.startsWith('>>>>>>>') ||
|
|
544
|
+
body === '=======') {
|
|
545
|
+
return true;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
return false;
|
|
549
|
+
}
|
|
489
550
|
/**
|
|
490
551
|
* Test-only surface for the apply-patch heuristics. Specs poke
|
|
491
552
|
* `extractPatchPaths` directly to assert on the path-parsing layer
|
|
492
553
|
* without paying for a real git invocation.
|
|
493
554
|
*/
|
|
494
|
-
export const __test__ = { extractPatchPaths, runGit, unquoteGitPath };
|
|
555
|
+
export const __test__ = { extractPatchPaths, runGit, unquoteGitPath, containsConflictMarkers };
|
|
495
556
|
//# sourceMappingURL=apply-patch.js.map
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { callTool } from '../core/mcp/client.js';
|
|
2
|
+
import { getMcpPermission, setMcpPermission, } from '../core/mcp/permission.js';
|
|
3
|
+
/**
|
|
4
|
+
* Tool dispatcher for MCP-invoked tools (β4 M1 + M3 + M5).
|
|
5
|
+
*
|
|
6
|
+
* Tool names use the `mcp__<server>__<tool>` namespace (double-underscore
|
|
7
|
+
* separator, mirroring Claude Code's MCP envelope). The triple-underscore
|
|
8
|
+
* forms (`mcp__server__tool__sub`) collapse into the third segment when
|
|
9
|
+
* the upstream server itself uses underscores in its tool names — `split`
|
|
10
|
+
* on the first two `__` only, so any further `__` in the tool name part
|
|
11
|
+
* survive intact (e.g. `mcp__github__create_issue` -> server=`github`,
|
|
12
|
+
* tool=`create_issue`).
|
|
13
|
+
*
|
|
14
|
+
* Why double-underscore: native Pugi tools use single-token names
|
|
15
|
+
* (`read`, `grep`, `edit`, `bash`). The double-underscore prefix
|
|
16
|
+
* unambiguously segregates the MCP namespace from native names without
|
|
17
|
+
* needing per-name regex matching at every dispatch site.
|
|
18
|
+
*
|
|
19
|
+
* Permission flow:
|
|
20
|
+
* 1. Server trust gate (handled at registry-load time). If a server is
|
|
21
|
+
* not `trusted`, its tools never reach the engine loop.
|
|
22
|
+
* 2. Per-(server, tool) permission cache (`./mcp/permission.ts`).
|
|
23
|
+
* Unset on first dispatch -> caller must prompt. Cached `allow_always`
|
|
24
|
+
* auto-passes; cached `deny` auto-refuses.
|
|
25
|
+
*
|
|
26
|
+
* This module is the bridge — it parses the namespaced name, finds the
|
|
27
|
+
* live connection in the registry, consults the cache, and (when
|
|
28
|
+
* approved) routes through `client.callTool`. Prompting is the executor's
|
|
29
|
+
* responsibility; this module exposes the cache lookup + dispatch
|
|
30
|
+
* primitives so the executor stays small.
|
|
31
|
+
*/
|
|
32
|
+
/**
|
|
33
|
+
* Prefix every MCP tool name carries on the engine-loop wire.
|
|
34
|
+
*/
|
|
35
|
+
export const MCP_TOOL_PREFIX = 'mcp__';
|
|
36
|
+
/**
|
|
37
|
+
* Parse `mcp__<server>__<tool>` into `{ serverName, toolName }`. Returns
|
|
38
|
+
* null when the input does not match the namespace — callers use this as
|
|
39
|
+
* the "is this an MCP tool?" predicate.
|
|
40
|
+
*
|
|
41
|
+
* Server names cannot contain `__` by registry validation (they are JSON
|
|
42
|
+
* object keys); tool names CAN (e.g. `create_issue` has a single `_` but
|
|
43
|
+
* `read_directory` has none, so the only ambiguity is when an upstream
|
|
44
|
+
* tool uses double-underscore in its slug — extremely rare, but if it
|
|
45
|
+
* happens the second `__` boundary still parses correctly because we
|
|
46
|
+
* split on the FIRST occurrence after the prefix).
|
|
47
|
+
*/
|
|
48
|
+
export function parseMcpToolName(name) {
|
|
49
|
+
if (!name.startsWith(MCP_TOOL_PREFIX))
|
|
50
|
+
return null;
|
|
51
|
+
const tail = name.slice(MCP_TOOL_PREFIX.length);
|
|
52
|
+
const sep = tail.indexOf('__');
|
|
53
|
+
if (sep === -1)
|
|
54
|
+
return null;
|
|
55
|
+
const serverName = tail.slice(0, sep);
|
|
56
|
+
const toolName = tail.slice(sep + 2);
|
|
57
|
+
if (serverName.length === 0 || toolName.length === 0)
|
|
58
|
+
return null;
|
|
59
|
+
return { serverName, toolName };
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Build the namespaced tool name from a server + tool pair. Inverse of
|
|
63
|
+
* `parseMcpToolName`. Used by `buildMcpToolDefs` to emit the schema.
|
|
64
|
+
*/
|
|
65
|
+
export function buildMcpToolName(serverName, toolName) {
|
|
66
|
+
return `${MCP_TOOL_PREFIX}${serverName}__${toolName}`;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Build engine-loop tool definitions from every trusted server's
|
|
70
|
+
* surfaced tools. Empty array when no MCP servers are trusted — the
|
|
71
|
+
* schema builder can call this unconditionally without checking first.
|
|
72
|
+
*/
|
|
73
|
+
export function buildMcpToolDefs(registry) {
|
|
74
|
+
if (!registry)
|
|
75
|
+
return [];
|
|
76
|
+
const defs = [];
|
|
77
|
+
for (const state of registry.servers.values()) {
|
|
78
|
+
if (state.trust !== 'trusted')
|
|
79
|
+
continue;
|
|
80
|
+
for (const tool of state.surfacedTools) {
|
|
81
|
+
defs.push({
|
|
82
|
+
name: buildMcpToolName(state.name, tool.name),
|
|
83
|
+
description: descriptionFor(state.name, tool),
|
|
84
|
+
// The upstream server returns its own JSON Schema in `inputSchema`.
|
|
85
|
+
// We surface it verbatim — the loop client passes it through to
|
|
86
|
+
// the model, and the model emits arguments matching the upstream
|
|
87
|
+
// shape. Default to `{ type: 'object' }` when missing so the
|
|
88
|
+
// OpenAI-shaped tool envelope still validates.
|
|
89
|
+
parameters: tool.inputSchema ?? { type: 'object' },
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// Sort stable so the schema bundle hash (used for caching/audit) is
|
|
94
|
+
// deterministic regardless of Map iteration order.
|
|
95
|
+
return defs.sort((a, b) => a.name.localeCompare(b.name));
|
|
96
|
+
}
|
|
97
|
+
function descriptionFor(serverName, tool) {
|
|
98
|
+
const base = tool.description?.trim() ?? `MCP tool ${tool.name} on server ${serverName}.`;
|
|
99
|
+
return `[MCP:${serverName}] ${base}`;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Look up the live connection + tool metadata for a parsed MCP tool name.
|
|
103
|
+
* Returns null when the server is not trusted, not connected, or does
|
|
104
|
+
* not expose the named tool. Callers MUST handle null — never throw,
|
|
105
|
+
* because the model may emit stale tool names after a server restart.
|
|
106
|
+
*/
|
|
107
|
+
export function resolveMcpTool(registry, parsed) {
|
|
108
|
+
if (!registry)
|
|
109
|
+
return null;
|
|
110
|
+
const state = registry.servers.get(parsed.serverName);
|
|
111
|
+
if (!state || state.trust !== 'trusted' || !state.connection)
|
|
112
|
+
return null;
|
|
113
|
+
const tool = state.surfacedTools.find((t) => t.name === parsed.toolName);
|
|
114
|
+
if (!tool)
|
|
115
|
+
return null;
|
|
116
|
+
return { state, connection: state.connection, tool };
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* The default prompt — used when no interactive bridge is wired (CI,
|
|
120
|
+
* non-TTY pipes). Returns `deny` so an unattended run never silently
|
|
121
|
+
* fires an MCP call the operator never approved. The deny is NOT
|
|
122
|
+
* persisted, so the next run with a wired prompt still has a chance to
|
|
123
|
+
* approve.
|
|
124
|
+
*/
|
|
125
|
+
export const defaultNonInteractiveMcpPrompt = async () => 'unset';
|
|
126
|
+
/**
|
|
127
|
+
* Dispatch one MCP tool call. The flow:
|
|
128
|
+
*
|
|
129
|
+
* 1. Parse the namespaced tool name. Return error string when
|
|
130
|
+
* malformed — the model sees the error and can self-correct.
|
|
131
|
+
* 2. Resolve the live connection. Return error when the server is not
|
|
132
|
+
* trusted/connected or the tool is unknown.
|
|
133
|
+
* 3. Consult the permission cache. `deny` short-circuits. `allow_always`
|
|
134
|
+
* proceeds. `unset` invokes the prompt; the operator's verdict is
|
|
135
|
+
* persisted (allow_always/deny) or used one-shot (allow_once).
|
|
136
|
+
* 4. Parse the arguments string. Bad JSON -> error string.
|
|
137
|
+
* 5. Call `client.callTool` and stringify the content for the model.
|
|
138
|
+
*
|
|
139
|
+
* Throws ONLY on unrecoverable transport failures (e.g. the connection
|
|
140
|
+
* died mid-call). Tool-level errors from the upstream server are
|
|
141
|
+
* surfaced as `[MCP error] <message>` strings so the model can recover.
|
|
142
|
+
*/
|
|
143
|
+
export async function dispatchMcpTool(input) {
|
|
144
|
+
const parsed = parseMcpToolName(input.name);
|
|
145
|
+
if (!parsed) {
|
|
146
|
+
return `[MCP dispatch error] tool name "${input.name}" does not match the ${MCP_TOOL_PREFIX}<server>__<tool> namespace`;
|
|
147
|
+
}
|
|
148
|
+
const resolved = resolveMcpTool(input.registry, parsed);
|
|
149
|
+
if (!resolved) {
|
|
150
|
+
return `[MCP dispatch error] no trusted+connected server "${parsed.serverName}" exposes a tool named "${parsed.toolName}"`;
|
|
151
|
+
}
|
|
152
|
+
let args;
|
|
153
|
+
try {
|
|
154
|
+
args = parseArgumentsRaw(input.argumentsRaw);
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
return `[MCP dispatch error] invalid JSON in arguments for ${input.name}: ${error instanceof Error ? error.message : String(error)}`;
|
|
158
|
+
}
|
|
159
|
+
// Permission gate.
|
|
160
|
+
const cached = getMcpPermission(parsed.serverName, parsed.toolName);
|
|
161
|
+
let effective = cached;
|
|
162
|
+
if (cached === 'unset') {
|
|
163
|
+
const verdict = await input.prompt({
|
|
164
|
+
serverName: parsed.serverName,
|
|
165
|
+
toolName: parsed.toolName,
|
|
166
|
+
toolDescription: resolved.tool.description ?? '',
|
|
167
|
+
callArguments: args,
|
|
168
|
+
});
|
|
169
|
+
effective = verdict;
|
|
170
|
+
if (verdict === 'allow_always' || verdict === 'deny') {
|
|
171
|
+
setMcpPermission(parsed.serverName, parsed.toolName, verdict, resolveDecidedBy(input.decidedBy));
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (effective === 'deny') {
|
|
175
|
+
return `[MCP refused] operator denied ${parsed.serverName}:${parsed.toolName}`;
|
|
176
|
+
}
|
|
177
|
+
if (effective !== 'allow_once' && effective !== 'allow_always') {
|
|
178
|
+
// Includes `unset` returned by the non-interactive default prompt.
|
|
179
|
+
return `[MCP refused] no operator approval for ${parsed.serverName}:${parsed.toolName} (run from a TTY to approve)`;
|
|
180
|
+
}
|
|
181
|
+
// Dispatch.
|
|
182
|
+
let result;
|
|
183
|
+
try {
|
|
184
|
+
result = await callTool(resolved.connection, parsed.toolName, args, {
|
|
185
|
+
...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}),
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
// Transport-level failure (timeout, child died mid-call). Surface
|
|
190
|
+
// as a recoverable string so the model can degrade gracefully.
|
|
191
|
+
return `[MCP transport error] ${parsed.serverName}:${parsed.toolName}: ${error instanceof Error ? error.message : String(error)}`;
|
|
192
|
+
}
|
|
193
|
+
return renderMcpToolResult(result.content, result.isError, parsed);
|
|
194
|
+
}
|
|
195
|
+
function parseArgumentsRaw(raw) {
|
|
196
|
+
if (!raw || raw.trim() === '')
|
|
197
|
+
return {};
|
|
198
|
+
const parsed = JSON.parse(raw);
|
|
199
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
200
|
+
throw new Error('arguments must be a JSON object');
|
|
201
|
+
}
|
|
202
|
+
return parsed;
|
|
203
|
+
}
|
|
204
|
+
function resolveDecidedBy(override) {
|
|
205
|
+
return (override?.trim() ||
|
|
206
|
+
process.env.PUGI_TRUSTED_BY?.trim() ||
|
|
207
|
+
process.env.USER?.trim() ||
|
|
208
|
+
process.env.USERNAME?.trim() ||
|
|
209
|
+
'cli');
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Project the MCP `content` payload into a single text string the model
|
|
213
|
+
* can ingest. MCP servers reply with `content: [{ type: 'text', text }]`
|
|
214
|
+
* by convention; we concatenate every `type: text` chunk and surface a
|
|
215
|
+
* `[MCP non-text content]` marker for other content kinds (images,
|
|
216
|
+
* resource references) which are not yet wired into Pugi's loop.
|
|
217
|
+
*
|
|
218
|
+
* `isError: true` from the upstream maps to a `[MCP error] ...` prefix
|
|
219
|
+
* so the model knows the call failed at the server, not at the
|
|
220
|
+
* transport.
|
|
221
|
+
*/
|
|
222
|
+
export function renderMcpToolResult(content, isError, parsed) {
|
|
223
|
+
const text = projectTextContent(content);
|
|
224
|
+
const prefix = isError ? `[MCP error ${parsed.serverName}:${parsed.toolName}] ` : '';
|
|
225
|
+
if (text === null) {
|
|
226
|
+
// Fallback to a JSON dump so the model sees SOMETHING — better than
|
|
227
|
+
// an opaque empty string when the upstream uses image / resource
|
|
228
|
+
// content kinds.
|
|
229
|
+
try {
|
|
230
|
+
return `${prefix}${JSON.stringify(content)}`;
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
return `${prefix}[MCP non-serialisable content]`;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return `${prefix}${text}`;
|
|
237
|
+
}
|
|
238
|
+
function projectTextContent(content) {
|
|
239
|
+
if (content === null || content === undefined)
|
|
240
|
+
return '';
|
|
241
|
+
if (typeof content === 'string')
|
|
242
|
+
return content;
|
|
243
|
+
if (!Array.isArray(content))
|
|
244
|
+
return null;
|
|
245
|
+
const parts = [];
|
|
246
|
+
for (const entry of content) {
|
|
247
|
+
if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
|
|
248
|
+
const obj = entry;
|
|
249
|
+
if (obj.type === 'text' && typeof obj.text === 'string') {
|
|
250
|
+
parts.push(obj.text);
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
// Non-text chunk — record a marker so the model knows something was
|
|
255
|
+
// dropped from the response.
|
|
256
|
+
parts.push('[MCP non-text content chunk]');
|
|
257
|
+
}
|
|
258
|
+
return parts.join('\n');
|
|
259
|
+
}
|
|
260
|
+
//# sourceMappingURL=mcp-tool.js.map
|