@pugi/cli 0.1.0-beta.12 → 0.1.0-beta.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/core/consensus/diff-capture.js +73 -0
  2. package/dist/core/context/index.js +7 -0
  3. package/dist/core/context/markdown-traverse.js +255 -0
  4. package/dist/core/edits/dispatch.js +218 -2
  5. package/dist/core/edits/journal.js +199 -0
  6. package/dist/core/edits/layer-d-ast.js +557 -14
  7. package/dist/core/edits/verify-hook.js +273 -0
  8. package/dist/core/engine/anvil-client.js +99 -5
  9. package/dist/core/engine/context-prefix.js +155 -0
  10. package/dist/core/engine/intent.js +260 -0
  11. package/dist/core/engine/native-pugi.js +663 -249
  12. package/dist/core/engine/prompts.js +52 -2
  13. package/dist/core/engine/tool-bridge.js +311 -9
  14. package/dist/core/lsp/client.js +57 -0
  15. package/dist/core/mcp/client.js +9 -0
  16. package/dist/core/mcp/http-server.js +553 -0
  17. package/dist/core/mcp/permission.js +190 -0
  18. package/dist/core/mcp/server-tools.js +219 -0
  19. package/dist/core/mcp/server.js +397 -0
  20. package/dist/core/repl/history.js +11 -1
  21. package/dist/core/repl/model-pricing.js +135 -0
  22. package/dist/core/repl/session.js +328 -12
  23. package/dist/core/repl/slash-commands.js +18 -4
  24. package/dist/core/settings.js +43 -0
  25. package/dist/core/subagents/dispatcher-real.js +600 -0
  26. package/dist/core/subagents/dispatcher.js +113 -24
  27. package/dist/core/subagents/index.js +18 -5
  28. package/dist/core/subagents/isolation-matrix.js +213 -0
  29. package/dist/core/subagents/spawn.js +19 -4
  30. package/dist/core/transport/version-interceptor.js +166 -0
  31. package/dist/index.js +28 -0
  32. package/dist/runtime/bootstrap.js +190 -0
  33. package/dist/runtime/cli.js +859 -269
  34. package/dist/runtime/commands/lsp.js +165 -5
  35. package/dist/runtime/commands/mcp.js +537 -0
  36. package/dist/runtime/commands/review-consensus.js +17 -2
  37. package/dist/runtime/headless.js +543 -0
  38. package/dist/runtime/load-hooks-or-exit.js +71 -0
  39. package/dist/runtime/version.js +65 -0
  40. package/dist/tools/agent-tool.js +192 -0
  41. package/dist/tools/apply-patch.js +62 -1
  42. package/dist/tools/mcp-tool.js +260 -0
  43. package/dist/tools/multi-edit.js +361 -0
  44. package/dist/tools/registry.js +5 -0
  45. package/dist/tools/web-fetch.js +147 -2
  46. package/dist/tools/web-search.js +458 -0
  47. package/dist/tui/agent-tree.js +10 -0
  48. package/dist/tui/ask-modal.js +2 -2
  49. package/dist/tui/conversation-pane.js +1 -1
  50. package/dist/tui/input-box.js +1 -1
  51. package/dist/tui/markdown-render.js +4 -4
  52. package/dist/tui/repl-render.js +105 -15
  53. package/dist/tui/repl-splash.js +2 -2
  54. package/dist/tui/repl.js +10 -4
  55. package/dist/tui/splash.js +1 -1
  56. package/dist/tui/status-bar.js +94 -16
  57. package/dist/tui/update-banner.js +20 -2
  58. 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