@pugi/cli 0.1.0-beta.5 → 0.1.0-beta.51

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 (264) hide show
  1. package/THIRD_PARTY_NOTICES.md +40 -0
  2. package/assets/pugi-mascot.ansi +15 -25
  3. package/assets/pugi-prozr2-mascot.ansi +9 -0
  4. package/bin/run.js +33 -1
  5. package/dist/commands/jobs-watch.js +201 -0
  6. package/dist/commands/jobs.js +15 -0
  7. package/dist/commands/smoke.js +133 -0
  8. package/dist/core/agent-progress/cleanup.js +134 -0
  9. package/dist/core/agent-progress/schema.js +144 -0
  10. package/dist/core/agent-progress/writer.js +101 -0
  11. package/dist/core/artifact-chain/dispatcher.js +148 -0
  12. package/dist/core/artifact-chain/exporter.js +164 -0
  13. package/dist/core/artifact-chain/state.js +243 -0
  14. package/dist/core/artifact-chain/steps.js +169 -0
  15. package/dist/core/auth/ensure-authenticated.js +129 -0
  16. package/dist/core/auth/env-provider.js +238 -0
  17. package/dist/core/auto-update/channels.js +122 -0
  18. package/dist/core/auto-update/checker.js +241 -0
  19. package/dist/core/auto-update/state.js +235 -0
  20. package/dist/core/bare-mode/index.js +107 -0
  21. package/dist/core/bash-classifier.js +400 -4
  22. package/dist/core/checkpoint/resumer.js +149 -0
  23. package/dist/core/checkpoint/rewinder.js +291 -0
  24. package/dist/core/codegraph/decision-store.js +248 -0
  25. package/dist/core/codegraph/detect-repo.js +459 -0
  26. package/dist/core/codegraph/install.js +134 -0
  27. package/dist/core/codegraph/offer-hook.js +220 -0
  28. package/dist/core/compact/auto-trigger.js +96 -0
  29. package/dist/core/compact/buffer-rewriter.js +115 -0
  30. package/dist/core/compact/summarizer.js +208 -0
  31. package/dist/core/compact/token-counter.js +108 -0
  32. package/dist/core/consensus/diff-capture.js +112 -3
  33. package/dist/core/context/index.js +7 -0
  34. package/dist/core/context/markdown-traverse.js +255 -0
  35. package/dist/core/cost/rate-card.js +129 -0
  36. package/dist/core/cost/tracker.js +221 -0
  37. package/dist/core/denial-tracking/index.js +8 -0
  38. package/dist/core/denial-tracking/state.js +264 -0
  39. package/dist/core/diagnostics/probe-runner.js +93 -0
  40. package/dist/core/diagnostics/probes/api.js +46 -0
  41. package/dist/core/diagnostics/probes/auth.js +86 -0
  42. package/dist/core/diagnostics/probes/bare-mode.js +42 -0
  43. package/dist/core/diagnostics/probes/cli-version.js +127 -0
  44. package/dist/core/diagnostics/probes/config.js +72 -0
  45. package/dist/core/diagnostics/probes/denial-tracking.js +57 -0
  46. package/dist/core/diagnostics/probes/disk.js +81 -0
  47. package/dist/core/diagnostics/probes/git.js +65 -0
  48. package/dist/core/diagnostics/probes/hooks.js +118 -0
  49. package/dist/core/diagnostics/probes/mcp.js +75 -0
  50. package/dist/core/diagnostics/probes/node.js +59 -0
  51. package/dist/core/diagnostics/probes/pnpm.js +36 -0
  52. package/dist/core/diagnostics/probes/pugi-md.js +89 -0
  53. package/dist/core/diagnostics/probes/sandbox.js +40 -0
  54. package/dist/core/diagnostics/probes/session.js +74 -0
  55. package/dist/core/diagnostics/probes/status-snapshot.js +488 -0
  56. package/dist/core/diagnostics/probes/workspace.js +63 -0
  57. package/dist/core/diagnostics/types.js +70 -0
  58. package/dist/core/dispatch/cache-cleanup.js +197 -0
  59. package/dist/core/dispatch/cache-handoff.js +295 -0
  60. package/dist/core/edits/dispatch.js +218 -2
  61. package/dist/core/edits/journal.js +199 -0
  62. package/dist/core/edits/layer-d-ast.js +557 -14
  63. package/dist/core/edits/verify-hook.js +273 -0
  64. package/dist/core/edits/worktree.js +322 -0
  65. package/dist/core/engine/anvil-client.js +115 -5
  66. package/dist/core/engine/auto-compact.js +179 -0
  67. package/dist/core/engine/budgets.js +155 -0
  68. package/dist/core/engine/context-prefix.js +155 -0
  69. package/dist/core/engine/intent.js +260 -0
  70. package/dist/core/engine/native-pugi.js +897 -211
  71. package/dist/core/engine/prompts.js +88 -2
  72. package/dist/core/engine/strip-internal-fields.js +124 -0
  73. package/dist/core/engine/tool-bridge.js +1045 -36
  74. package/dist/core/feedback/queue.js +177 -0
  75. package/dist/core/feedback/submitter.js +145 -0
  76. package/dist/core/file-cache.js +113 -1
  77. package/dist/core/hooks/events.js +44 -0
  78. package/dist/core/hooks/index.js +15 -0
  79. package/dist/core/hooks/registry.js +213 -0
  80. package/dist/core/hooks/runner.js +236 -0
  81. package/dist/core/hooks/v2/event-emitter.js +115 -0
  82. package/dist/core/hooks/v2/executor.js +282 -0
  83. package/dist/core/hooks/v2/index.js +25 -0
  84. package/dist/core/hooks/v2/lifecycle.js +104 -0
  85. package/dist/core/hooks/v2/loader.js +216 -0
  86. package/dist/core/hooks/v2/matcher.js +125 -0
  87. package/dist/core/hooks/v2/trust.js +143 -0
  88. package/dist/core/hooks/v2/types.js +86 -0
  89. package/dist/core/lsp/cache.js +105 -0
  90. package/dist/core/lsp/client.js +776 -0
  91. package/dist/core/lsp/language-detect.js +66 -0
  92. package/dist/core/lsp/post-edit-diagnostics.js +171 -0
  93. package/dist/core/mcp/client.js +75 -6
  94. package/dist/core/mcp/http-server.js +553 -0
  95. package/dist/core/mcp/orchestrator-tools.js +662 -0
  96. package/dist/core/mcp/permission.js +190 -0
  97. package/dist/core/mcp/registry.js +24 -2
  98. package/dist/core/mcp/server-tools.js +219 -0
  99. package/dist/core/mcp/server.js +397 -0
  100. package/dist/core/memory/dual-write.js +416 -0
  101. package/dist/core/memory/phase1-kinds.js +20 -0
  102. package/dist/core/memory-sync/queue.js +158 -0
  103. package/dist/core/onboarding/ensure-initialized.js +133 -0
  104. package/dist/core/onboarding/marker.js +111 -0
  105. package/dist/core/onboarding/telemetry-state.js +108 -0
  106. package/dist/core/output-style/presets.js +176 -0
  107. package/dist/core/output-style/state.js +185 -0
  108. package/dist/core/path-security.js +284 -2
  109. package/dist/core/permissions/auto-classifier.js +124 -0
  110. package/dist/core/permissions/circuit-breaker.js +83 -0
  111. package/dist/core/permissions/gate.js +278 -0
  112. package/dist/core/permissions/index.js +20 -0
  113. package/dist/core/permissions/mode.js +174 -0
  114. package/dist/core/permissions/state.js +241 -0
  115. package/dist/core/permissions/tool-class.js +93 -0
  116. package/dist/core/prd-check/parser.js +215 -0
  117. package/dist/core/prd-check/reporter.js +127 -0
  118. package/dist/core/prd-check/session-review.js +557 -0
  119. package/dist/core/prd-check/verifiers.js +223 -0
  120. package/dist/core/pugi-md/context-injector.js +76 -0
  121. package/dist/core/pugi-md/walk-up.js +207 -0
  122. package/dist/core/release-notes/parser.js +241 -0
  123. package/dist/core/release-notes/state.js +116 -0
  124. package/dist/core/repl/history.js +11 -1
  125. package/dist/core/repl/model-pricing.js +135 -0
  126. package/dist/core/repl/session.js +1897 -37
  127. package/dist/core/repl/slash-commands.js +430 -15
  128. package/dist/core/repl/store/session-store.js +31 -2
  129. package/dist/core/repl/workspace-context.js +22 -0
  130. package/dist/core/repo-map/build.js +125 -0
  131. package/dist/core/repo-map/cache.js +185 -0
  132. package/dist/core/repo-map/extractor.js +254 -0
  133. package/dist/core/repo-map/formatter.js +145 -0
  134. package/dist/core/repo-map/scanner.js +211 -0
  135. package/dist/core/retry-budget/budget.js +284 -0
  136. package/dist/core/retry-budget/index.js +5 -0
  137. package/dist/core/session.js +92 -0
  138. package/dist/core/settings.js +80 -0
  139. package/dist/core/share/formatter.js +271 -0
  140. package/dist/core/share/redactor.js +221 -0
  141. package/dist/core/share/uploader.js +267 -0
  142. package/dist/core/skills/defaults.js +457 -0
  143. package/dist/core/smoke/headless-driver.js +174 -0
  144. package/dist/core/smoke/orchestrator.js +194 -0
  145. package/dist/core/smoke/runner.js +238 -0
  146. package/dist/core/smoke/scenario-parser.js +316 -0
  147. package/dist/core/subagents/dispatcher-real.js +600 -0
  148. package/dist/core/subagents/dispatcher.js +113 -24
  149. package/dist/core/subagents/index.js +18 -5
  150. package/dist/core/subagents/isolation-matrix.js +213 -0
  151. package/dist/core/subagents/spawn.js +19 -4
  152. package/dist/core/telemetry/emitter.js +229 -0
  153. package/dist/core/telemetry/queue.js +251 -0
  154. package/dist/core/theme/context.js +91 -0
  155. package/dist/core/theme/presets.js +228 -0
  156. package/dist/core/theme/state.js +181 -0
  157. package/dist/core/todos/invariant.js +10 -0
  158. package/dist/core/todos/state.js +177 -0
  159. package/dist/core/transport/version-interceptor.js +166 -0
  160. package/dist/core/vim/keymap.js +288 -0
  161. package/dist/core/vim/state.js +92 -0
  162. package/dist/core/worktree-manager/cleanup.js +123 -0
  163. package/dist/core/worktree-manager/manager.js +303 -0
  164. package/dist/index.js +28 -0
  165. package/dist/runtime/bootstrap.js +190 -0
  166. package/dist/runtime/cli.js +3241 -343
  167. package/dist/runtime/commands/cancel.js +231 -0
  168. package/dist/runtime/commands/chain.js +489 -0
  169. package/dist/runtime/commands/codegraph-status.js +227 -0
  170. package/dist/runtime/commands/compact.js +297 -0
  171. package/dist/runtime/commands/cost.js +199 -0
  172. package/dist/runtime/commands/delegate.js +242 -11
  173. package/dist/runtime/commands/dispatch.js +126 -0
  174. package/dist/runtime/commands/doctor.js +412 -0
  175. package/dist/runtime/commands/feedback.js +184 -0
  176. package/dist/runtime/commands/hooks.js +184 -0
  177. package/dist/runtime/commands/lsp.js +368 -0
  178. package/dist/runtime/commands/mcp.js +879 -0
  179. package/dist/runtime/commands/memory.js +508 -0
  180. package/dist/runtime/commands/model.js +237 -0
  181. package/dist/runtime/commands/onboarding.js +275 -0
  182. package/dist/runtime/commands/patch.js +128 -0
  183. package/dist/runtime/commands/permissions.js +112 -0
  184. package/dist/runtime/commands/plan.js +143 -0
  185. package/dist/runtime/commands/prd-check.js +285 -0
  186. package/dist/runtime/commands/redo-blob-store.js +92 -0
  187. package/dist/runtime/commands/redo.js +361 -0
  188. package/dist/runtime/commands/release-notes.js +229 -0
  189. package/dist/runtime/commands/repo-map.js +95 -0
  190. package/dist/runtime/commands/report.js +299 -0
  191. package/dist/runtime/commands/resume.js +118 -0
  192. package/dist/runtime/commands/review-consensus.js +17 -2
  193. package/dist/runtime/commands/rewind.js +333 -0
  194. package/dist/runtime/commands/sessions.js +163 -0
  195. package/dist/runtime/commands/share.js +316 -0
  196. package/dist/runtime/commands/status.js +186 -0
  197. package/dist/runtime/commands/stickers.js +82 -0
  198. package/dist/runtime/commands/style.js +194 -0
  199. package/dist/runtime/commands/theme.js +196 -0
  200. package/dist/runtime/commands/undo.js +32 -0
  201. package/dist/runtime/commands/update.js +289 -0
  202. package/dist/runtime/commands/vim.js +140 -0
  203. package/dist/runtime/commands/worktree.js +177 -0
  204. package/dist/runtime/commands/worktrees.js +155 -0
  205. package/dist/runtime/headless-repl.js +195 -0
  206. package/dist/runtime/headless.js +543 -0
  207. package/dist/runtime/load-hooks-or-exit.js +71 -0
  208. package/dist/runtime/plan-decompose.js +531 -0
  209. package/dist/runtime/version.js +65 -0
  210. package/dist/tools/agent-tool.js +229 -0
  211. package/dist/tools/apply-patch.js +556 -0
  212. package/dist/tools/ask-user-question.js +213 -0
  213. package/dist/tools/ask-user.js +115 -0
  214. package/dist/tools/bash.js +203 -4
  215. package/dist/tools/file-tools.js +85 -14
  216. package/dist/tools/lsp-tools.js +189 -0
  217. package/dist/tools/mcp-tool.js +260 -0
  218. package/dist/tools/multi-edit.js +361 -0
  219. package/dist/tools/powershell.js +268 -0
  220. package/dist/tools/registry.js +51 -0
  221. package/dist/tools/skill-tool.js +96 -0
  222. package/dist/tools/tasks.js +208 -0
  223. package/dist/tools/todo-write.js +184 -0
  224. package/dist/tools/web-fetch.js +147 -2
  225. package/dist/tools/web-search.js +458 -0
  226. package/dist/tui/agent-progress-card.js +111 -0
  227. package/dist/tui/agent-tree.js +10 -0
  228. package/dist/tui/ask-modal.js +2 -2
  229. package/dist/tui/ask-user-question-prompt.js +192 -0
  230. package/dist/tui/compact-banner.js +81 -0
  231. package/dist/tui/conversation-pane.js +82 -8
  232. package/dist/tui/cost-table.js +111 -0
  233. package/dist/tui/doctor-table.js +46 -0
  234. package/dist/tui/feedback-prompt.js +156 -0
  235. package/dist/tui/input-box.js +218 -3
  236. package/dist/tui/markdown-render.js +4 -4
  237. package/dist/tui/onboarding-wizard.js +240 -0
  238. package/dist/tui/permissions-picker.js +86 -0
  239. package/dist/tui/render.js +35 -0
  240. package/dist/tui/repl-render.js +313 -35
  241. package/dist/tui/repl-splash-art.js +1 -1
  242. package/dist/tui/repl-splash-mascot.js +32 -8
  243. package/dist/tui/repl-splash.js +2 -2
  244. package/dist/tui/repl.js +85 -5
  245. package/dist/tui/splash.js +1 -1
  246. package/dist/tui/status-bar.js +94 -16
  247. package/dist/tui/status-table.js +7 -0
  248. package/dist/tui/stickers-art.js +136 -0
  249. package/dist/tui/style-table.js +28 -0
  250. package/dist/tui/theme-table.js +29 -0
  251. package/dist/tui/thinking-spinner.js +123 -0
  252. package/dist/tui/tool-stream-pane.js +52 -3
  253. package/dist/tui/update-banner.js +27 -2
  254. package/dist/tui/vim-input.js +267 -0
  255. package/dist/tui/welcome-banner.js +107 -0
  256. package/dist/tui/welcome-data.js +293 -0
  257. package/docs/examples/codegraph.mcp.json +10 -0
  258. package/package.json +13 -7
  259. package/test/scenarios/codegen-create-file.scenario.txt +13 -0
  260. package/test/scenarios/compact-force.scenario.txt +11 -0
  261. package/test/scenarios/identity.scenario.txt +11 -0
  262. package/test/scenarios/persona-handoff.scenario.txt +11 -0
  263. package/test/scenarios/walkback.scenario.txt +12 -0
  264. package/dist/core/engine/compaction-hook.js +0 -154
@@ -1,9 +1,37 @@
1
+ /**
2
+ * file-tools - Pugi CLI file/bash/glob/grep tool surface.
3
+ *
4
+ * Workspace-binding contract (CEO red-alert 2026-05-27 follow-up):
5
+ *
6
+ * Every tool dispatch path threads `ctx.root` from the operator's
7
+ * `process.cwd()` through `EngineTask.workspaceRoot` ->
8
+ * `native-pugi.run()` -> `toolCtx.root` -> here. Tools call
9
+ * `resolveWorkspacePath(ctx.root, path)` for every on-disk operation
10
+ * so a dispatched specialist (e.g. Hiroshi writing tic-tac-toe HTML)
11
+ * produces files in the OPERATOR'S cwd, never in a server-side temp
12
+ * space. The path-security gate refuses traversal (`../etc/passwd`,
13
+ * URL-encoded variants, symlink escapes at the target).
14
+ *
15
+ * Wiring chain:
16
+ * 1. runtime/cli.ts: workspaceRoot = process.cwd()
17
+ * 2. EngineTask.workspaceRoot threads through to native-pugi.run().
18
+ * 3. native-pugi: const root = task.workspaceRoot
19
+ * 4. tool-bridge: passes ctx.root to file-tools / bash.
20
+ * 5. file-tools: resolveWorkspacePath(ctx.root, path).
21
+ *
22
+ * The contract is locked by `test/tools-write-to-workspace.spec.ts`
23
+ * (6 cases covering relative + nested + absolute paths + traversal
24
+ * refusal). If any layer of the chain regressed silently, dispatched
25
+ * files would land in `/tmp` instead of the operator's repo, which
26
+ * is the same failure surface as the menu-mode anti-pattern the
27
+ * sibling commits close.
28
+ */
1
29
  import { spawnSync } from 'node:child_process';
2
- import { existsSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
30
+ import { existsSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from 'node:fs';
3
31
  import { dirname, isAbsolute, relative } from 'node:path';
4
32
  import { globSync } from 'node:fs';
5
33
  import { decidePermission } from '../core/permission.js';
6
- import { createReadRecord, hashContent } from '../core/file-cache.js';
34
+ import { StaleReadError, createReadRecord, hashContent, } from '../core/file-cache.js';
7
35
  import { resolveWorkspacePath } from '../core/path-security.js';
8
36
  import { recordFileMutation, recordToolCall, recordToolResult } from '../core/session.js';
9
37
  /**
@@ -19,6 +47,11 @@ export class OperatorAbortedError extends Error {
19
47
  this.name = 'OperatorAbortedError';
20
48
  }
21
49
  }
50
+ // Re-export StaleReadError so tool-bridge / test consumers can import
51
+ // the typed error from a single file-tools surface alongside
52
+ // OperatorAbortedError. Same shape as the existing OperatorAbortedError
53
+ // re-surface pattern.
54
+ export { StaleReadError } from '../core/file-cache.js';
22
55
  /**
23
56
  * α6.9 WriteGate: refuse the tool dispatch when the active
24
57
  * cancellation token has aborted. Idempotent (the token's `isAborted`
@@ -124,10 +157,37 @@ export function writeTool(ctx, path, content) {
124
157
  throw error;
125
158
  }
126
159
  const existed = existsSync(resolved);
127
- const before = existed ? readFileSync(resolved, 'utf8') : undefined;
160
+ // Leak L1 stale-read gate for writeTool's update-existing path. The
161
+ // model uses writeTool for two distinct intents:
162
+ //
163
+ // - create-new: path does not exist on disk. There is no prior
164
+ // read to validate against; skip the gate. This is the
165
+ // intentional escape hatch the leak spec also calls out.
166
+ // - overwrite-existing: path exists. Without the gate the model
167
+ // could blind-clobber an externally-modified file, losing the
168
+ // concurrent change silently. Force the model to re-read first.
169
+ //
170
+ // We deliberately apply the SAME stale-validation primitive editTool
171
+ // uses so the two write surfaces stay symmetric and a future fix to
172
+ // either one cannot accidentally weaken the other.
173
+ let before;
174
+ if (existed) {
175
+ before = readFileSync(resolved, 'utf8');
176
+ const currentStat = statSync(resolved);
177
+ const validation = ctx.readCache.validate(ctx.root, path, currentStat.mtimeMs, before);
178
+ if (validation.stale) {
179
+ const reason = `stale_read: write ${path} refused — ${validation.detail}`;
180
+ recordToolResult(ctx.session, toolCallId, 'error', reason);
181
+ throw new StaleReadError(path, validation.reason, validation.detail);
182
+ }
183
+ }
128
184
  const tmp = `${resolved}.pugi-tmp-${Date.now()}`;
129
185
  writeFileSync(tmp, content, { encoding: 'utf8', mode: 0o600 });
130
186
  renameSync(tmp, resolved);
187
+ // Refresh the cache with the post-write content so the model can
188
+ // chain a follow-up read+edit on the same file without an extra
189
+ // round-trip. Same pattern editTool uses below.
190
+ ctx.readCache.set(createReadRecord(ctx.root, path, content, 'read_tool'));
131
191
  recordFileMutation(ctx.session, {
132
192
  toolCallId,
133
193
  path,
@@ -154,10 +214,6 @@ export function editTool(ctx, path, oldString, newString) {
154
214
  recordToolResult(ctx.session, toolCallId, 'error', reason);
155
215
  throw new Error(reason);
156
216
  }
157
- const readRecord = ctx.readCache.get(ctx.root, path);
158
- if (!readRecord) {
159
- throw new Error(`Cannot edit ${path}: file must be read first`);
160
- }
161
217
  let resolved;
162
218
  try {
163
219
  resolved = permissionGatedResolve(ctx, path, 'edit', 'edit');
@@ -167,16 +223,31 @@ export function editTool(ctx, path, oldString, newString) {
167
223
  recordToolResult(ctx.session, toolCallId, 'error', reason);
168
224
  throw error;
169
225
  }
226
+ // Leak L1 stale-read gate. Validate the model's read-time view of
227
+ // the file against the on-disk state BEFORE applying the mutation.
228
+ // We read disk content once and feed it to the validator so a single
229
+ // syscall covers both the gate decision AND the oldString/newString
230
+ // replacement below.
170
231
  const before = readFileSync(resolved, 'utf8');
171
- const currentHash = hashContent(before);
172
- if (currentHash !== readRecord.sha256) {
173
- throw new Error(`Cannot edit ${path}: file changed since last read`);
232
+ const currentStat = statSync(resolved);
233
+ const validation = ctx.readCache.validate(ctx.root, path, currentStat.mtimeMs, before);
234
+ if (validation.stale) {
235
+ const reason = `stale_read: edit ${path} refused — ${validation.detail}`;
236
+ recordToolResult(ctx.session, toolCallId, 'error', reason);
237
+ throw new StaleReadError(path, validation.reason, validation.detail);
174
238
  }
239
+ const currentHash = hashContent(before);
175
240
  const matches = before.split(oldString).length - 1;
176
- if (matches === 0)
177
- throw new Error(`Cannot edit ${path}: oldString not found`);
178
- if (matches > 1)
179
- throw new Error(`Cannot edit ${path}: oldString is not unique`);
241
+ if (matches === 0) {
242
+ const reason = `Cannot edit ${path}: oldString not found`;
243
+ recordToolResult(ctx.session, toolCallId, 'error', reason);
244
+ throw new Error(reason);
245
+ }
246
+ if (matches > 1) {
247
+ const reason = `Cannot edit ${path}: oldString is not unique`;
248
+ recordToolResult(ctx.session, toolCallId, 'error', reason);
249
+ throw new Error(reason);
250
+ }
180
251
  const after = before.replace(oldString, newString);
181
252
  const tmp = `${resolved}.pugi-tmp-${Date.now()}`;
182
253
  writeFileSync(tmp, after, { encoding: 'utf8', mode: 0o600 });
@@ -0,0 +1,189 @@
1
+ import { gateOnCancellation, OperatorAbortedError } from './file-tools.js';
2
+ import { recordToolCall, recordToolResult } from '../core/session.js';
3
+ /** Cap for any single LSP tool's payload size. Keeps model context lean. */
4
+ const LSP_PAYLOAD_CAP_BYTES = 8 * 1024;
5
+ export async function lspHover(ctx, lang, file, line, col) {
6
+ const toolCallId = recordToolCall(ctx.session, 'lsp_hover', `${lang}:${file}:${line}:${col}`);
7
+ return guard(ctx, 'lsp_hover', toolCallId, async () => {
8
+ const client = ctx.lspClients?.get(lang);
9
+ if (!client)
10
+ return unavailable(lang);
11
+ const result = await client.hover(file, { line, character: col }, ctx.cancellation);
12
+ if (!result.ok)
13
+ return failure(result);
14
+ if (!result.value) {
15
+ return { ok: true, value: { content: '' } };
16
+ }
17
+ const content = truncate(result.value.content);
18
+ return {
19
+ ok: true,
20
+ value: {
21
+ content: content.text,
22
+ ...(result.value.range ? { range: result.value.range } : {}),
23
+ },
24
+ ...(content.truncated ? { truncated: true } : {}),
25
+ };
26
+ });
27
+ }
28
+ export async function lspDefinition(ctx, lang, file, line, col) {
29
+ const toolCallId = recordToolCall(ctx.session, 'lsp_definition', `${lang}:${file}:${line}:${col}`);
30
+ return guard(ctx, 'lsp_definition', toolCallId, async () => {
31
+ const client = ctx.lspClients?.get(lang);
32
+ if (!client)
33
+ return unavailable(lang);
34
+ const result = await client.definition(file, { line, character: col }, ctx.cancellation);
35
+ if (!result.ok)
36
+ return failure(result);
37
+ const capped = capLocations(result.value);
38
+ return {
39
+ ok: true,
40
+ value: capped.value,
41
+ ...(capped.truncated ? { truncated: true } : {}),
42
+ };
43
+ });
44
+ }
45
+ export async function lspReferences(ctx, lang, file, line, col) {
46
+ const toolCallId = recordToolCall(ctx.session, 'lsp_references', `${lang}:${file}:${line}:${col}`);
47
+ return guard(ctx, 'lsp_references', toolCallId, async () => {
48
+ const client = ctx.lspClients?.get(lang);
49
+ if (!client)
50
+ return unavailable(lang);
51
+ const result = await client.references(file, { line, character: col }, ctx.cancellation);
52
+ if (!result.ok)
53
+ return failure(result);
54
+ const capped = capLocations(result.value);
55
+ return {
56
+ ok: true,
57
+ value: capped.value,
58
+ ...(capped.truncated ? { truncated: true } : {}),
59
+ };
60
+ });
61
+ }
62
+ export async function lspDiagnostics(ctx, lang, file) {
63
+ const toolCallId = recordToolCall(ctx.session, 'lsp_diagnostics', `${lang}:${file}`);
64
+ return guard(ctx, 'lsp_diagnostics', toolCallId, async () => {
65
+ const client = ctx.lspClients?.get(lang);
66
+ if (!client)
67
+ return unavailable(lang);
68
+ const result = await client.diagnostics(file, ctx.cancellation);
69
+ if (!result.ok)
70
+ return failure(result);
71
+ const capped = capDiagnostics(result.value);
72
+ return {
73
+ ok: true,
74
+ value: capped.value,
75
+ ...(capped.truncated ? { truncated: true } : {}),
76
+ };
77
+ });
78
+ }
79
+ async function guard(ctx, toolName, toolCallId, op) {
80
+ try {
81
+ gateOnCancellation(ctx, toolName);
82
+ }
83
+ catch (error) {
84
+ if (error instanceof OperatorAbortedError) {
85
+ recordToolResult(ctx.session, toolCallId, 'cancelled', error.message);
86
+ return { ok: false, reason: 'operator_aborted', detail: error.message };
87
+ }
88
+ throw error;
89
+ }
90
+ try {
91
+ const result = await op();
92
+ if (result.ok) {
93
+ recordToolResult(ctx.session, toolCallId, 'success', summarize(result.value));
94
+ }
95
+ else {
96
+ recordToolResult(ctx.session, toolCallId, 'error', `${result.reason ?? 'error'}: ${result.detail ?? ''}`);
97
+ }
98
+ return result;
99
+ }
100
+ catch (error) {
101
+ const message = error instanceof Error ? error.message : String(error);
102
+ recordToolResult(ctx.session, toolCallId, 'error', message);
103
+ return { ok: false, reason: 'lsp_error', detail: message };
104
+ }
105
+ }
106
+ function unavailable(lang) {
107
+ return {
108
+ ok: false,
109
+ reason: 'lsp_unavailable',
110
+ detail: `no LSP server started for ${lang}. Install the server and re-run ` +
111
+ `with --lsp ${lang}, or fall back to grep.`,
112
+ };
113
+ }
114
+ function failure(result) {
115
+ if (result.ok) {
116
+ // Shouldn't be hit — caller checks first.
117
+ return { ok: true, value: result.value };
118
+ }
119
+ return { ok: false, reason: result.reason, detail: result.detail };
120
+ }
121
+ function summarize(value) {
122
+ if (value === null || value === undefined)
123
+ return 'no result';
124
+ if (Array.isArray(value))
125
+ return `${value.length} items`;
126
+ if (typeof value === 'object')
127
+ return Object.keys(value).join(',');
128
+ return String(value);
129
+ }
130
+ function truncate(text) {
131
+ const bytes = Buffer.byteLength(text, 'utf8');
132
+ if (bytes <= LSP_PAYLOAD_CAP_BYTES)
133
+ return { text, truncated: false };
134
+ // Truncate to the cap byte boundary. We don't try to honor codepoint
135
+ // alignment — UTF-8 surrogate splits show up as a single ? at the
136
+ // boundary, which is acceptable for a debug surface; the dispatcher
137
+ // is the trust boundary for "this is what the model will see".
138
+ const buf = Buffer.from(text, 'utf8').subarray(0, LSP_PAYLOAD_CAP_BYTES);
139
+ return { text: `${buf.toString('utf8')}\n... [truncated]`, truncated: true };
140
+ }
141
+ function capLocations(locations) {
142
+ // Cap at 200 locations OR the byte cap, whichever hits first. The
143
+ // 200 number is the operator-facing "this is a hot symbol" threshold —
144
+ // a richer surface (paginated `pugi lsp references --offset N`) is
145
+ // open backlog.
146
+ const COUNT_CAP = 200;
147
+ if (locations.length === 0)
148
+ return { value: locations, truncated: false };
149
+ const trimmed = locations.slice(0, COUNT_CAP);
150
+ const serialized = JSON.stringify(trimmed);
151
+ if (Buffer.byteLength(serialized, 'utf8') <= LSP_PAYLOAD_CAP_BYTES && trimmed.length === locations.length) {
152
+ return { value: trimmed, truncated: false };
153
+ }
154
+ // Trim by halves until we fit the byte cap. Worst case ~10 iterations
155
+ // for the 200 max, fine for an interactive tool.
156
+ let upper = trimmed.length;
157
+ while (upper > 1) {
158
+ const half = Math.floor(upper / 2);
159
+ const sub = trimmed.slice(0, half);
160
+ if (Buffer.byteLength(JSON.stringify(sub), 'utf8') <= LSP_PAYLOAD_CAP_BYTES) {
161
+ return { value: sub, truncated: true };
162
+ }
163
+ upper = half;
164
+ }
165
+ return { value: trimmed.slice(0, 1), truncated: true };
166
+ }
167
+ function capDiagnostics(items) {
168
+ if (items.length === 0)
169
+ return { value: items, truncated: false };
170
+ const serialized = JSON.stringify(items);
171
+ if (Buffer.byteLength(serialized, 'utf8') <= LSP_PAYLOAD_CAP_BYTES) {
172
+ return { value: items, truncated: false };
173
+ }
174
+ // Diagnostics are sorted error-first in LSP convention; trim from the
175
+ // tail so we keep the highest-severity items.
176
+ let upper = items.length;
177
+ while (upper > 1) {
178
+ const half = Math.floor(upper / 2);
179
+ const sub = items.slice(0, half);
180
+ if (Buffer.byteLength(JSON.stringify(sub), 'utf8') <= LSP_PAYLOAD_CAP_BYTES) {
181
+ return { value: sub, truncated: true };
182
+ }
183
+ upper = half;
184
+ }
185
+ return { value: items.slice(0, 1), truncated: true };
186
+ }
187
+ /** Test-only surface so specs can poke truncation directly. */
188
+ export const __test__ = { truncate, capLocations, capDiagnostics, LSP_PAYLOAD_CAP_BYTES };
189
+ //# sourceMappingURL=lsp-tools.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