@oh-my-pi/pi-coding-agent 17.1.7 → 17.2.0

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 +127 -0
  2. package/dist/{CHANGELOG-5k4dq4g6.md → CHANGELOG-be1f2t8h.md} +127 -0
  3. package/dist/cli.js +9554 -12379
  4. package/dist/template-8vdv6xb4.js +1671 -0
  5. package/dist/template-f8wx9vfn.css +1355 -0
  6. package/dist/template-qat058wr.html +55 -0
  7. package/dist/tool-views.generated-jdfmzwmn.js +35 -0
  8. package/dist/types/advisor/advise-tool.d.ts +0 -7
  9. package/dist/types/advisor/runtime.d.ts +8 -11
  10. package/dist/types/advisor/transcript-recorder.d.ts +15 -0
  11. package/dist/types/async/job-manager.d.ts +11 -0
  12. package/dist/types/auto-thinking/classifier.d.ts +9 -2
  13. package/dist/types/cleanse/agent.d.ts +19 -0
  14. package/dist/types/cleanse/balance.d.ts +7 -0
  15. package/dist/types/cleanse/checkers.d.ts +13 -0
  16. package/dist/types/cleanse/index.d.ts +16 -0
  17. package/dist/types/cleanse/loop.d.ts +16 -0
  18. package/dist/types/cleanse/parsers.d.ts +13 -0
  19. package/dist/types/cleanse/progress.d.ts +14 -0
  20. package/dist/types/cleanse/types.d.ts +64 -0
  21. package/dist/types/cli/args.d.ts +2 -0
  22. package/dist/types/cli/session-picker.d.ts +14 -7
  23. package/dist/types/collab/guest.d.ts +21 -2
  24. package/dist/types/commands/cleanse.d.ts +23 -0
  25. package/dist/types/commands/launch.d.ts +6 -0
  26. package/dist/types/config/settings-schema.d.ts +63 -8
  27. package/dist/types/cursor-bridge-tools.d.ts +54 -0
  28. package/dist/types/cursor.d.ts +193 -8
  29. package/dist/types/edit/edit-clipboard.d.ts +19 -0
  30. package/dist/types/edit/hashline/diff.d.ts +9 -1
  31. package/dist/types/edit/index.d.ts +8 -1
  32. package/dist/types/edit/streaming.d.ts +8 -1
  33. package/dist/types/export/html/index.d.ts +2 -0
  34. package/dist/types/extensibility/extensions/runner.d.ts +19 -2
  35. package/dist/types/extensibility/extensions/types.d.ts +25 -1
  36. package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +27 -1
  37. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +2 -0
  38. package/dist/types/internal-urls/local-protocol.d.ts +3 -2
  39. package/dist/types/launch/terminal-output-worker-client.d.ts +5 -0
  40. package/dist/types/launch/terminal-output-worker-protocol.d.ts +14 -0
  41. package/dist/types/launch/terminal-output-worker.d.ts +1 -0
  42. package/dist/types/lsp/client.d.ts +2 -0
  43. package/dist/types/main.d.ts +2 -0
  44. package/dist/types/mcp/manager.d.ts +33 -4
  45. package/dist/types/modes/components/codex-reset-fireworks.d.ts +51 -0
  46. package/dist/types/modes/components/read-tool-group.d.ts +12 -0
  47. package/dist/types/modes/components/session-selector.d.ts +6 -0
  48. package/dist/types/modes/components/status-line/component.d.ts +23 -0
  49. package/dist/types/modes/components/tool-execution.d.ts +16 -7
  50. package/dist/types/modes/components/usage-row.d.ts +2 -0
  51. package/dist/types/modes/controllers/mcp-command-controller.d.ts +26 -1
  52. package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
  53. package/dist/types/modes/interactive-mode.d.ts +8 -5
  54. package/dist/types/modes/rpc/rpc-client.d.ts +7 -0
  55. package/dist/types/modes/rpc/rpc-types.d.ts +16 -0
  56. package/dist/types/modes/theme/theme.d.ts +12 -1
  57. package/dist/types/modes/types.d.ts +2 -1
  58. package/dist/types/sdk.d.ts +1 -0
  59. package/dist/types/session/agent-session-events.d.ts +2 -0
  60. package/dist/types/session/agent-session-types.d.ts +36 -1
  61. package/dist/types/session/agent-session.d.ts +4 -2
  62. package/dist/types/session/async-job-delivery.d.ts +8 -0
  63. package/dist/types/session/claude-session-store.d.ts +13 -0
  64. package/dist/types/session/codex-session-store.d.ts +14 -0
  65. package/dist/types/session/credential-pin.d.ts +58 -0
  66. package/dist/types/session/foreign-session-import.d.ts +15 -0
  67. package/dist/types/session/foreign-session-jsonl.d.ts +9 -0
  68. package/dist/types/session/foreign-session-store.d.ts +23 -0
  69. package/dist/types/session/model-controls.d.ts +3 -3
  70. package/dist/types/session/session-advisors.d.ts +47 -2
  71. package/dist/types/session/session-entries.d.ts +19 -1
  72. package/dist/types/session/session-handoff.d.ts +4 -1
  73. package/dist/types/session/session-loader.d.ts +2 -0
  74. package/dist/types/session/session-maintenance.d.ts +1 -1
  75. package/dist/types/session/session-manager.d.ts +21 -0
  76. package/dist/types/session/session-tools.d.ts +25 -1
  77. package/dist/types/session/turn-recovery.d.ts +10 -4
  78. package/dist/types/system-prompt.d.ts +2 -1
  79. package/dist/types/task/executor.d.ts +11 -2
  80. package/dist/types/task/isolation-ownership.d.ts +34 -0
  81. package/dist/types/task/render.d.ts +7 -0
  82. package/dist/types/thinking.d.ts +18 -6
  83. package/dist/types/tools/acp-bridge.d.ts +28 -5
  84. package/dist/types/tools/browser/cmux/cmux-tab.d.ts +1 -2
  85. package/dist/types/tools/browser/tab-worker.d.ts +1 -4
  86. package/dist/types/tools/grep.d.ts +17 -1
  87. package/dist/types/tools/hub/launch.d.ts +5 -1
  88. package/dist/types/tools/index.d.ts +4 -1
  89. package/dist/types/tools/path-utils.d.ts +19 -0
  90. package/dist/types/tools/read.d.ts +2 -0
  91. package/dist/types/tools/xdev.d.ts +2 -1
  92. package/dist/types/tui/output-block.d.ts +5 -5
  93. package/dist/types/utils/changelog.d.ts +19 -0
  94. package/dist/types/utils/git.d.ts +10 -0
  95. package/dist/types/utils/jj.d.ts +11 -4
  96. package/dist/types/web/search/types.d.ts +2 -2
  97. package/package.json +16 -12
  98. package/scripts/bundle-dist.ts +3 -1
  99. package/src/advisor/advise-tool.ts +0 -11
  100. package/src/advisor/runtime.ts +138 -24
  101. package/src/advisor/transcript-recorder.ts +56 -0
  102. package/src/async/job-manager.ts +30 -7
  103. package/src/auto-thinking/classifier.ts +48 -10
  104. package/src/cleanse/agent.ts +226 -0
  105. package/src/cleanse/balance.ts +79 -0
  106. package/src/cleanse/checkers.ts +996 -0
  107. package/src/cleanse/index.ts +190 -0
  108. package/src/cleanse/loop.ts +51 -0
  109. package/src/cleanse/parsers.ts +726 -0
  110. package/src/cleanse/progress.ts +50 -0
  111. package/src/cleanse/prompts/assignment.md +47 -0
  112. package/src/cleanse/types.ts +72 -0
  113. package/src/cli/args.ts +6 -0
  114. package/src/cli/flag-tables.ts +2 -0
  115. package/src/cli/gallery-fixtures/fs.ts +25 -0
  116. package/src/cli/session-picker.ts +34 -17
  117. package/src/cli/ttsr-cli.ts +19 -1
  118. package/src/cli/update-cli.ts +3 -0
  119. package/src/cli/usage-cli.ts +29 -4
  120. package/src/cli/worktree-cli.ts +28 -11
  121. package/src/cli-commands.ts +1 -0
  122. package/src/cli.ts +11 -1
  123. package/src/collab/guest.ts +37 -4
  124. package/src/collab/host.ts +1 -0
  125. package/src/commands/cleanse.ts +45 -0
  126. package/src/commands/launch.ts +6 -0
  127. package/src/config/model-registry.ts +103 -25
  128. package/src/config/settings-schema.ts +65 -6
  129. package/src/config/settings.ts +25 -0
  130. package/src/cursor-bridge-tools.ts +81 -0
  131. package/src/cursor.ts +463 -12
  132. package/src/discovery/claude-plugins.ts +144 -34
  133. package/src/edit/edit-clipboard.ts +23 -0
  134. package/src/edit/hashline/diff.ts +49 -10
  135. package/src/edit/hashline/execute.ts +38 -6
  136. package/src/edit/hashline/filesystem.ts +27 -3
  137. package/src/edit/index.ts +12 -2
  138. package/src/edit/renderer.ts +4 -4
  139. package/src/edit/streaming.ts +17 -2
  140. package/src/export/html/index.ts +17 -10
  141. package/src/export/html/template.js +1 -1
  142. package/src/extensibility/extensions/runner.ts +70 -0
  143. package/src/extensibility/extensions/types.ts +30 -0
  144. package/src/extensibility/legacy-pi-ai-shim.ts +46 -1
  145. package/src/extensibility/legacy-pi-coding-agent-shim.ts +12 -13
  146. package/src/internal-urls/local-protocol.ts +3 -2
  147. package/src/internal-urls/mcp-protocol.ts +4 -1
  148. package/src/launch/broker.ts +14 -4
  149. package/src/launch/terminal-output-worker-client.ts +53 -0
  150. package/src/launch/terminal-output-worker-protocol.ts +11 -0
  151. package/src/launch/terminal-output-worker.ts +23 -0
  152. package/src/lsp/client.ts +18 -3
  153. package/src/lsp/index.ts +4 -0
  154. package/src/lsp/render.ts +1 -1
  155. package/src/main.ts +127 -56
  156. package/src/mcp/manager.ts +120 -18
  157. package/src/modes/acp/acp-agent.ts +27 -18
  158. package/src/modes/acp/acp-event-mapper.ts +38 -4
  159. package/src/modes/components/chat-transcript-builder.ts +30 -11
  160. package/src/modes/components/codex-reset-fireworks.ts +369 -0
  161. package/src/modes/components/read-tool-group.ts +159 -17
  162. package/src/modes/components/session-selector.ts +19 -3
  163. package/src/modes/components/settings-selector.ts +9 -1
  164. package/src/modes/components/status-line/component.ts +381 -54
  165. package/src/modes/components/tool-execution.ts +78 -31
  166. package/src/modes/components/usage-row.ts +10 -5
  167. package/src/modes/controllers/event-controller.ts +26 -4
  168. package/src/modes/controllers/mcp-command-controller.ts +68 -3
  169. package/src/modes/controllers/selector-controller.ts +94 -36
  170. package/src/modes/controllers/tan-command-controller.ts +14 -0
  171. package/src/modes/interactive-mode.ts +67 -64
  172. package/src/modes/prompt-action-autocomplete.ts +5 -3
  173. package/src/modes/rpc/rpc-client.ts +18 -0
  174. package/src/modes/rpc/rpc-mode.ts +16 -1
  175. package/src/modes/rpc/rpc-types.ts +11 -0
  176. package/src/modes/theme/theme.ts +151 -6
  177. package/src/modes/types.ts +2 -1
  178. package/src/modes/utils/ui-helpers.ts +38 -21
  179. package/src/prompts/goals/guided-goal-interview.md +41 -6
  180. package/src/prompts/system/auto-thinking-difficulty.md +4 -2
  181. package/src/prompts/system/plan-mode-active.md +2 -2
  182. package/src/prompts/system/system-prompt.md +3 -0
  183. package/src/prompts/system/vibe-mode-active.md +4 -1
  184. package/src/prompts/system/xdev-mount-notice.md +1 -1
  185. package/src/prompts/tools/bash.md +15 -17
  186. package/src/prompts/tools/browser.md +1 -0
  187. package/src/prompts/tools/checkpoint.md +1 -1
  188. package/src/prompts/tools/glob.md +7 -6
  189. package/src/prompts/tools/goal.md +1 -1
  190. package/src/prompts/tools/grep.md +7 -6
  191. package/src/sdk.ts +178 -15
  192. package/src/session/agent-session-events.ts +1 -0
  193. package/src/session/agent-session-types.ts +40 -1
  194. package/src/session/agent-session.ts +330 -179
  195. package/src/session/async-job-delivery.ts +8 -0
  196. package/src/session/claude-session-store.ts +426 -0
  197. package/src/session/codex-session-store.ts +673 -0
  198. package/src/session/credential-pin.ts +93 -0
  199. package/src/session/foreign-session-import.ts +52 -0
  200. package/src/session/foreign-session-jsonl.ts +29 -0
  201. package/src/session/foreign-session-store.ts +26 -0
  202. package/src/session/model-controls.ts +15 -7
  203. package/src/session/session-advisors.ts +142 -23
  204. package/src/session/session-entries.ts +21 -1
  205. package/src/session/session-handoff.ts +20 -3
  206. package/src/session/session-history-format.ts +31 -6
  207. package/src/session/session-loader.ts +25 -9
  208. package/src/session/session-maintenance.ts +59 -11
  209. package/src/session/session-manager.ts +61 -0
  210. package/src/session/session-tools.ts +107 -5
  211. package/src/session/settings-stream-fn.ts +1 -0
  212. package/src/session/turn-recovery.ts +97 -51
  213. package/src/slash-commands/builtin-registry.ts +136 -5
  214. package/src/system-prompt.ts +3 -2
  215. package/src/task/executor.ts +17 -4
  216. package/src/task/isolation-ownership.ts +106 -0
  217. package/src/task/render.ts +20 -4
  218. package/src/task/worktree.ts +8 -0
  219. package/src/thinking.ts +29 -10
  220. package/src/tools/acp-bridge.ts +52 -8
  221. package/src/tools/ask.ts +3 -3
  222. package/src/tools/ast-edit.ts +9 -2
  223. package/src/tools/browser/cmux/cmux-tab.ts +9 -14
  224. package/src/tools/browser/tab-worker.ts +12 -35
  225. package/src/tools/browser.ts +7 -2
  226. package/src/tools/checkpoint.ts +0 -13
  227. package/src/tools/gh-renderer.ts +3 -3
  228. package/src/tools/grep.ts +64 -8
  229. package/src/tools/hub/launch.ts +10 -15
  230. package/src/tools/index.ts +38 -5
  231. package/src/tools/path-utils.ts +88 -0
  232. package/src/tools/read.ts +3 -0
  233. package/src/tools/write.ts +44 -3
  234. package/src/tools/xdev.ts +39 -14
  235. package/src/tui/code-cell.ts +4 -4
  236. package/src/tui/output-block.ts +25 -8
  237. package/src/utils/changelog.ts +144 -4
  238. package/src/utils/git.ts +61 -14
  239. package/src/utils/jj.ts +19 -13
  240. package/src/utils/shell-snapshot-fn-env.sh +5 -2
  241. package/src/web/search/providers/codex.ts +34 -0
  242. package/src/web/search/render.ts +2 -2
  243. package/src/web/search/types.ts +1 -1
  244. package/dist/types/goals/guided-setup.d.ts +0 -30
  245. package/src/goals/guided-setup.ts +0 -171
  246. package/src/prompts/goals/guided-goal-system.md +0 -33
@@ -41,6 +41,20 @@ interface ResolvedPluginDir {
41
41
  warnings: string[];
42
42
  }
43
43
 
44
+ interface ResolvedMCPConfig {
45
+ /** On-disk config file to read, or null when servers are inline or nothing applies. */
46
+ path: string | null;
47
+ /** Server map declared inline in the plugin manifest, or null when the source is a file. */
48
+ inlineServers: Record<string, unknown> | null;
49
+ /** Path recorded as each discovered server's capability source. */
50
+ sourcePath: string;
51
+ /** Directory that relative stdio `command`/`cwd` values resolve against. */
52
+ baseDir: string;
53
+ /** True when a plugin manifest named this source, false for the conventional fallback. */
54
+ declared: boolean;
55
+ warnings: string[];
56
+ }
57
+
44
58
  async function readPluginManifest(root: ClaudePluginRoot): Promise<ClaudePluginManifest | null> {
45
59
  const manifestPath = path.join(root.path, ".claude-plugin", "plugin.json");
46
60
  const raw = await readFile(manifestPath);
@@ -375,50 +389,144 @@ async function loadTools(ctx: LoadContext): Promise<LoadResult<CustomTool>> {
375
389
  // MCP Servers
376
390
  // =============================================================================
377
391
 
378
- async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>> {
379
- const items: MCPServer[] = [];
380
- const warnings: string[] = [];
381
-
382
- const { roots, warnings: rootWarnings } = await listClaudePluginRoots(ctx.home, ctx.cwd);
383
- warnings.push(...rootWarnings);
392
+ /**
393
+ * Unwrap a parsed MCP config file to its server map. Supports the nested
394
+ * `{ mcpServers: { } }` project shape and the flat `{ name: cfg, … }`
395
+ * marketplace-plugin shape. Returns null when a `mcpServers` field is present
396
+ * but not an object map (malformed) so the caller skips the file.
397
+ */
398
+ function extractServerMap(obj: Record<string, unknown>): Record<string, unknown> | null {
399
+ if (isRecord(obj.mcpServers)) return obj.mcpServers;
400
+ if (!("mcpServers" in obj)) return obj;
401
+ return null;
402
+ }
384
403
 
385
- for (const root of roots) {
386
- const mcpPath = path.join(root.path, ".mcp.json");
387
- const raw = await readFile(mcpPath);
388
- if (raw === null) continue; // file absent — skip silently
404
+ /**
405
+ * Resolve where a plugin's MCP servers come from, honoring the manifest's
406
+ * `mcpServers` field before the conventional root `.mcp.json`.
407
+ *
408
+ * `.omp-plugin/plugin.json` takes precedence over `.claude-plugin/plugin.json`.
409
+ * The field may be an inline object (the server map itself) or a string path to
410
+ * a config file within the plugin root; a path escaping the root is rejected
411
+ * with a warning. When no manifest declares the field, `<root>/.mcp.json` is the
412
+ * fallback source.
413
+ */
414
+ async function resolvePluginMCPConfig(root: ClaudePluginRoot): Promise<ResolvedMCPConfig> {
415
+ const fallback = path.join(root.path, ".mcp.json");
416
+ for (const manifestDir of [".omp-plugin", ".claude-plugin"]) {
417
+ const manifestPath = path.join(root.path, manifestDir, "plugin.json");
418
+ const raw = await readFile(manifestPath);
419
+ if (raw === null) continue;
389
420
 
390
421
  let parsed: unknown;
391
422
  try {
392
423
  parsed = JSON.parse(raw);
393
424
  } catch {
394
- warnings.push(`[claude-plugins] Invalid JSON in ${mcpPath}`);
395
- logger.warn(`[claude-plugins] Invalid JSON in ${mcpPath}`);
396
425
  continue;
397
426
  }
427
+ if (!isRecord(parsed)) continue;
428
+ const pointer = parsed.mcpServers;
429
+
430
+ // Inline object form: the manifest value is the server map itself, rooted
431
+ // at the plugin directory (Claude's ${CLAUDE_PLUGIN_ROOT} base).
432
+ if (isRecord(pointer)) {
433
+ return {
434
+ path: null,
435
+ inlineServers: pointer,
436
+ sourcePath: manifestPath,
437
+ baseDir: root.path,
438
+ declared: true,
439
+ warnings: [],
440
+ };
441
+ }
442
+
443
+ // File-pointer form: resolve the named config file within the plugin root.
444
+ if (typeof pointer === "string") {
445
+ const configured = pointer.trim();
446
+ if (configured.length === 0) continue;
447
+ const resolved = path.resolve(root.path, configured);
448
+ if (!isWithinPluginRoot(root.path, resolved)) {
449
+ return {
450
+ path: null,
451
+ inlineServers: null,
452
+ sourcePath: manifestPath,
453
+ baseDir: root.path,
454
+ declared: true,
455
+ warnings: [
456
+ `[claude-plugins] Ignoring mcpServers path outside plugin root for ${root.id}: ${configured}`,
457
+ ],
458
+ };
459
+ }
460
+ return {
461
+ path: resolved,
462
+ inlineServers: null,
463
+ sourcePath: resolved,
464
+ baseDir: path.dirname(resolved),
465
+ declared: true,
466
+ warnings: [],
467
+ };
468
+ }
469
+ }
470
+
471
+ return {
472
+ path: fallback,
473
+ inlineServers: null,
474
+ sourcePath: fallback,
475
+ baseDir: path.dirname(fallback),
476
+ declared: false,
477
+ warnings: [],
478
+ };
479
+ }
480
+
481
+ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>> {
482
+ const items: MCPServer[] = [];
483
+ const warnings: string[] = [];
398
484
 
399
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue;
400
- const obj = parsed as Record<string, unknown>;
401
-
402
- // Two shapes are supported:
403
- // nested: { "mcpServers": { name: cfg, ... } } (OMP/Claude Code project shape)
404
- // flat: { name: cfg, ... } (Claude marketplace plugin shape)
405
- // If "mcpServers" is present and an object, treat it as the canonical map.
406
- // Otherwise, treat the whole object as the server map.
407
- let servers: Record<string, unknown>;
408
- if (
409
- obj.mcpServers !== undefined &&
410
- obj.mcpServers !== null &&
411
- typeof obj.mcpServers === "object" &&
412
- !Array.isArray(obj.mcpServers)
413
- ) {
414
- servers = obj.mcpServers as Record<string, unknown>;
415
- } else if (!("mcpServers" in obj)) {
416
- servers = obj;
485
+ const { roots, warnings: rootWarnings } = await listClaudePluginRoots(ctx.home, ctx.cwd);
486
+ warnings.push(...rootWarnings);
487
+
488
+ for (const root of roots) {
489
+ const resolved = await resolvePluginMCPConfig(root);
490
+ warnings.push(...resolved.warnings);
491
+
492
+ let servers: Record<string, unknown> | null;
493
+ if (resolved.inlineServers) {
494
+ servers = resolved.inlineServers;
495
+ } else if (resolved.path !== null) {
496
+ const raw = await readFile(resolved.path);
497
+ if (raw === null) {
498
+ // The conventional fallback is optional, but a manifest that names a
499
+ // missing file is an authoring error that would otherwise register
500
+ // zero servers with no explanation.
501
+ if (resolved.declared) {
502
+ const warning = `[claude-plugins] Missing mcpServers file declared by ${root.id}: ${resolved.path}`;
503
+ warnings.push(warning);
504
+ logger.warn(warning);
505
+ }
506
+ continue;
507
+ }
508
+
509
+ let parsed: unknown;
510
+ try {
511
+ parsed = JSON.parse(raw);
512
+ } catch {
513
+ warnings.push(`[claude-plugins] Invalid JSON in ${resolved.path}`);
514
+ logger.warn(`[claude-plugins] Invalid JSON in ${resolved.path}`);
515
+ continue;
516
+ }
517
+ // Two file shapes are supported:
518
+ // nested: { "mcpServers": { name: cfg, ... } } (OMP/Claude Code project shape)
519
+ // flat: { name: cfg, ... } (Claude marketplace plugin shape)
520
+ if (!isRecord(parsed)) continue;
521
+ servers = extractServerMap(parsed);
417
522
  } else {
418
523
  continue;
419
524
  }
525
+ if (servers === null) continue;
420
526
 
421
- for (const [serverName, serverCfg] of Object.entries(servers)) {
527
+ const { sourcePath, baseDir } = resolved;
528
+ for (const serverName in servers) {
529
+ const serverCfg = servers[serverName];
422
530
  if (!serverCfg || typeof serverCfg !== "object" || Array.isArray(serverCfg)) continue;
423
531
  const raw = serverCfg as {
424
532
  enabled?: boolean;
@@ -437,7 +545,9 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
437
545
  // occasionally ship .mcp.json entries with neither, which would register a useless
438
546
  // server and surface as a connection error at runtime.
439
547
  if (typeof raw.command !== "string" && typeof raw.url !== "string") {
440
- warnings.push(`[claude-plugins] Skipping MCP server "${serverName}" in ${mcpPath}: missing command or url`);
548
+ warnings.push(
549
+ `[claude-plugins] Skipping MCP server "${serverName}" in ${sourcePath}: missing command or url`,
550
+ );
441
551
  continue;
442
552
  }
443
553
  const namespacedName = root.plugin ? `${root.plugin}:${serverName}` : serverName;
@@ -446,7 +556,7 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
446
556
  const substitutedCwd = raw.cwd !== undefined ? substitutePluginRoot(raw.cwd, root.path) : undefined;
447
557
  // Root relative command/cwd at the plugin's config directory, not the
448
558
  // session cwd (MCP stdio spawning resolves relative values there).
449
- const rooted = resolvePluginStdioPaths({ command: substitutedCommand, cwd: substitutedCwd }, root.path);
559
+ const rooted = resolvePluginStdioPaths({ command: substitutedCommand, cwd: substitutedCwd }, baseDir);
450
560
  const server: MCPServer = {
451
561
  name: namespacedName,
452
562
  ...(raw.enabled !== undefined && { enabled: raw.enabled }),
@@ -460,7 +570,7 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
460
570
  ...(raw.auth !== undefined && { auth: raw.auth }),
461
571
  ...(raw.oauth !== undefined && { oauth: raw.oauth }),
462
572
  ...(raw.type !== undefined && { transport: raw.type as MCPServer["transport"] }),
463
- _source: createSourceMeta(PROVIDER_ID, mcpPath, root.scope),
573
+ _source: createSourceMeta(PROVIDER_ID, sourcePath, root.scope),
464
574
  };
465
575
  items.push(server);
466
576
  }
@@ -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
+ }
@@ -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)