@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
@@ -13,6 +13,7 @@ import * as path from "node:path";
13
13
  import {
14
14
  type ApplyResult,
15
15
  applyEdits,
16
+ type Clipboard,
16
17
  type Cursor,
17
18
  computeFileHash,
18
19
  type Edit,
@@ -26,8 +27,10 @@ import {
26
27
  parsePatchStreaming,
27
28
  Recovery,
28
29
  resolveBlockEdits,
30
+ resolveClipboardEdits,
29
31
  type SnapshotStore,
30
32
  stripBom,
33
+ validateClipboardSequence,
31
34
  } from "@oh-my-pi/hashline";
32
35
  import { resolveToCwd } from "../../tools/path-utils";
33
36
  import { generateDiffString } from "../diff";
@@ -48,6 +51,14 @@ export interface HashlineDiffOptions {
48
51
  * authoring input; the final apply path still validates through Patcher.
49
52
  */
50
53
  skipHashValidation?: boolean;
54
+ /**
55
+ * Clipboard register shared across the sections of one patch preview.
56
+ * `CUT` in an earlier section feeds `PASTE` in a later one, so the preview
57
+ * matches apply. Multi-section previews MUST thread one register through
58
+ * sections in patch order; omitted, each section gets a
59
+ * private register (same-file cut/paste still previews correctly).
60
+ */
61
+ clipboard?: Clipboard;
51
62
  }
52
63
 
53
64
  async function readSectionText(absolutePath: string, sectionPath: string): Promise<string> {
@@ -144,6 +155,7 @@ function hasAnchorScopedEdit(edits: readonly Edit[]): boolean {
144
155
  return edits.some(edit => {
145
156
  if (edit.kind === "delete") return true;
146
157
  if (edit.kind === "block") return true;
158
+ if (edit.kind === "cut") return true;
147
159
  return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor";
148
160
  });
149
161
  }
@@ -204,14 +216,24 @@ function applyPreviewEdits(args: {
204
216
  const liveMatches = expected !== undefined && computeFileHash(normalized) === expected;
205
217
  const edits = parsePreviewEdits(section, options.streaming);
206
218
  const resolved = resolvePreviewEdits({ section, absolutePath, normalized, snapshots, expected, liveMatches, edits });
207
- if (options.skipHashValidation || expected === undefined || liveMatches) return applyEdits(normalized, resolved);
208
- if (!hasAnchorScopedEdit(resolved)) return applyEdits(normalized, resolved);
219
+ const clipboard = options.clipboard ?? {};
220
+ // Mirror the Patcher: surface clipboard sequencing mistakes with their
221
+ // targeted message before the recovery path below swallows them. Streaming
222
+ // previews stay lenient — a mid-typed op transiently violating sequencing
223
+ // must not flash an error frame.
224
+ if (!options.streaming) validateClipboardSequence(resolved, clipboard);
225
+ const applyOptions = { clipboard, ...(options.streaming ? { onEmptyPaste: "drop" as const } : {}) };
226
+ if (options.skipHashValidation || expected === undefined || liveMatches) {
227
+ return applyEdits(normalized, resolved, applyOptions);
228
+ }
229
+ if (!hasAnchorScopedEdit(resolved)) return applyEdits(normalized, resolved, applyOptions);
209
230
 
210
231
  const recovered = new Recovery(snapshots).tryRecover({
211
232
  path: absolutePath,
212
233
  currentText: normalized,
213
234
  fileHash: expected,
214
235
  edits: resolved,
236
+ clipboard,
215
237
  });
216
238
  if (recovered) return recovered;
217
239
  throw createMismatchError(section, absolutePath, normalized, snapshots, expected);
@@ -250,18 +272,36 @@ function insertCursorLine(cursor: Cursor, fileLineCount: number): number {
250
272
  function buildStreamingSectionDiff(
251
273
  section: PatchSection,
252
274
  normalized: string,
275
+ clipboard: Clipboard,
253
276
  ): { diff: string; firstChangedLine: number | undefined } | { error: string } {
254
277
  const { edits, fileOp } = parsePatchStreaming(section.diff);
255
- const resolved = resolveBlockEdits(edits, normalized, section.path, nativeBlockResolver, { onUnresolved: "drop" });
278
+ const blockResolved = resolveBlockEdits(edits, normalized, section.path, nativeBlockResolver, {
279
+ onUnresolved: "drop",
280
+ });
281
+ const fileLines = normalized.split("\n");
282
+ // Expand clipboard ops so a `PASTE` previews the moved rows. Resolve into
283
+ // a scratch register and commit it back only on success: a mid-typed
284
+ // capture (out-of-range while the range digits are still streaming,
285
+ // transient sequencing violations) throws AFTER earlier captures may have
286
+ // landed, and leaking those into the shared register would let a later
287
+ // section preview a paste that never happened. On failure, fall back to
288
+ // filtering the clipboard edits out of this section's frame.
289
+ const scratch: Clipboard = { ...clipboard };
290
+ let resolved: readonly Edit[];
291
+ try {
292
+ resolved = resolveClipboardEdits(blockResolved, fileLines, scratch, { onEmptyPaste: "drop" });
293
+ if (scratch.lines === undefined) delete clipboard.lines;
294
+ else clipboard.lines = scratch.lines;
295
+ } catch {
296
+ resolved = blockResolved.filter(edit => edit.kind !== "cut" && edit.kind !== "paste");
297
+ }
256
298
  if (resolved.length === 0) {
257
- // A whole-file op (REM / MV) carries no line edits: the change is the
258
- // delete/move itself, conveyed by the result header, so emit an empty
259
- // diff rather than a misleading "No changes" error.
299
+ // A whole-file op (REM / MV) carries no line edits; the result header
300
+ // conveys the change, so emit an empty diff.
260
301
  if (fileOp) return { diff: "", firstChangedLine: undefined };
261
302
  return { error: `No changes would be made to ${section.path}.` };
262
303
  }
263
304
 
264
- const fileLines = normalized.split("\n");
265
305
  const rows: string[] = [];
266
306
  let firstChangedLine: number | undefined;
267
307
 
@@ -323,11 +363,10 @@ export async function computeHashlineSectionDiff(
323
363
  // diff: feed the in-flight ops through the natural-order builder so the
324
364
  // streamed cursor stays pinned to the bottom. The args-complete pass
325
365
  // (`streaming` unset) falls through to the real Myers diff below.
326
- if (options.streaming) return buildStreamingSectionDiff(section, normalized);
366
+ if (options.streaming) return buildStreamingSectionDiff(section, normalized, options.clipboard ?? {});
327
367
  const result = applyPreviewEdits({ section, absolutePath, normalized, snapshots, options });
328
368
  if (normalized === result.text) {
329
- // REM/MV-only sections change no text; the header conveys the
330
- // delete/move, so don't surface a "No changes" error.
369
+ // REM/MV-only sections change no text; the header conveys the op.
331
370
  if (section.fileOp) return { diff: "", firstChangedLine: undefined };
332
371
  return { error: `No changes would be made to ${section.path}.` };
333
372
  }
@@ -13,6 +13,9 @@
13
13
  import {
14
14
  type BlockResolution,
15
15
  buildCompactDiffPreview,
16
+ type Clipboard,
17
+ commitClipboard,
18
+ forkClipboard,
16
19
  MismatchError as HashlineMismatchError,
17
20
  Patch,
18
21
  Patcher,
@@ -25,6 +28,7 @@ import type { ToolSession } from "../../tools";
25
28
  import { outputMeta } from "../../tools/output-meta";
26
29
  import { ToolError } from "../../tools/tool-errors";
27
30
  import { generateDiffString } from "../diff";
31
+ import { getEditClipboard } from "../edit-clipboard";
28
32
  import { getFileSnapshotStore } from "../file-snapshot-store";
29
33
  import type { EditToolDetails, EditToolPerFileResult, LspBatchRequest } from "../renderer";
30
34
  import { pruneOversizedEditSnapshots } from "../snapshot-details";
@@ -98,12 +102,24 @@ interface RenderedSection {
98
102
  perFileResult: EditToolPerFileResult;
99
103
  }
100
104
 
105
+ const BLOCK_OP_LABELS: Record<BlockResolution["op"], string> = {
106
+ replace: "SWAP.BLK",
107
+ insert_after: "INS.BLK.POST",
108
+ cut: "CUT.BLK",
109
+ paste_after: "PASTE.BLK.POST",
110
+ };
111
+
101
112
  function formatBlockResolution(resolution: BlockResolution): string {
102
- const op = resolution.op === "delete" ? "DEL.BLK" : resolution.op === "insert_after" ? "INS.BLK.POST" : "SWAP.BLK";
113
+ const op = BLOCK_OP_LABELS[resolution.op];
103
114
  const lines = resolution.end - resolution.start + 1;
104
115
  const span =
105
116
  resolution.start === resolution.end ? `line ${resolution.start}` : `lines ${resolution.start}-${resolution.end}`;
106
- const suffix = resolution.op === "insert_after" ? `; body lands after line ${resolution.end}` : "";
117
+ const suffix =
118
+ resolution.op === "insert_after"
119
+ ? `; body lands after line ${resolution.end}`
120
+ : resolution.op === "paste_after"
121
+ ? `; clipboard lands after line ${resolution.end}`
122
+ : "";
107
123
  return `${op} ${resolution.anchorLine} → resolved ${span} (${lines} line${lines === 1 ? "" : "s"})${suffix}`;
108
124
  }
109
125
 
@@ -213,12 +229,19 @@ export async function executeHashlineSingle(
213
229
  const enforceSeenLines = options.session.settings.get("edit.enforceSeenLines");
214
230
  const patcher = new Patcher({ fs, snapshots, blockResolver: nativeBlockResolver, enforceSeenLines });
215
231
 
232
+ // The clipboard register is session-persistent: `CUT` in one edit call can
233
+ // `PASTE` in a later one. Each batch works on a fork and publishes it back
234
+ // only after writes land.
235
+ const sessionClipboard = getEditClipboard(options.session);
236
+ const clipboard = forkClipboard(sessionClipboard);
237
+
216
238
  // Single-section fast path: prepare, commit, render.
217
239
  const inputHash = hashPatchInput(options.input);
218
240
  if (patch.sections.length === 1) {
219
241
  fs.setBatchRequest(narrowBatchRequest(options.batchRequest, true));
220
- const prepared = await patcher.prepare(patch.sections[0]);
242
+ const prepared = await patcher.prepare(patch.sections[0], clipboard);
221
243
  const sectionResult = await patcher.commit(prepared);
244
+ commitClipboard(clipboard, sessionClipboard);
222
245
  if (sectionResult.op === "noop") {
223
246
  const { count, escalate } = recordNoopEdit(options.session, sectionResult.canonicalPath, inputHash);
224
247
  if (escalate) {
@@ -231,9 +254,18 @@ export async function executeHashlineSingle(
231
254
  }
232
255
 
233
256
  // Multi-section: prepare every section up front so we fail fast before
234
- // any write hits the filesystem.
257
+ // any write hits the filesystem. One clipboard register spans the batch,
258
+ // so `CUT` in one section feeds `PASTE` in a later one.
235
259
  const prepared: PreparedSection[] = [];
236
- for (const section of patch.sections) prepared.push(await patcher.prepare(section));
260
+ // Register state after each section's prepare. Commits are non-atomic: a
261
+ // mid-batch write failure leaves earlier sections on disk, so the session
262
+ // register must reflect exactly the landed prefix — content a landed CUT
263
+ // deleted would otherwise be lost.
264
+ const sectionStates: Clipboard[] = [];
265
+ for (const section of patch.sections) {
266
+ prepared.push(await patcher.prepare(section, clipboard));
267
+ sectionStates.push(forkClipboard(clipboard));
268
+ }
237
269
  assertUniqueCanonicalPaths(prepared);
238
270
  for (const entry of prepared) {
239
271
  if (entry.isNoop) {
@@ -251,6 +283,7 @@ export async function executeHashlineSingle(
251
283
  const isLast = i === prepared.length - 1;
252
284
  fs.setBatchRequest(narrowBatchRequest(options.batchRequest, isLast));
253
285
  const sectionResult = await patcher.commit(prepared[i]);
286
+ commitClipboard(sectionStates[i], sessionClipboard);
254
287
  if (sectionResult.op === "noop") {
255
288
  const { count, escalate } = recordNoopEdit(options.session, sectionResult.canonicalPath, inputHash);
256
289
  throw escalate
@@ -260,7 +293,6 @@ export async function executeHashlineSingle(
260
293
  resetNoopEdit(options.session, sectionResult.canonicalPath);
261
294
  rendered.push(renderSection(sectionResult, fs.consumeDiagnostics(sectionResult.path), prepared[i].section.path));
262
295
  }
263
-
264
296
  return {
265
297
  content: [
266
298
  {
@@ -198,9 +198,33 @@ export class HashlineFilesystem extends Filesystem {
198
198
  const finalContent = await serializeEditFileText(absolutePath, relativePath, content);
199
199
 
200
200
  // Route through ACP bridge when available; skips internal artifacts.
201
- if (await routeWriteThroughBridge(this.session, relativePath, absolutePath, finalContent, this.#signal)) {
201
+ // `finalContent` is storage-space (e.g. a notebook's full JSON); the
202
+ // bridge may also report content that diverges from it (e.g. the
203
+ // client reformatted on save). `WriteResult.text` must stay in
204
+ // view-space — the same space `readText` returns — so a follow-up
205
+ // `readText` sees exactly what this write reports.
206
+ const bridgeResult = await routeWriteThroughBridge(
207
+ this.session,
208
+ relativePath,
209
+ absolutePath,
210
+ finalContent,
211
+ this.#signal,
212
+ );
213
+ if (bridgeResult) {
202
214
  this.#diagnosticsByPath.set(relativePath, undefined);
203
- return { text: finalContent };
215
+ if (!bridgeResult.driftedFromRequest) {
216
+ // No client-side transform: the view we sent is what's on disk.
217
+ return { text: content };
218
+ }
219
+ // Drifted (e.g. format-on-save): re-derive the view from what
220
+ // actually landed on disk instead of assuming `content` still
221
+ // matches. Falls back to `content` if the drifted file can't be
222
+ // re-read as a valid view (e.g. a formatter broke notebook JSON).
223
+ try {
224
+ return { text: await readEditFileText(absolutePath, relativePath) };
225
+ } catch {
226
+ return { text: content };
227
+ }
204
228
  }
205
229
 
206
230
  const diagnostics = await this.#writethrough(
@@ -213,7 +237,7 @@ export class HashlineFilesystem extends Filesystem {
213
237
  );
214
238
  invalidateFsScanAfterWrite(absolutePath);
215
239
  this.#diagnosticsByPath.set(relativePath, diagnostics);
216
- return { text: finalContent };
240
+ return { text: content };
217
241
  }
218
242
 
219
243
  async exists(relativePath: string): Promise<boolean> {
package/src/edit/index.ts CHANGED
@@ -399,14 +399,24 @@ export class EditTool implements AgentTool<TInput> {
399
399
  readonly #editMode?: EditMode;
400
400
  readonly #deferredDiagnostics: DeferredDiagnostics;
401
401
 
402
- constructor(private readonly session: ToolSession) {
402
+ /**
403
+ * `mode` pins the edit variant for this instance, for callers whose protocol
404
+ * fixes the shape of an edit. The Cursor `pi_edit` frame carries
405
+ * `old_text`/`new_text` pairs, which only `replace` accepts — under the
406
+ * default `hashline` mode those args do not match the schema at all. Left
407
+ * unset, the env/settings resolution applies as before.
408
+ */
409
+ constructor(
410
+ private readonly session: ToolSession,
411
+ mode?: EditMode,
412
+ ) {
403
413
  const {
404
414
  PI_EDIT_FUZZY: editFuzzy = "auto",
405
415
  PI_EDIT_FUZZY_THRESHOLD: editFuzzyThreshold = "auto",
406
416
  PI_EDIT_VARIANT: envEditVariant = "auto",
407
417
  } = Bun.env;
408
418
 
409
- this.#editMode = resolveConfiguredEditMode(envEditVariant);
419
+ this.#editMode = mode ?? resolveConfiguredEditMode(envEditVariant);
410
420
  this.#allowFuzzy = resolveAllowFuzzy(session, editFuzzy);
411
421
  this.#fuzzyThreshold = resolveFuzzyThreshold(session, editFuzzyThreshold);
412
422
  const deduplicateDiagnostics =
@@ -140,7 +140,7 @@ interface HashlineInputEntry {
140
140
  path: string;
141
141
  op?: Operation;
142
142
  rename?: string;
143
- /** A SWAP/DEL/INS line-editing op precedes the file op — keeps a move framed. */
143
+ /** A SWAP/CUT/INS line-editing op precedes the file op — keeps a move framed. */
144
144
  hasLineEdits?: boolean;
145
145
  }
146
146
 
@@ -568,9 +568,9 @@ function parseHashlineInputPreviewHeader(line: string): string | null {
568
568
  return previewPath.length > 0 ? previewPath : null;
569
569
  }
570
570
 
571
- // Line-editing op headers (SWAP/DEL/INS family), distinct from the file-level
572
- // REM/MV ops. Body rows are always `+TEXT`, so this only matches real headers.
573
- const HL_LINE_OP_HEADER = /^(?:SWAP|DEL|INS)\b/;
571
+ // Line-editing op headers (SWAP/CUT/INS family), distinct from file-level
572
+ // REM/MV ops. Body rows are `+TEXT`, so this only matches real headers.
573
+ const HL_LINE_OP_HEADER = /^(?:SWAP|CUT|INS)\b/;
574
574
 
575
575
  /**
576
576
  * Walk a (possibly mid-stream) hashline payload into per-section descriptors:
@@ -16,8 +16,10 @@
16
16
  import {
17
17
  ABORT_MARKER,
18
18
  BEGIN_PATCH_MARKER,
19
+ type Clipboard,
19
20
  containsRecognizableHashlineOperations,
20
21
  END_PATCH_MARKER,
22
+ forkClipboard,
21
23
  type PatchSection as HashlineInputSection,
22
24
  Patch as HashlinePatch,
23
25
  type SnapshotStore,
@@ -50,6 +52,13 @@ export interface StreamingDiffContext {
50
52
  * not flicker in the preview.
51
53
  */
52
54
  isStreaming?: boolean;
55
+ /**
56
+ * Session-persistent clipboard register (`CUT`/`PASTE`). Previews
57
+ * fork it per frame — never mutating it — so a `PASTE` of content cut in
58
+ * an earlier edit call (or an earlier section of this patch) renders the
59
+ * real rows.
60
+ */
61
+ clipboard?: Clipboard;
53
62
  }
54
63
 
55
64
  /**
@@ -411,7 +420,7 @@ const patchStrategy: EditStreamingStrategy<PatchArgs> = {
411
420
  if (typeof edit?.diff !== "string") continue;
412
421
  // `create` ops carry full file content in `diff` with no +/- markers;
413
422
  // pass that content through whole.
414
- const added = extractAddedLines(edit.diff, true);
423
+ const added = extractAddedLines(edit.diff, edit.op === "create");
415
424
  digest = digest === undefined ? added : `${digest}\n${added}`;
416
425
  }
417
426
  return digest;
@@ -542,7 +551,9 @@ const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
542
551
  // to parse; suppress until the next chunk arrives. Once args are
543
552
  // complete, surface the error so the model sees what went wrong.
544
553
  if (ctx.isStreaming) return null;
545
- const result = await computeHashlineDiff({ input }, ctx.cwd, ctx.snapshots);
554
+ const result = await computeHashlineDiff({ input }, ctx.cwd, ctx.snapshots, {
555
+ clipboard: forkClipboard(ctx.clipboard),
556
+ });
546
557
  ctx.signal.throwIfAborted();
547
558
  return [toPerFilePreview("", result)];
548
559
  }
@@ -558,12 +569,16 @@ const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
558
569
  const trailingProcessedIndex = sectionsToProcess.length - 1;
559
570
 
560
571
  const previews: PerFileDiffPreview[] = [];
572
+ // Fork the session register per preview frame: sections feed each other
573
+ // in patch order, but a preview must never mutate the live register.
574
+ const clipboard = forkClipboard(ctx.clipboard);
561
575
  for (let i = 0; i < sectionsToProcess.length; i++) {
562
576
  ctx.signal.throwIfAborted();
563
577
  const section = sectionsToProcess[i];
564
578
  const result = await computeHashlineSectionDiff(section, ctx.cwd, ctx.snapshots, {
565
579
  streaming: ctx.isStreaming,
566
580
  skipHashValidation: ctx.isStreaming === true,
581
+ clipboard,
567
582
  });
568
583
  ctx.signal.throwIfAborted();
569
584
  // Ignore parse/apply errors from the trailing (actively-typed)
@@ -376,10 +376,14 @@ if "__omp_prelude_loaded__" not in globals():
376
376
  raise RuntimeError("tool bridge is unavailable in this kernel")
377
377
  return (base.rstrip("/"), token, session)
378
378
 
379
+ import urllib.error, urllib.request
380
+
381
+ # urllib discovers environment and macOS SystemConfiguration proxies. This
382
+ # host-owned loopback endpoint must always connect directly.
383
+ _BRIDGE_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}))
384
+
379
385
  def _bridge_call(name: str, args: dict):
380
386
  """POST one request to the host tool bridge and return its `value`."""
381
- import urllib.request, urllib.error
382
-
383
387
  base, token, session = _tool_proxy_from_env()
384
388
  _run_id_getter = globals().get("__omp_current_run_id__")
385
389
  _run_id = (
@@ -400,7 +404,7 @@ if "__omp_prelude_loaded__" not in globals():
400
404
  },
401
405
  )
402
406
  try:
403
- with urllib.request.urlopen(req) as resp:
407
+ with _BRIDGE_OPENER.open(req) as resp:
404
408
  body = resp.read()
405
409
  except urllib.error.HTTPError as exc:
406
410
  body = exc.read()
@@ -395,7 +395,7 @@
395
395
  }
396
396
 
397
397
  // Apply filter mode
398
- const isSettingsEntry = ['label', 'custom', 'model_change', 'thinking_level_change', 'mode_change', 'ttsr_injection', 'session_init'].includes(entry.type);
398
+ const isSettingsEntry = ['label', 'custom', 'model_change', 'thinking_level_change', 'mode_change', 'ttsr_injection', 'session_init', 'credential_pin'].includes(entry.type);
399
399
  let passesFilter = true;
400
400
 
401
401
  switch (filterMode) {
@@ -10,6 +10,7 @@ import type { Settings } from "../../config/settings";
10
10
  import type { LocalProtocolOptions } from "../../internal-urls/local-protocol";
11
11
  import type { MemoryRuntimeContext } from "../../memory-backend";
12
12
  import { type Theme, theme } from "../../modes/theme/theme";
13
+ import type { AsyncJobSnapshot } from "../../session/agent-session";
13
14
  import type { SessionManager } from "../../session/session-manager";
14
15
  import type { BranchHandler, NavigateTreeHandler, NewSessionHandler } from "../session-handler-types";
15
16
  import { ManagedTimers } from "./managed-timers";
@@ -40,6 +41,7 @@ import type {
40
41
  ExtensionUIDialogOptions,
41
42
  InputEvent,
42
43
  InputEventResult,
44
+ McpNotificationEvent,
43
45
  MessageRenderer,
44
46
  RegisteredCommand,
45
47
  RegisteredTool,
@@ -218,6 +220,14 @@ async function raceHandlerWithTimeout<T>(
218
220
 
219
221
  const MAX_PENDING_CREDENTIAL_DISABLED = 32;
220
222
 
223
+ /**
224
+ * Buffer cap for `mcp_notification` events received before {@link ExtensionRunner.initialize}
225
+ * has run. Sized to match the manager-side buffer in `MCPManager.NOTIFICATION_BUFFER_CAP` so
226
+ * the two layers can't drop different amounts of the same burst — the pipe drains, or it
227
+ * spills, but it does so consistently at both ends. Drop-oldest under pressure.
228
+ */
229
+ const MAX_PENDING_MCP_NOTIFICATIONS = 100;
230
+
221
231
  /**
222
232
  * Events handled by the generic emit() method.
223
233
  * Events with dedicated emitXxx() methods are excluded for stronger type safety.
@@ -327,6 +337,7 @@ export class ExtensionRunner {
327
337
  #getContextUsageFn: () => ContextUsage | undefined = () => undefined;
328
338
  #compactFn: (instructionsOrOptions?: string | CompactOptions) => Promise<void> = async () => {};
329
339
  #getSystemPromptFn: () => string[] = () => [];
340
+ #getAsyncJobSnapshotFn: () => AsyncJobSnapshot | null = () => null;
330
341
  #newSessionHandler: NewSessionHandler = async () => ({ cancelled: false });
331
342
  #branchHandler: BranchHandler = async () => ({ cancelled: false });
332
343
  #navigateTreeHandler: NavigateTreeHandler = async () => ({ cancelled: false });
@@ -345,6 +356,19 @@ export class ExtensionRunner {
345
356
  */
346
357
  #pendingCredentialDisabled: CredentialDisabledEvent[] = [];
347
358
 
359
+ /**
360
+ * Buffer for `mcp_notification` events received via {@link emitMcpNotification} before
361
+ * {@link initialize} has run. Two-layer race: `MCPManager` also buffers frames until
362
+ * its first `addNotificationListener` subscriber attaches, but the sdk.ts bridge is
363
+ * registered inside `createAgentSession` — BEFORE the mode controller calls
364
+ * `ExtensionRunner.initialize()`. Without this second buffer, the manager's drain
365
+ * arrives at the bridge → the bridge calls `emitMcpNotification` → the runner drops
366
+ * the frame because `#initialized === false`, and the frame evaporates a second time.
367
+ * Bounded at {@link MAX_PENDING_MCP_NOTIFICATIONS}; oldest entries are dropped under
368
+ * pressure. Drained in {@link initialize} once the runtime/UI context is wired.
369
+ */
370
+ #pendingMcpNotifications: Array<Omit<McpNotificationEvent, "type">> = [];
371
+
348
372
  /**
349
373
  * Timers scheduled by extensions through the sanctioned `ctx.setInterval` /
350
374
  * `ctx.setTimeout` helpers. Callbacks run with the same isolation as handler
@@ -392,9 +416,11 @@ export class ExtensionRunner {
392
416
  getMemory?: () => MemoryRuntimeContext | undefined,
393
417
  private readonly settings?: Settings,
394
418
  private readonly localProtocolOptions?: LocalProtocolOptions,
419
+ getAsyncJobSnapshot?: () => AsyncJobSnapshot | null,
395
420
  ) {
396
421
  this.#uiContext = noOpUIContext;
397
422
  this.#getMemoryFn = getMemory;
423
+ this.#getAsyncJobSnapshotFn = getAsyncJobSnapshot ?? (() => null);
398
424
  }
399
425
 
400
426
  initialize(
@@ -457,6 +483,23 @@ export class ExtensionRunner {
457
483
  });
458
484
  }
459
485
  });
486
+
487
+ // Drain events buffered by emitMcpNotification() before initialize ran, using the
488
+ // same deferred-microtask ordering as the credential-disabled drain above so any
489
+ // onError listener registered synchronously after initialize() still catches
490
+ // handler errors during flush.
491
+ const pendingMcp = this.#pendingMcpNotifications.splice(0);
492
+ queueMicrotask(() => {
493
+ for (const event of pendingMcp) {
494
+ this.emit({ type: "mcp_notification", ...event }).catch((error: unknown) => {
495
+ logger.warn("mcp_notification handler threw during initialize flush", {
496
+ server: event.server,
497
+ method: event.method,
498
+ error: error instanceof Error ? error.message : String(error),
499
+ });
500
+ });
501
+ }
502
+ });
460
503
  }
461
504
 
462
505
  /**
@@ -484,6 +527,32 @@ export class ExtensionRunner {
484
527
  await this.emit({ type: "credential_disabled", ...event });
485
528
  }
486
529
 
530
+ /**
531
+ * Forward an MCP server notification to extension handlers.
532
+ *
533
+ * If {@link initialize} has not yet run, the notification is buffered and replayed
534
+ * once initialize wires the runtime/UI context. Matches the credential-disabled
535
+ * deferral above: the sdk.ts bridge registers `MCPManager.addNotificationListener`
536
+ * inside `createAgentSession` — BEFORE the mode controller calls `initialize()` on
537
+ * this runner — so notification frames drained by the manager (either fresh
538
+ * arrivals or replay from its own startup buffer) can reach us pre-init. Without
539
+ * this buffer they would evaporate for a second time here.
540
+ *
541
+ * Bounded at {@link MAX_PENDING_MCP_NOTIFICATIONS}; oldest entries drop under
542
+ * pressure. Never throws; per-handler errors are routed through {@link onError}
543
+ * via {@link emit}'s normal isolation.
544
+ */
545
+ async emitMcpNotification(event: Omit<McpNotificationEvent, "type">): Promise<void> {
546
+ if (!this.#initialized) {
547
+ if (this.#pendingMcpNotifications.length >= MAX_PENDING_MCP_NOTIFICATIONS) {
548
+ this.#pendingMcpNotifications.shift();
549
+ }
550
+ this.#pendingMcpNotifications.push(event);
551
+ return;
552
+ }
553
+ await this.emit({ type: "mcp_notification", ...event });
554
+ }
555
+
487
556
  /** Emits a session stop pass that can be cancelled with the active settle signal. */
488
557
  async emitSessionStop(event: Omit<SessionStopEvent, "type">): Promise<SessionStopEventResult | undefined> {
489
558
  if (event.signal.aborted) return undefined;
@@ -666,6 +735,7 @@ export class ExtensionRunner {
666
735
  ui: this.#uiContext,
667
736
  getContextUsage: () => this.#getContextUsageFn(),
668
737
  compact: instructionsOrOptions => this.#compactFn(instructionsOrOptions),
738
+ getAsyncJobSnapshot: () => this.#getAsyncJobSnapshotFn(),
669
739
  hasUI: this.hasUI(),
670
740
  cwd: this.cwd,
671
741
  sessionManager: this.sessionManager,
@@ -49,6 +49,7 @@ import type { LocalProtocolOptions } from "../../internal-urls/local-protocol";
49
49
  import type { MemoryRuntimeContext } from "../../memory-backend";
50
50
  import type { CustomEditor } from "../../modes/components/custom-editor";
51
51
  import type { Theme } from "../../modes/theme/theme";
52
+ import type { AsyncJobSnapshot } from "../../session/agent-session";
52
53
  import type { CompactMode } from "../../session/compact-modes";
53
54
  import type { CustomMessage, CustomMessagePayload } from "../../session/messages";
54
55
  import type { ReadonlySessionManager, SessionManager } from "../../session/session-manager";
@@ -415,6 +416,8 @@ export interface ExtensionContext {
415
416
  ui: ExtensionUIContext;
416
417
  /** Get current context usage for the active model. */
417
418
  getContextUsage(): ContextUsage | undefined;
419
+ /** Get a read-only snapshot of async jobs owned by this session. */
420
+ getAsyncJobSnapshot(): AsyncJobSnapshot | null;
418
421
  /** Compact the session context (interactive mode shows UI). */
419
422
  compact(instructionsOrOptions?: string | CompactOptions): Promise<void>;
420
423
  /** Whether UI is available (false in print/RPC mode) */
@@ -713,6 +716,31 @@ export interface CredentialDisabledEvent {
713
716
  disabledCause: string;
714
717
  }
715
718
 
719
+ // ============================================================================
720
+ // MCP Events
721
+ // ============================================================================
722
+
723
+ /**
724
+ * Fired for every JSON-RPC notification received from a connected MCP server,
725
+ * AFTER the runtime's own handling of known list/update methods. Unknown or
726
+ * server-custom methods are delivered too — extensions can bridge them into
727
+ * session behavior by inspecting `method`/`params` and injecting a follow-up
728
+ * via `pi.sendMessage(..., { deliverAs })` or `pi.sendUserMessage(...)`.
729
+ */
730
+ export interface McpNotificationEvent {
731
+ type: "mcp_notification";
732
+ /**
733
+ * Server name as declared in the MCP config (raw, unsanitized). Note this
734
+ * differs from the sanitized prefix used in `mcp__<sanitized_server>_<tool>`
735
+ * tool names — filter by this raw name, not by tool-name prefix matching.
736
+ */
737
+ server: string;
738
+ /** JSON-RPC method (e.g. `notifications/tools/list_changed`, or server-custom). */
739
+ method: string;
740
+ /** JSON-RPC params, opaque to the runtime. */
741
+ params: unknown;
742
+ }
743
+
716
744
  // ============================================================================
717
745
  // User Bash Events
718
746
  // ============================================================================
@@ -941,6 +969,7 @@ export type ExtensionEvent =
941
969
  | TodoReminderEvent
942
970
  | GoalUpdatedEvent
943
971
  | CredentialDisabledEvent
972
+ | McpNotificationEvent
944
973
  | UserBashEvent
945
974
  | UserPythonEvent
946
975
  | InputEvent
@@ -1133,6 +1162,7 @@ export interface ExtensionAPI {
1133
1162
  on(event: "tool_result", handler: ExtensionHandler<ToolResultEvent, ToolResultEventResult>): void;
1134
1163
  on(event: "user_bash", handler: ExtensionHandler<UserBashEvent, UserBashEventResult>): void;
1135
1164
  on(event: "user_python", handler: ExtensionHandler<UserPythonEvent, UserPythonEventResult>): void;
1165
+ on(event: "mcp_notification", handler: ExtensionHandler<McpNotificationEvent>): void;
1136
1166
 
1137
1167
  // =========================================================================
1138
1168
  // Tool Registration