@oh-my-pi/pi-coding-agent 17.1.8 → 17.2.1

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 (246) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/dist/{CHANGELOG-vt8ene9g.md → CHANGELOG-dj46zzrm.md} +110 -0
  3. package/dist/cli.js +10009 -9654
  4. package/dist/{template-dys3vk5b.js → template-8vdv6xb4.js} +1 -1
  5. package/dist/types/advisor/runtime.d.ts +8 -2
  6. package/dist/types/advisor/transcript-recorder.d.ts +15 -0
  7. package/dist/types/auto-thinking/classifier.d.ts +9 -2
  8. package/dist/types/cli/args.d.ts +2 -0
  9. package/dist/types/cli/session-picker.d.ts +14 -7
  10. package/dist/types/collab/guest.d.ts +21 -2
  11. package/dist/types/commands/launch.d.ts +6 -0
  12. package/dist/types/config/model-discovery.d.ts +12 -0
  13. package/dist/types/config/settings-schema.d.ts +73 -8
  14. package/dist/types/cursor-bridge-tools.d.ts +54 -0
  15. package/dist/types/cursor.d.ts +193 -8
  16. package/dist/types/edit/edit-clipboard.d.ts +19 -0
  17. package/dist/types/edit/hashline/diff.d.ts +9 -1
  18. package/dist/types/edit/index.d.ts +8 -1
  19. package/dist/types/edit/streaming.d.ts +8 -1
  20. package/dist/types/extensibility/extensions/runner.d.ts +19 -2
  21. package/dist/types/extensibility/extensions/types.d.ts +25 -1
  22. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +25 -0
  23. package/dist/types/internal-urls/index.d.ts +1 -0
  24. package/dist/types/internal-urls/local-protocol.d.ts +3 -2
  25. package/dist/types/internal-urls/security-protocol.d.ts +15 -0
  26. package/dist/types/internal-urls/types.d.ts +1 -1
  27. package/dist/types/launch/terminal-output-worker-client.d.ts +5 -0
  28. package/dist/types/launch/terminal-output-worker-protocol.d.ts +14 -0
  29. package/dist/types/launch/terminal-output-worker.d.ts +1 -0
  30. package/dist/types/lsp/client.d.ts +2 -0
  31. package/dist/types/main.d.ts +2 -0
  32. package/dist/types/mcp/manager.d.ts +33 -4
  33. package/dist/types/modes/components/codex-reset-fireworks.d.ts +51 -0
  34. package/dist/types/modes/components/read-tool-group.d.ts +12 -0
  35. package/dist/types/modes/components/session-selector.d.ts +6 -0
  36. package/dist/types/modes/components/status-line/component.d.ts +23 -0
  37. package/dist/types/modes/components/tool-execution.d.ts +16 -7
  38. package/dist/types/modes/components/usage-row.d.ts +2 -0
  39. package/dist/types/modes/controllers/mcp-command-controller.d.ts +26 -1
  40. package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
  41. package/dist/types/modes/interactive-mode.d.ts +4 -2
  42. package/dist/types/modes/rpc/rpc-client.d.ts +7 -0
  43. package/dist/types/modes/rpc/rpc-types.d.ts +16 -0
  44. package/dist/types/modes/theme/theme.d.ts +12 -1
  45. package/dist/types/modes/types.d.ts +2 -1
  46. package/dist/types/sdk.d.ts +18 -0
  47. package/dist/types/security/auth.d.ts +30 -0
  48. package/dist/types/security/cloud.d.ts +79 -0
  49. package/dist/types/security/comparison.d.ts +49 -0
  50. package/dist/types/security/contracts/ids.d.ts +15 -0
  51. package/dist/types/security/contracts/index.d.ts +4 -0
  52. package/dist/types/security/contracts/schemas.d.ts +660 -0
  53. package/dist/types/security/contracts/types.d.ts +234 -0
  54. package/dist/types/security/contracts/validation.d.ts +5 -0
  55. package/dist/types/security/coordinator.d.ts +100 -0
  56. package/dist/types/security/importers/codex-security.d.ts +7 -0
  57. package/dist/types/security/importers/index.d.ts +2 -0
  58. package/dist/types/security/importers/sarif.d.ts +9 -0
  59. package/dist/types/security/index.d.ts +13 -0
  60. package/dist/types/security/preflight.d.ts +61 -0
  61. package/dist/types/security/provenance.d.ts +24 -0
  62. package/dist/types/security/publication.d.ts +78 -0
  63. package/dist/types/security/remediation.d.ts +27 -0
  64. package/dist/types/security/resource-output.d.ts +8 -0
  65. package/dist/types/security/sarif.d.ts +2 -0
  66. package/dist/types/security/store.d.ts +40 -0
  67. package/dist/types/session/agent-session-events.d.ts +2 -0
  68. package/dist/types/session/agent-session-types.d.ts +36 -1
  69. package/dist/types/session/agent-session.d.ts +4 -2
  70. package/dist/types/session/claude-session-store.d.ts +13 -0
  71. package/dist/types/session/codex-session-store.d.ts +14 -0
  72. package/dist/types/session/credential-pin.d.ts +58 -0
  73. package/dist/types/session/foreign-session-import.d.ts +15 -0
  74. package/dist/types/session/foreign-session-jsonl.d.ts +9 -0
  75. package/dist/types/session/foreign-session-store.d.ts +23 -0
  76. package/dist/types/session/session-advisors.d.ts +47 -2
  77. package/dist/types/session/session-entries.d.ts +19 -1
  78. package/dist/types/session/session-handoff.d.ts +4 -1
  79. package/dist/types/session/session-loader.d.ts +2 -0
  80. package/dist/types/session/session-maintenance.d.ts +1 -1
  81. package/dist/types/session/session-manager.d.ts +21 -0
  82. package/dist/types/session/session-tools.d.ts +25 -1
  83. package/dist/types/session/turn-recovery.d.ts +10 -4
  84. package/dist/types/slash-commands/helpers/security.d.ts +2 -0
  85. package/dist/types/system-prompt.d.ts +4 -1
  86. package/dist/types/task/executor.d.ts +14 -2
  87. package/dist/types/task/render.d.ts +7 -0
  88. package/dist/types/thinking.d.ts +18 -6
  89. package/dist/types/tools/acp-bridge.d.ts +28 -5
  90. package/dist/types/tools/builtin-names.d.ts +1 -1
  91. package/dist/types/tools/grep.d.ts +17 -1
  92. package/dist/types/tools/hub/launch.d.ts +5 -1
  93. package/dist/types/tools/index.d.ts +10 -2
  94. package/dist/types/tools/path-utils.d.ts +19 -0
  95. package/dist/types/tools/read.d.ts +2 -0
  96. package/dist/types/tools/security-scan.d.ts +96 -0
  97. package/dist/types/tools/xdev.d.ts +2 -1
  98. package/dist/types/utils/changelog.d.ts +19 -0
  99. package/dist/types/utils/git.d.ts +10 -0
  100. package/dist/types/utils/jj.d.ts +11 -4
  101. package/dist/types/web/search/types.d.ts +2 -2
  102. package/package.json +12 -12
  103. package/scripts/security-compare.ts +40 -0
  104. package/src/advisor/runtime.ts +138 -12
  105. package/src/advisor/transcript-recorder.ts +56 -0
  106. package/src/auto-thinking/classifier.ts +48 -10
  107. package/src/cli/args.ts +6 -0
  108. package/src/cli/flag-tables.ts +2 -0
  109. package/src/cli/gallery-fixtures/fs.ts +25 -0
  110. package/src/cli/session-picker.ts +34 -17
  111. package/src/cli/ttsr-cli.ts +19 -1
  112. package/src/cli.ts +9 -0
  113. package/src/collab/guest.ts +37 -4
  114. package/src/collab/host.ts +1 -0
  115. package/src/commands/launch.ts +6 -0
  116. package/src/config/model-discovery.ts +32 -5
  117. package/src/config/model-registry.ts +107 -25
  118. package/src/config/settings-schema.ts +77 -6
  119. package/src/config/settings.ts +25 -0
  120. package/src/cursor-bridge-tools.ts +81 -0
  121. package/src/cursor.ts +463 -12
  122. package/src/edit/edit-clipboard.ts +23 -0
  123. package/src/edit/hashline/diff.ts +49 -10
  124. package/src/edit/hashline/execute.ts +38 -6
  125. package/src/edit/hashline/filesystem.ts +27 -3
  126. package/src/edit/index.ts +12 -2
  127. package/src/edit/renderer.ts +4 -4
  128. package/src/edit/streaming.ts +17 -2
  129. package/src/eval/py/prelude.py +7 -3
  130. package/src/export/html/template.js +1 -1
  131. package/src/extensibility/extensions/runner.ts +70 -0
  132. package/src/extensibility/extensions/types.ts +30 -0
  133. package/src/extensibility/legacy-pi-coding-agent-shim.ts +65 -13
  134. package/src/internal-urls/index.ts +1 -0
  135. package/src/internal-urls/local-protocol.ts +3 -2
  136. package/src/internal-urls/mcp-protocol.ts +4 -1
  137. package/src/internal-urls/router.ts +4 -1
  138. package/src/internal-urls/security-protocol.ts +261 -0
  139. package/src/internal-urls/types.ts +1 -1
  140. package/src/launch/terminal-output-worker-client.ts +53 -0
  141. package/src/launch/terminal-output-worker-protocol.ts +11 -0
  142. package/src/launch/terminal-output-worker.ts +23 -0
  143. package/src/lsp/client.ts +18 -3
  144. package/src/lsp/index.ts +7 -0
  145. package/src/lsp/render.ts +1 -1
  146. package/src/main.ts +127 -56
  147. package/src/mcp/manager.ts +120 -18
  148. package/src/modes/acp/acp-agent.ts +15 -9
  149. package/src/modes/components/chat-transcript-builder.ts +30 -11
  150. package/src/modes/components/codex-reset-fireworks.ts +369 -0
  151. package/src/modes/components/read-tool-group.ts +159 -17
  152. package/src/modes/components/session-selector.ts +19 -3
  153. package/src/modes/components/status-line/component.ts +381 -54
  154. package/src/modes/components/tool-execution.ts +78 -31
  155. package/src/modes/components/usage-row.ts +10 -5
  156. package/src/modes/controllers/event-controller.ts +26 -4
  157. package/src/modes/controllers/mcp-command-controller.ts +68 -3
  158. package/src/modes/controllers/selector-controller.ts +94 -36
  159. package/src/modes/controllers/tan-command-controller.ts +14 -0
  160. package/src/modes/interactive-mode.ts +31 -17
  161. package/src/modes/rpc/host-uris.ts +6 -0
  162. package/src/modes/rpc/rpc-client.ts +18 -0
  163. package/src/modes/rpc/rpc-mode.ts +16 -1
  164. package/src/modes/rpc/rpc-types.ts +11 -0
  165. package/src/modes/theme/theme.ts +151 -6
  166. package/src/modes/types.ts +2 -1
  167. package/src/modes/utils/ui-helpers.ts +38 -21
  168. package/src/prompts/agents/security-reviewer.md +75 -0
  169. package/src/prompts/security/scan-coordinator.md +7 -0
  170. package/src/prompts/security/scan-request.md +21 -0
  171. package/src/prompts/security/validate-request.md +8 -0
  172. package/src/prompts/system/auto-thinking-difficulty.md +4 -2
  173. package/src/prompts/system/plan-mode-active.md +2 -2
  174. package/src/prompts/system/system-prompt.md +6 -0
  175. package/src/prompts/system/xdev-mount-notice.md +1 -1
  176. package/src/prompts/tools/bash.md +15 -17
  177. package/src/prompts/tools/checkpoint.md +1 -1
  178. package/src/prompts/tools/glob.md +7 -6
  179. package/src/prompts/tools/grep.md +7 -6
  180. package/src/prompts/tools/security-publish.md +1 -0
  181. package/src/prompts/tools/security-scan.md +1 -0
  182. package/src/sdk.ts +212 -21
  183. package/src/security/auth.ts +98 -0
  184. package/src/security/cloud.ts +686 -0
  185. package/src/security/comparison.ts +255 -0
  186. package/src/security/contracts/ids.ts +111 -0
  187. package/src/security/contracts/index.ts +4 -0
  188. package/src/security/contracts/schemas.ts +201 -0
  189. package/src/security/contracts/types.ts +254 -0
  190. package/src/security/contracts/validation.ts +65 -0
  191. package/src/security/coordinator.ts +708 -0
  192. package/src/security/importers/codex-security.ts +387 -0
  193. package/src/security/importers/index.ts +2 -0
  194. package/src/security/importers/sarif.ts +357 -0
  195. package/src/security/index.ts +13 -0
  196. package/src/security/preflight.ts +405 -0
  197. package/src/security/provenance.ts +106 -0
  198. package/src/security/publication.ts +326 -0
  199. package/src/security/remediation.ts +93 -0
  200. package/src/security/resource-output.ts +50 -0
  201. package/src/security/sarif.ts +78 -0
  202. package/src/security/store.ts +430 -0
  203. package/src/session/agent-session-events.ts +1 -0
  204. package/src/session/agent-session-types.ts +40 -1
  205. package/src/session/agent-session.ts +302 -175
  206. package/src/session/claude-session-store.ts +426 -0
  207. package/src/session/codex-session-store.ts +673 -0
  208. package/src/session/credential-pin.ts +93 -0
  209. package/src/session/foreign-session-import.ts +52 -0
  210. package/src/session/foreign-session-jsonl.ts +29 -0
  211. package/src/session/foreign-session-store.ts +26 -0
  212. package/src/session/model-controls.ts +11 -3
  213. package/src/session/session-advisors.ts +140 -16
  214. package/src/session/session-entries.ts +21 -1
  215. package/src/session/session-handoff.ts +20 -3
  216. package/src/session/session-loader.ts +25 -9
  217. package/src/session/session-maintenance.ts +59 -11
  218. package/src/session/session-manager.ts +61 -0
  219. package/src/session/session-tools.ts +107 -5
  220. package/src/session/settings-stream-fn.ts +1 -0
  221. package/src/session/turn-recovery.ts +97 -51
  222. package/src/slash-commands/builtin-registry.ts +152 -3
  223. package/src/slash-commands/helpers/security.ts +451 -0
  224. package/src/system-prompt.ts +7 -2
  225. package/src/task/agents.ts +2 -0
  226. package/src/task/executor.ts +20 -4
  227. package/src/task/render.ts +20 -4
  228. package/src/task/structured-subagent.ts +6 -4
  229. package/src/thinking.ts +29 -10
  230. package/src/tools/acp-bridge.ts +52 -8
  231. package/src/tools/browser.ts +5 -0
  232. package/src/tools/builtin-names.ts +1 -0
  233. package/src/tools/checkpoint.ts +0 -13
  234. package/src/tools/grep.ts +64 -8
  235. package/src/tools/hub/launch.ts +10 -15
  236. package/src/tools/index.ts +37 -5
  237. package/src/tools/path-utils.ts +91 -0
  238. package/src/tools/read.ts +3 -0
  239. package/src/tools/security-scan.ts +287 -0
  240. package/src/tools/write.ts +9 -3
  241. package/src/tools/xdev.ts +39 -14
  242. package/src/utils/changelog.ts +144 -4
  243. package/src/utils/git.ts +61 -14
  244. package/src/utils/jj.ts +19 -13
  245. package/src/web/search/providers/codex.ts +34 -0
  246. package/src/web/search/types.ts +1 -1
package/src/cursor.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import * as fs from "node:fs";
3
+ import * as path from "node:path";
3
4
  import type {
4
5
  AgentEvent,
5
6
  AgentTool,
@@ -9,35 +10,91 @@ import type {
9
10
  } from "@oh-my-pi/pi-agent-core";
10
11
  import type {
11
12
  CursorMcpCall,
13
+ CursorMcpResource,
14
+ CursorMcpResourceContent,
12
15
  CursorShellStreamCallbacks,
13
16
  CursorTodoSnapshot,
14
17
  CursorExecHandlers as ICursorExecHandlers,
15
18
  ToolResultMessage,
16
19
  } from "@oh-my-pi/pi-ai";
20
+ import {
21
+ piEscapeRegexLiteral,
22
+ piGrepSkip,
23
+ piJoinPath,
24
+ piLimit,
25
+ piLsPath,
26
+ piReadPath,
27
+ piTimeout,
28
+ } from "@oh-my-pi/pi-ai/providers/cursor/exec-modern";
17
29
  import { sanitizeText } from "@oh-my-pi/pi-utils";
18
- import { resolveToCwd } from "./tools/path-utils";
30
+ import type { MCPResourceReadResult } from "./mcp/types";
31
+ import type { ApprovalMode } from "./tools/approval";
32
+ import { resolveApproval } from "./tools/approval";
33
+ import { confineToWorkspace, resolveToCwd } from "./tools/path-utils";
19
34
  import type { TodoPhase, TodoStatus } from "./tools/todo";
20
35
 
21
36
  /** Phase used for Cursor-owned tasks with no local phase grouping. */
22
37
  const CURSOR_TODO_PHASE = "Tasks";
23
38
 
39
+ /**
40
+ * A tool instance the bridge can run, matching the erased shape the session's
41
+ * tool registry stores. The concrete tools have narrower `execute` parameter
42
+ * types than the default `AgentTool`, which only unify through this alias.
43
+ */
44
+ type CursorBridgeTool = AgentTool<any, any, any>;
45
+
46
+ /**
47
+ * The live MCP connections Cursor's resource frames are answered from.
48
+ *
49
+ * Named so every construction site can hand over the same adapter; a session
50
+ * and its advisors share one set of connections.
51
+ */
52
+ export interface CursorMcpResourceAdapter {
53
+ serverNames(): string[];
54
+ getServerResources(
55
+ name: string,
56
+ ): Promise<{ resources: { uri: string; name?: string; description?: string; mimeType?: string }[] } | undefined>;
57
+ readServerResource(name: string, uri: string): Promise<MCPResourceReadResult | undefined>;
58
+ }
59
+
24
60
  interface CursorExecBridgeOptions {
25
61
  cwd: string;
26
62
  getCwd?: () => string;
27
63
  tools: Map<string, AgentTool>;
28
64
  /** Resolves execution overrides (mounted-device permission wrappers) before the canonical map. */
29
65
  getExecutableTool?: (name: string) => AgentTool | undefined;
66
+ /**
67
+ * The `replace`-mode `edit` instance `pi_edit` must run, when the session
68
+ * granted `edit` at all.
69
+ *
70
+ * `PiEditExecArgs` is that mode's schema verbatim, and the session's own
71
+ * `edit` may be in any mode — `hashline` by default — whose schema rejects
72
+ * `old_text`/`new_text` outright. {@link tools} therefore cannot be trusted
73
+ * for this one frame: a session that starts on another provider keeps its
74
+ * configured instance in the map (only Cursor sessions move `edit` out), and
75
+ * switching to Cursor later does not rebuild the roster.
76
+ */
77
+ getEditReplaceTool?: () => CursorBridgeTool | undefined;
30
78
  getToolContext?: () => AgentToolContext | undefined;
31
79
  emitEvent?: (event: AgentEvent) => void;
32
80
  /**
33
- * Whether the Cursor native `delete` frame may remove files. Unlike every
34
- * other exec handler, `executeDelete` mutates the filesystem directly instead
35
- * of consulting {@link tools}, so a background read-only advisor could delete
36
- * workspace files it was never granted a mutating tool for (issue #5680
37
- * review). Defaults to allowed to preserve the primary agent's behavior;
38
- * callers with a restricted tool set (advisors) opt out.
81
+ * Whether frames that mutate the filesystem WITHOUT running a registry tool
82
+ * may do so: the native `delete` frame, and a `read_mcp_resource` carrying
83
+ * `download_path`. Both write or remove workspace files directly instead of
84
+ * consulting {@link tools}, so a background read-only advisor could touch
85
+ * files it was never granted a mutating tool for (issue #5680 review).
86
+ *
87
+ * This is a grant, not a policy: it answers "did the session hand this
88
+ * channel a file-writing tool", which callers derive from their own roster
89
+ * before any bridge-specific rewriting. The primary Cursor session moves
90
+ * `edit` out of {@link tools} and serves it through
91
+ * {@link getEditReplaceTool}, so reading the map here would deny an
92
+ * edit-only session. Defaults to allowed
93
+ * to preserve the primary agent's behavior; callers with a restricted tool
94
+ * set (advisors) opt out. The user's approval policy is resolved separately,
95
+ * per call.
39
96
  */
40
- allowNativeDelete?: boolean;
97
+ allowDirectFileMutation?: boolean;
41
98
  /**
42
99
  * Mirror Cursor's server-owned todo list into local session state. Cursor
43
100
  * resolves `update_todos` / `read_todos` remotely, so without this bridge
@@ -50,6 +107,99 @@ interface CursorExecBridgeOptions {
50
107
  * Cursor emits no local `todo` toolResult, so nothing else records it.
51
108
  */
52
109
  persistTodoPhases?: (phases: TodoPhase[]) => void;
110
+ /**
111
+ * Build a `grep` tool honoring a frame's own context width and match cap.
112
+ *
113
+ * The modern `pi_grep` frame carries both, and the shared `grep` instance
114
+ * is fixed to the session settings at construction — so without this the
115
+ * two fields are silently dropped. Callers that cannot supply it keep the
116
+ * shared instance and the session's defaults.
117
+ *
118
+ * The returned tool is executed as-is. Callers whose registry tools carry an
119
+ * approval wrapper MUST apply the same wrapper here, or a frame supplying
120
+ * either field silently escapes the approval gate that every other call
121
+ * goes through.
122
+ */
123
+ createGrepTool?(options: { context?: number; totalMatchLimit?: number }): CursorBridgeTool | undefined;
124
+ /**
125
+ * The session's live MCP connections, for Cursor's resource frames.
126
+ *
127
+ * `list_mcp_resources` / `read_mcp_resource` ask what this client's servers
128
+ * advertise. Without this the bridge answers an empty catalog and
129
+ * `not_found`, hiding resources the session is in fact connected to.
130
+ *
131
+ * `getServerResources` is async because a server's catalog loads in the
132
+ * background after its tools register: a frame arriving in that window would
133
+ * otherwise read the not-yet-populated cache and report an empty catalog,
134
+ * which is indistinguishable from a server that advertises nothing.
135
+ */
136
+ mcpResources?: CursorMcpResourceAdapter;
137
+ }
138
+
139
+ /**
140
+ * Write a downloaded resource without following a link at the target.
141
+ *
142
+ * The containment check and the write are separate syscalls, so a link planted
143
+ * at the target in between would redirect the bytes — the check cannot close
144
+ * that window on its own. `O_NOFOLLOW` decides it atomically for symlinks.
145
+ *
146
+ * A hard link needs a second check: it is a regular file that passes both the
147
+ * containment check and `O_NOFOLLOW` while sharing its inode with a file
148
+ * anywhere else on the volume, so truncating it overwrites that file too.
149
+ * `nlink > 1` on the OPEN handle is the test — statting the path first would
150
+ * reintroduce the race the open just closed. Same reasoning, and the same
151
+ * refusal, as `autolearn/managed-skills.ts`.
152
+ *
153
+ * Truncation therefore happens after that check rather than through `O_TRUNC`,
154
+ * which would have already destroyed the contents by the time it ran.
155
+ *
156
+ * Scope: `O_NOFOLLOW` applies to the FINAL component only. A parent directory
157
+ * swapped for an outward symlink between the check and this open is still
158
+ * followed; refusing that needs an `openat`/dirfd walk of every segment, which
159
+ * this does not attempt — an attacker who can rewrite the workspace tree
160
+ * mid-download is already inside the boundary this guard defends.
161
+ *
162
+ * Parent directories are created first, since the frame may name a path whose
163
+ * directories do not exist yet.
164
+ *
165
+ * `O_NONBLOCK` is what keeps the guard below reachable. A write-only open of a
166
+ * FIFO blocks until a reader arrives, so a `download_path` naming one would
167
+ * hang the turn forever WITHOUT the non-regular check ever running — the open
168
+ * itself never returns. Non-blocking turns that into `ENXIO` when no reader is
169
+ * attached, and hands back a descriptor the `isFile()` check refuses when one
170
+ * is. The flag has no effect on regular files, which is every legitimate
171
+ * target.
172
+ */
173
+ async function writeWithoutFollowingLinks(absolutePath: string, payload: string | Buffer): Promise<void> {
174
+ await fs.promises.mkdir(path.dirname(absolutePath), { recursive: true });
175
+ const handle = await fs.promises
176
+ .open(
177
+ absolutePath,
178
+ fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK,
179
+ )
180
+ .catch((error: NodeJS.ErrnoException) => {
181
+ // A readerless FIFO. Reported as the refusal it is, rather than the
182
+ // bare "no such device or address" the errno spells out.
183
+ if (error.code === "ENXIO") {
184
+ throw new Error(`Refusing to download onto a special file: ${absolutePath}`);
185
+ }
186
+ throw error;
187
+ });
188
+ try {
189
+ const stat = await handle.stat();
190
+ if (!stat.isFile()) {
191
+ throw new Error(`Refusing to download onto a non-regular file: ${absolutePath}`);
192
+ }
193
+ if (stat.nlink > 1) {
194
+ throw new Error(
195
+ `Refusing to download onto a file with ${stat.nlink} hard links, which would overwrite its other names: ${absolutePath}`,
196
+ );
197
+ }
198
+ await handle.truncate(0);
199
+ await handle.writeFile(payload);
200
+ } finally {
201
+ await handle.close();
202
+ }
53
203
  }
54
204
 
55
205
  function createToolResultMessage(
@@ -81,8 +231,9 @@ async function executeTool(
81
231
  toolName: string,
82
232
  toolCallId: string,
83
233
  args: Record<string, unknown>,
234
+ overrideTool?: CursorBridgeTool,
84
235
  ): Promise<ToolResultMessage> {
85
- const tool = options.getExecutableTool?.(toolName) ?? options.tools.get(toolName);
236
+ const tool = overrideTool ?? options.getExecutableTool?.(toolName) ?? options.tools.get(toolName);
86
237
  if (!tool) {
87
238
  const result = buildToolErrorResult(`Tool "${toolName}" not available`);
88
239
  return createToolResultMessage(toolCallId, toolName, result, true);
@@ -133,14 +284,49 @@ async function executeTool(
133
284
  return createToolResultMessage(toolCallId, toolName, result, isError);
134
285
  }
135
286
 
287
+ /**
288
+ * Resolve the user's policy for a frame that mutates the filesystem directly.
289
+ *
290
+ * The native `delete` and `read_mcp_resource` download frames both bypass the
291
+ * registry, so no approval wrapper sits in front of them. `write` is the tier
292
+ * a file creation or removal belongs to. Returns `null` when the call may
293
+ * proceed, or the refusal text to answer with.
294
+ */
295
+ function refuseByWritePolicy(options: CursorExecBridgeOptions, toolName: string, pathArg: string): string | null {
296
+ const context = options.getToolContext?.();
297
+ const settings = context?.settings;
298
+ const approvalMode: ApprovalMode =
299
+ context?.autoApprove === true ? "yolo" : (settings?.get("tools.approvalMode") ?? "yolo");
300
+ const approval = resolveApproval(
301
+ { name: toolName, approval: "write" },
302
+ { path: pathArg },
303
+ approvalMode,
304
+ (settings?.get("tools.approval") ?? {}) as Record<string, unknown>,
305
+ );
306
+ if (approval.policy === "allow") return null;
307
+ return approval.policy === "deny"
308
+ ? `Tool "${toolName}" is blocked by user policy.`
309
+ : `Tool "${toolName}" requires approval, which this channel cannot request.`;
310
+ }
311
+
136
312
  async function executeDelete(options: CursorExecBridgeOptions, pathArg: string, toolCallId: string) {
137
313
  const toolName = "delete";
138
314
 
139
- if (options.allowNativeDelete === false) {
315
+ if (options.allowDirectFileMutation === false) {
140
316
  const result = buildToolErrorResult(`Tool "${toolName}" not available`);
141
317
  return createToolResultMessage(toolCallId, toolName, result, true);
142
318
  }
143
319
 
320
+ // Unlike every other frame, this one mutates the filesystem directly instead
321
+ // of running a registry tool, so no approval wrapper sits in front of it.
322
+ // `allowDirectFileMutation` answers "was a mutating tool granted", which is a
323
+ // different question from "does the user's policy allow this call" — without
324
+ // this, a configured `deny` or an `always-ask` session still lost the file.
325
+ const refusal = refuseByWritePolicy(options, toolName, pathArg);
326
+ if (refusal) {
327
+ return createToolResultMessage(toolCallId, toolName, buildToolErrorResult(refusal), true);
328
+ }
329
+
144
330
  options.emitEvent?.({ type: "tool_execution_start", toolCallId, toolName, args: { path: pathArg } });
145
331
 
146
332
  const absolutePath = resolveToCwd(pathArg, options.getCwd?.() ?? options.cwd);
@@ -241,10 +427,21 @@ function buildTodoSyncResult(
241
427
  export class CursorExecHandlers implements ICursorExecHandlers {
242
428
  constructor(private options: CursorExecBridgeOptions) {}
243
429
 
430
+ /**
431
+ * Modern Cursor builds paginate the legacy `read` frame with
432
+ * `offset`/`limit`, exactly as `pi_read` does. Dropping them returns the
433
+ * whole file (or its own truncation) for every page, so a model walking a
434
+ * large file never advances. Composed with the same helper, so both frames
435
+ * translate a range identically.
436
+ */
244
437
  async read(args: Parameters<NonNullable<ICursorExecHandlers["read"]>>[0]) {
245
438
  const toolCallId = decodeToolCallId(args.toolCallId);
246
- const toolResultMessage = await executeTool(this.options, "read", toolCallId, { path: args.path });
247
- return toolResultMessage;
439
+ const composed = piReadPath(args.path, args.offset, args.limit);
440
+ // A present `limit: 0` asks for zero lines; no selector expresses that.
441
+ if (composed === null) {
442
+ return createToolResultMessage(toolCallId, "read", { content: [{ type: "text", text: "" }] }, false);
443
+ }
444
+ return await executeTool(this.options, "read", toolCallId, { path: composed });
248
445
  }
249
446
 
250
447
  async ls(args: Parameters<NonNullable<ICursorExecHandlers["ls"]>>[0]) {
@@ -254,6 +451,12 @@ export class CursorExecHandlers implements ICursorExecHandlers {
254
451
  return toolResultMessage;
255
452
  }
256
453
 
454
+ /**
455
+ * Modern Cursor builds paginate this frame with `offset`. The local `grep`
456
+ * paginates by file through `skip`, which is the same unit its own
457
+ * "use skip=N for the next page" advice counts in — so an unforwarded
458
+ * offset re-runs the identical search and returns page one forever.
459
+ */
257
460
  async grep(args: Parameters<NonNullable<ICursorExecHandlers["grep"]>>[0]) {
258
461
  const toolCallId = decodeToolCallId(args.toolCallId);
259
462
  const searchPath = args.glob ? `${args.path || "."}/${args.glob}` : args.path || ".";
@@ -261,6 +464,7 @@ export class CursorExecHandlers implements ICursorExecHandlers {
261
464
  pattern: args.pattern,
262
465
  path: searchPath,
263
466
  case: args.caseInsensitive === true ? false : undefined,
467
+ skip: piGrepSkip(args.offset),
264
468
  });
265
469
  return toolResultMessage;
266
470
  }
@@ -401,6 +605,227 @@ export class CursorExecHandlers implements ICursorExecHandlers {
401
605
  });
402
606
  return toolResultMessage;
403
607
  }
608
+ /**
609
+ * Modern Cursor CLI Pi tool frames (`ExecServerMessage` 45-51).
610
+ *
611
+ * These are a separate frame family from the legacy `read`/`shell`/... set,
612
+ * not aliases: different args, different result oneofs, and no `tool_call_id`
613
+ * (the provider mints one and passes it in `call.toolCallId`). Each maps onto
614
+ * the local tool with matching semantics, so the same approval, sandboxing
615
+ * and event plumbing applies as for a model-issued call.
616
+ */
617
+ /**
618
+ * `offset`/`limit` are a 1-indexed start line plus a line count (verified
619
+ * against the reference `LocalPiReadExecutor`), which is exactly the local
620
+ * `read` tool's `:N+K` inline selector — the tool takes no range kwargs, so
621
+ * the range has to be composed onto the path or ranged reads silently
622
+ * return the whole file.
623
+ */
624
+ async piRead(call: Parameters<NonNullable<ICursorExecHandlers["piRead"]>>[0]) {
625
+ const { path: readPath, offset, limit } = call.args;
626
+ const composed = piReadPath(readPath, offset, limit);
627
+ // A present `limit: 0` asks for zero lines. The reference slices an empty
628
+ // string for it; no `read` selector expresses that, so answer directly
629
+ // rather than falling back to a whole-file read.
630
+ if (composed === null) {
631
+ return createToolResultMessage(call.toolCallId, "read", { content: [{ type: "text", text: "" }] }, false);
632
+ }
633
+ return await executeTool(this.options, "read", call.toolCallId, { path: composed });
634
+ }
635
+
636
+ async piBash(call: Parameters<NonNullable<ICursorExecHandlers["piBash"]>>[0]) {
637
+ return await executeTool(this.options, "bash", call.toolCallId, {
638
+ command: call.args.command,
639
+ timeout: piTimeout(call.args.timeout),
640
+ });
641
+ }
642
+
643
+ /**
644
+ * `PiEditExecArgs` is the local `edit` tool's replace mode verbatim: a path
645
+ * plus `old_text`/`new_text` pairs. The tool's schema is snake_case, so the
646
+ * proto's camelCase accessors are mapped back on the way in.
647
+ *
648
+ * The replace-mode instance is requested explicitly rather than resolved
649
+ * from {@link CursorExecBridgeOptions.tools}: the registry's `edit` is in
650
+ * the session's configured mode, whose schema rejects these arguments.
651
+ */
652
+ async piEdit(call: Parameters<NonNullable<ICursorExecHandlers["piEdit"]>>[0]) {
653
+ return await executeTool(
654
+ this.options,
655
+ "edit",
656
+ call.toolCallId,
657
+ {
658
+ path: call.args.path,
659
+ edits: call.args.edits.map(edit => ({ old_text: edit.oldText, new_text: edit.newText })),
660
+ },
661
+ this.options.getEditReplaceTool?.(),
662
+ );
663
+ }
664
+
665
+ async piWrite(call: Parameters<NonNullable<ICursorExecHandlers["piWrite"]>>[0]) {
666
+ return await executeTool(this.options, "write", call.toolCallId, {
667
+ path: call.args.path,
668
+ content: call.args.content,
669
+ });
670
+ }
671
+
672
+ /**
673
+ * `literal` makes the pattern a fixed string; the local tool is regex-only,
674
+ * so the pattern is escaped on the way in (same translation the legacy pi
675
+ * shim does).
676
+ *
677
+ * `context` and `limit` are not expressible in the model-facing schema —
678
+ * context width comes from settings fixed at tool construction — so the
679
+ * frame's values are honored by building a per-call `grep` through
680
+ * {@link CursorExecBridgeOptions.createGrepTool}. Both are `optional int32`,
681
+ * so a present `0` context means "no context lines", not "use the default".
682
+ * Without the factory the shared instance runs with session defaults.
683
+ */
684
+ async piGrep(call: Parameters<NonNullable<ICursorExecHandlers["piGrep"]>>[0]) {
685
+ const { pattern, path, glob, ignoreCase, literal, context, limit } = call.args;
686
+ const scoped =
687
+ context !== undefined || limit !== undefined
688
+ ? this.options.createGrepTool?.({ context, totalMatchLimit: piLimit(limit) })
689
+ : undefined;
690
+ // Same arg mapping as the legacy `grep` handler: the local tool takes one
691
+ // path spec, and its `case` flag is case-SENSITIVITY, the inverse of the
692
+ // frame's `ignore_case`.
693
+ return await executeTool(
694
+ this.options,
695
+ "grep",
696
+ call.toolCallId,
697
+ {
698
+ pattern: literal === true ? piEscapeRegexLiteral(pattern) : pattern,
699
+ path: glob ? piJoinPath(path, glob) : path || ".",
700
+ case: ignoreCase === true ? false : undefined,
701
+ },
702
+ scoped,
703
+ );
704
+ }
705
+
706
+ /**
707
+ * `pi_find` is a filename search, which is the local `glob` tool — not
708
+ * `grep`. Its `pattern` is a glob, joined onto `path` because `glob` takes a
709
+ * single combined path spec.
710
+ *
711
+ * `limit` is `optional int32`, so `0` is present rather than unset; the
712
+ * reference clamps it with `Math.max(1, limit ?? 1000)`, and an unset limit
713
+ * leaves the local tool's own default in place.
714
+ */
715
+ async piFind(call: Parameters<NonNullable<ICursorExecHandlers["piFind"]>>[0]) {
716
+ const { pattern, path, limit } = call.args;
717
+ return await executeTool(this.options, "glob", call.toolCallId, {
718
+ path: piJoinPath(path, pattern),
719
+ limit: piLimit(limit),
720
+ });
721
+ }
722
+
723
+ /**
724
+ * Redirected to `read`, which lists directories — same as the legacy `ls`.
725
+ * The frame's entry `limit` is not mapped; see {@link piLsPath}.
726
+ */
727
+ async piLs(call: Parameters<NonNullable<ICursorExecHandlers["piLs"]>>[0]) {
728
+ return await executeTool(this.options, "read", call.toolCallId, { path: piLsPath(call.args.path) });
729
+ }
730
+
731
+ /**
732
+ * The resources this client's MCP servers advertise.
733
+ *
734
+ * Cursor addresses a later read by `server`, so every entry carries the name
735
+ * it came from. An absent `server` filter means "all of them".
736
+ */
737
+ async listMcpResources({ server }: { server?: string }): Promise<CursorMcpResource[]> {
738
+ const mcp = this.options.mcpResources;
739
+ if (!mcp) return [];
740
+ const names = server ? [server] : mcp.serverNames();
741
+ // Concurrently: each name may block on that server's first catalog load,
742
+ // and a slow server should not delay the rest of the listing.
743
+ const catalogs = await Promise.all(names.map(async name => [name, await mcp.getServerResources(name)] as const));
744
+ const listed: CursorMcpResource[] = [];
745
+ for (const [name, catalog] of catalogs) {
746
+ for (const resource of catalog?.resources ?? []) {
747
+ listed.push({
748
+ uri: resource.uri,
749
+ name: resource.name,
750
+ description: resource.description,
751
+ mimeType: resource.mimeType,
752
+ server: name,
753
+ });
754
+ }
755
+ }
756
+ return listed;
757
+ }
758
+
759
+ /**
760
+ * Read one resource, or `null` when the server or uri is unknown.
761
+ *
762
+ * MCP returns a list of content items; the wire carries exactly one text or
763
+ * blob. Text items are joined, since a multi-part text resource is one
764
+ * document; otherwise the first blob stands in. `blob` arrives base64 and
765
+ * the wire wants bytes.
766
+ *
767
+ * A `downloadPath` frame is a different contract: write the bytes to that
768
+ * workspace-relative path and answer with the path alone, so a large binary
769
+ * lands on disk instead of in the model's context. That makes it a workspace
770
+ * mutation reached without a registry tool, so it is gated exactly like the
771
+ * native `delete` frame — on the session actually granting a file-writing
772
+ * tool, and on the user's `write`-tier policy. The gate runs before the read
773
+ * so a refused download never fetches the resource either.
774
+ */
775
+ async readMcpResource({
776
+ server,
777
+ uri,
778
+ downloadPath,
779
+ }: {
780
+ server: string;
781
+ uri: string;
782
+ downloadPath?: string;
783
+ }): Promise<CursorMcpResourceContent | null> {
784
+ if (downloadPath) {
785
+ if (this.options.allowDirectFileMutation === false) {
786
+ throw new Error('Tool "write" not available: this session cannot download resources to disk.');
787
+ }
788
+ const refusal = refuseByWritePolicy(this.options, "write", downloadPath);
789
+ if (refusal) throw new Error(refusal);
790
+ }
791
+ const mcp = this.options.mcpResources;
792
+ if (!mcp) return null;
793
+ const read = await mcp.readServerResource(server, uri);
794
+ if (!read) return null;
795
+ // The mime type must describe the bytes actually sent, not whatever item
796
+ // happened to be first: an image blob followed by a text note would
797
+ // otherwise label the text `image/png` and mislead the model about what
798
+ // it is holding. Each branch below takes the type from its own producer.
799
+ const textItems = read.contents.filter(item => item.text !== undefined);
800
+ const texts = textItems.map(item => item.text as string);
801
+ const blobItem = read.contents.find(item => item.blob !== undefined);
802
+ const blob = blobItem?.blob;
803
+ const textMimeType = textItems[0]?.mimeType;
804
+ const blobMimeType = blobItem?.mimeType;
805
+
806
+ if (downloadPath) {
807
+ // Text resources download as their own bytes; a blob decodes first.
808
+ const payload =
809
+ texts.length > 0 ? texts.join("\n") : blob !== undefined ? Buffer.from(blob, "base64") : undefined;
810
+ if (payload === undefined) return null;
811
+ // The path is workspace-relative BY CONTRACT, but it arrives from the
812
+ // server, and `resolveToCwd` deliberately honors absolute paths and
813
+ // `..` for user-authored tool input. Taking it at its word would let a
814
+ // frame write anywhere this process can reach, so confine it here
815
+ // rather than trusting the declaration.
816
+ const cwd = this.options.getCwd?.() ?? this.options.cwd;
817
+ const absolutePath = confineToWorkspace(downloadPath, cwd);
818
+ if (!absolutePath) throw new Error(`Refusing to download outside the workspace: ${downloadPath}`);
819
+ await writeWithoutFollowingLinks(absolutePath, payload);
820
+ // The path echoed back is the one the frame asked for; the model
821
+ // addresses it the same relative way.
822
+ return { uri, mimeType: texts.length > 0 ? textMimeType : blobMimeType, downloadPath };
823
+ }
824
+
825
+ if (texts.length > 0) return { uri, mimeType: textMimeType, text: texts.join("\n") };
826
+ if (blob === undefined) return null;
827
+ return { uri, mimeType: blobMimeType, blob: Buffer.from(blob, "base64") };
828
+ }
404
829
 
405
830
  /**
406
831
  * Settle a completed native Cursor todo call, mirroring its list when the
@@ -510,4 +935,30 @@ export class CursorExecHandlers implements ICursorExecHandlers {
510
935
  const toolResultMessage = await executeTool(this.options, toolName, toolCallId, args);
511
936
  return toolResultMessage;
512
937
  }
938
+
939
+ /**
940
+ * Resolve an MCP call's approval without running it.
941
+ *
942
+ * Same resolution the wrapper applies at execution time, minus the
943
+ * execution: an unknown tool is not approvable, and a `prompt` is not an
944
+ * approval — the frame has no way to carry an interactive question, and the
945
+ * user is asked for real when the call itself arrives.
946
+ */
947
+ async mcpApprovalPreflight(call: CursorMcpCall) {
948
+ const toolName = call.toolName || call.name;
949
+ const tool = this.options.getExecutableTool?.(toolName) ?? this.options.tools.get(toolName);
950
+ if (!tool) return false;
951
+ const context = this.options.getToolContext?.();
952
+ const settings = context?.settings;
953
+ const approvalMode: ApprovalMode =
954
+ context?.autoApprove === true ? "yolo" : (settings?.get("tools.approvalMode") ?? "yolo");
955
+ const args = Object.keys(call.args ?? {}).length > 0 ? call.args : decodeMcpArgs(call.rawArgs ?? {});
956
+ const approval = resolveApproval(
957
+ tool,
958
+ args,
959
+ approvalMode,
960
+ (settings?.get("tools.approval") ?? {}) as Record<string, unknown>,
961
+ );
962
+ return approval.policy === "allow";
963
+ }
513
964
  }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Session-bound edit clipboard register.
3
+ *
4
+ * `CUT`/`PASTE` hashline ops share one {@link Clipboard} per session so
5
+ * content captured by one edit call can be pasted by a later one (and across
6
+ * files within one call). The executor works on a fork per batch and commits
7
+ * it back only after every write lands — see `executeHashlineSingle`.
8
+ */
9
+ import type { Clipboard } from "@oh-my-pi/hashline";
10
+
11
+ interface EditClipboardOwner {
12
+ editClipboard?: Clipboard;
13
+ }
14
+
15
+ /**
16
+ * Look up (or lazily create) the clipboard register attached to a session.
17
+ * Storage lives on `session.editClipboard` so it ages out exactly with the
18
+ * session itself.
19
+ */
20
+ export function getEditClipboard(session: EditClipboardOwner): Clipboard {
21
+ session.editClipboard ??= {};
22
+ return session.editClipboard;
23
+ }