@caupulican/pi-adaptative 0.81.43 → 0.85.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 (159) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/bundled-resources/extensions/tmux-agent-manager/index.d.ts.map +1 -1
  3. package/dist/bundled-resources/extensions/tmux-agent-manager/index.js +14 -8
  4. package/dist/bundled-resources/extensions/tmux-agent-manager/index.js.map +1 -1
  5. package/dist/bundled-resources/extensions/tmux-agent-manager/index.ts +14 -8
  6. package/dist/bundled-resources/skills/deduplicate-by-evidence/SKILL.md +7 -5
  7. package/dist/bundled-resources/skills/worker-profile-writer/SKILL.md +63 -0
  8. package/dist/config.d.ts +1 -1
  9. package/dist/config.d.ts.map +1 -1
  10. package/dist/config.js +1 -1
  11. package/dist/config.js.map +1 -1
  12. package/dist/core/agent-paths.d.ts +1 -1
  13. package/dist/core/agent-paths.d.ts.map +1 -1
  14. package/dist/core/agent-paths.js +1 -1
  15. package/dist/core/agent-paths.js.map +1 -1
  16. package/dist/core/autonomy/lane-tool-surface.d.ts.map +1 -1
  17. package/dist/core/autonomy/lane-tool-surface.js +4 -2
  18. package/dist/core/autonomy/lane-tool-surface.js.map +1 -1
  19. package/dist/core/background-lane-controller.d.ts +3 -0
  20. package/dist/core/background-lane-controller.d.ts.map +1 -1
  21. package/dist/core/background-lane-controller.js +6 -0
  22. package/dist/core/background-lane-controller.js.map +1 -1
  23. package/dist/core/default-tool-surface.d.ts.map +1 -1
  24. package/dist/core/default-tool-surface.js +1 -0
  25. package/dist/core/default-tool-surface.js.map +1 -1
  26. package/dist/core/delegation/worker-delegation-controller.d.ts +7 -0
  27. package/dist/core/delegation/worker-delegation-controller.d.ts.map +1 -1
  28. package/dist/core/delegation/worker-delegation-controller.js +28 -0
  29. package/dist/core/delegation/worker-delegation-controller.js.map +1 -1
  30. package/dist/core/delegation/worker-profile-resolver.d.ts +2 -0
  31. package/dist/core/delegation/worker-profile-resolver.d.ts.map +1 -1
  32. package/dist/core/delegation/worker-profile-resolver.js +46 -11
  33. package/dist/core/delegation/worker-profile-resolver.js.map +1 -1
  34. package/dist/core/doctor.d.ts +5 -5
  35. package/dist/core/doctor.d.ts.map +1 -1
  36. package/dist/core/doctor.js +17 -20
  37. package/dist/core/doctor.js.map +1 -1
  38. package/dist/core/model-capability.d.ts.map +1 -1
  39. package/dist/core/model-capability.js +1 -0
  40. package/dist/core/model-capability.js.map +1 -1
  41. package/dist/core/orchestration/lane-tool-manifests.d.ts +1 -1
  42. package/dist/core/orchestration/lane-tool-manifests.d.ts.map +1 -1
  43. package/dist/core/orchestration/lane-tool-manifests.js +6 -1
  44. package/dist/core/orchestration/lane-tool-manifests.js.map +1 -1
  45. package/dist/core/orchestration/session-task-profile-store.d.ts +31 -0
  46. package/dist/core/orchestration/session-task-profile-store.d.ts.map +1 -0
  47. package/dist/core/orchestration/session-task-profile-store.js +138 -0
  48. package/dist/core/orchestration/session-task-profile-store.js.map +1 -0
  49. package/dist/core/orchestration/task-profile-writer.d.ts +58 -0
  50. package/dist/core/orchestration/task-profile-writer.d.ts.map +1 -0
  51. package/dist/core/orchestration/task-profile-writer.js +165 -0
  52. package/dist/core/orchestration/task-profile-writer.js.map +1 -0
  53. package/dist/core/runtime-builder.d.ts +3 -1
  54. package/dist/core/runtime-builder.d.ts.map +1 -1
  55. package/dist/core/runtime-builder.js +14 -0
  56. package/dist/core/runtime-builder.js.map +1 -1
  57. package/dist/core/secrets/credential-exposure-guard.d.ts.map +1 -1
  58. package/dist/core/secrets/credential-exposure-guard.js +32 -0
  59. package/dist/core/secrets/credential-exposure-guard.js.map +1 -1
  60. package/dist/core/security/untrusted-boundary.d.ts.map +1 -1
  61. package/dist/core/security/untrusted-boundary.js +4 -6
  62. package/dist/core/security/untrusted-boundary.js.map +1 -1
  63. package/dist/core/session-role.d.ts.map +1 -1
  64. package/dist/core/session-role.js +1 -0
  65. package/dist/core/session-role.js.map +1 -1
  66. package/dist/core/system-prompt.d.ts.map +1 -1
  67. package/dist/core/system-prompt.js +21 -22
  68. package/dist/core/system-prompt.js.map +1 -1
  69. package/dist/core/tool-capability-policy.js +1 -1
  70. package/dist/core/tool-capability-policy.js.map +1 -1
  71. package/dist/core/tools/bash.d.ts +1 -6
  72. package/dist/core/tools/bash.d.ts.map +1 -1
  73. package/dist/core/tools/bash.js +2 -26
  74. package/dist/core/tools/bash.js.map +1 -1
  75. package/dist/core/tools/delegate.js +4 -4
  76. package/dist/core/tools/delegate.js.map +1 -1
  77. package/dist/core/tools/edit-diff.d.ts +32 -5
  78. package/dist/core/tools/edit-diff.d.ts.map +1 -1
  79. package/dist/core/tools/edit-diff.js +137 -69
  80. package/dist/core/tools/edit-diff.js.map +1 -1
  81. package/dist/core/tools/edit.d.ts +22 -7
  82. package/dist/core/tools/edit.d.ts.map +1 -1
  83. package/dist/core/tools/edit.js +128 -55
  84. package/dist/core/tools/edit.js.map +1 -1
  85. package/dist/core/tools/file-mutation-intent.d.ts +70 -0
  86. package/dist/core/tools/file-mutation-intent.d.ts.map +1 -0
  87. package/dist/core/tools/file-mutation-intent.js +325 -0
  88. package/dist/core/tools/file-mutation-intent.js.map +1 -0
  89. package/dist/core/tools/goal.d.ts.map +1 -1
  90. package/dist/core/tools/goal.js +41 -18
  91. package/dist/core/tools/goal.js.map +1 -1
  92. package/dist/core/tools/index.d.ts +3 -1
  93. package/dist/core/tools/index.d.ts.map +1 -1
  94. package/dist/core/tools/index.js +31 -8
  95. package/dist/core/tools/index.js.map +1 -1
  96. package/dist/core/tools/profile-writer.d.ts +33 -0
  97. package/dist/core/tools/profile-writer.d.ts.map +1 -0
  98. package/dist/core/tools/profile-writer.js +67 -0
  99. package/dist/core/tools/profile-writer.js.map +1 -0
  100. package/dist/core/tools/task-steps.d.ts.map +1 -1
  101. package/dist/core/tools/task-steps.js +6 -11
  102. package/dist/core/tools/task-steps.js.map +1 -1
  103. package/dist/core/tools/tmux-dispatch.d.ts +2 -0
  104. package/dist/core/tools/tmux-dispatch.d.ts.map +1 -1
  105. package/dist/core/tools/tmux-dispatch.js +16 -2
  106. package/dist/core/tools/tmux-dispatch.js.map +1 -1
  107. package/dist/core/tools/write.d.ts +24 -7
  108. package/dist/core/tools/write.d.ts.map +1 -1
  109. package/dist/core/tools/write.js +182 -107
  110. package/dist/core/tools/write.js.map +1 -1
  111. package/dist/index.d.ts +1 -1
  112. package/dist/index.d.ts.map +1 -1
  113. package/dist/index.js +1 -1
  114. package/dist/index.js.map +1 -1
  115. package/dist/jscpd-cli.d.ts +3 -0
  116. package/dist/jscpd-cli.d.ts.map +1 -0
  117. package/dist/jscpd-cli.js +22 -0
  118. package/dist/jscpd-cli.js.map +1 -0
  119. package/dist/modes/interactive/components/profile-resource-editor.js +2 -2
  120. package/dist/modes/interactive/components/profile-resource-editor.js.map +1 -1
  121. package/dist/modes/interactive/components/tool-execution.d.ts +1 -0
  122. package/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  123. package/dist/modes/interactive/components/tool-execution.js +85 -3
  124. package/dist/modes/interactive/components/tool-execution.js.map +1 -1
  125. package/dist/modes/interactive/interactive-mode.js +3 -3
  126. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  127. package/dist/utils/bundled-jscpd.d.ts +20 -0
  128. package/dist/utils/bundled-jscpd.d.ts.map +1 -0
  129. package/dist/utils/bundled-jscpd.js +154 -0
  130. package/dist/utils/bundled-jscpd.js.map +1 -0
  131. package/dist/utils/shell.d.ts.map +1 -1
  132. package/dist/utils/shell.js +11 -0
  133. package/dist/utils/shell.js.map +1 -1
  134. package/dist/utils/tools-manager.d.ts +6 -1
  135. package/dist/utils/tools-manager.d.ts.map +1 -1
  136. package/dist/utils/tools-manager.js +81 -2
  137. package/dist/utils/tools-manager.js.map +1 -1
  138. package/docs/development.md +1 -1
  139. package/docs/index.md +3 -0
  140. package/docs/managed-data-tools.md +15 -0
  141. package/docs/quickstart.md +2 -2
  142. package/docs/tool-boundary-performance-roadmap-2026-08-02.md +166 -0
  143. package/docs/tool-repair.md +1 -1
  144. package/docs/windows.md +2 -2
  145. package/docs/work-directory.md +1 -1
  146. package/docs/worker-profiles.md +16 -0
  147. package/examples/extensions/built-in-tool-renderer.ts +12 -5
  148. package/examples/extensions/custom-provider-anthropic/package-lock.json +2 -2
  149. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  150. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  151. package/examples/extensions/minimal-mode.ts +8 -7
  152. package/examples/extensions/prompt-customizer.ts +4 -2
  153. package/examples/extensions/sandbox/package-lock.json +2 -2
  154. package/examples/extensions/sandbox/package.json +1 -1
  155. package/examples/extensions/ssh.ts +113 -24
  156. package/examples/extensions/with-deps/package-lock.json +2 -2
  157. package/examples/extensions/with-deps/package.json +1 -1
  158. package/npm-shrinkwrap.json +123 -23
  159. package/package.json +8 -6
@@ -0,0 +1,16 @@
1
+ # Task worker profiles
2
+
3
+ `profile_writer` lets the foreground orchestrator create the smallest worker profile needed for one bounded task. It does not edit user or project profile files.
4
+
5
+ Task profiles are:
6
+
7
+ - Derived only from an owner-authorized base profile.
8
+ - Immutable and assigned a `task-...` ID by Pi.
9
+ - Stored on the owning session branch, never shared across independent sessions.
10
+ - Limited to 32 profiles per session.
11
+ - Unable to add tools, resources, or budget beyond the base profile.
12
+ - Limited to configured, authenticated, non-exhausted models and a thinking level that model supports.
13
+
14
+ Use `profile_writer { action: "inspect" }` when the authorized bases or model combinations are unknown. Then call `create` with a concise task and only the fields that need narrowing. Pass the returned profile ID unchanged to `delegate`; never invent one.
15
+
16
+ Workers cannot invoke `profile_writer`, recursively delegate, or expand their admitted contract. A resumed worker retains the exact profile, model, resources, and transcript admitted for that task.
@@ -26,7 +26,13 @@
26
26
  */
27
27
 
28
28
  import type { BashToolDetails, EditToolDetails, ExtensionAPI, ReadToolDetails } from "@caupulican/pi-adaptative";
29
- import { createBashTool, createEditTool, createReadTool, createWriteTool } from "@caupulican/pi-adaptative";
29
+ import {
30
+ createBashTool,
31
+ createEditTool,
32
+ createReadTool,
33
+ createWriteTool,
34
+ FileMutationIntentController,
35
+ } from "@caupulican/pi-adaptative";
30
36
  import { Text } from "@caupulican/pi-tui";
31
37
 
32
38
  export default function (pi: ExtensionAPI) {
@@ -151,7 +157,8 @@ export default function (pi: ExtensionAPI) {
151
157
  });
152
158
 
153
159
  // --- Edit tool: show path and diff stats ---
154
- const originalEdit = createEditTool(cwd);
160
+ const fileMutationIntents = new FileMutationIntentController();
161
+ const originalEdit = createEditTool(cwd, { intentController: fileMutationIntents });
155
162
  pi.registerTool({
156
163
  name: "edit",
157
164
  label: "edit",
@@ -216,7 +223,7 @@ export default function (pi: ExtensionAPI) {
216
223
  });
217
224
 
218
225
  // --- Write tool: show path and size ---
219
- const originalWrite = createWriteTool(cwd);
226
+ const originalWrite = createWriteTool(cwd, { intentController: fileMutationIntents });
220
227
  pi.registerTool({
221
228
  name: "write",
222
229
  label: "write",
@@ -230,8 +237,8 @@ export default function (pi: ExtensionAPI) {
230
237
  renderCall(args, theme, _context) {
231
238
  let text = theme.fg("toolTitle", theme.bold("write "));
232
239
  text += theme.fg("accent", args.path);
233
- const lineCount = args.content.split("\n").length;
234
- text += theme.fg("dim", ` (${lineCount} lines)`);
240
+ const writeContent = args.action === "commit" && "content" in args ? args.content : undefined;
241
+ if (writeContent) text += theme.fg("dim", ` (${writeContent.length} characters)`);
235
242
  return new Text(text, 0, 0);
236
243
  },
237
244
 
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pi-extension-custom-provider",
3
- "version": "0.81.43",
3
+ "version": "0.85.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pi-extension-custom-provider",
9
- "version": "0.81.43",
9
+ "version": "0.85.0",
10
10
  "dependencies": {
11
11
  "@anthropic-ai/sdk": "^0.52.0"
12
12
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-extension-custom-provider-anthropic",
3
3
  "private": true,
4
- "version": "0.81.43",
4
+ "version": "0.85.0",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "clean": "echo 'nothing to clean'",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-extension-custom-provider-gitlab-duo",
3
3
  "private": true,
4
- "version": "0.81.43",
4
+ "version": "0.85.0",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "clean": "echo 'nothing to clean'",
@@ -25,6 +25,7 @@ import {
25
25
  createLsTool,
26
26
  createReadTool,
27
27
  createWriteTool,
28
+ FileMutationIntentController,
28
29
  } from "@caupulican/pi-adaptative";
29
30
  import { Text } from "@caupulican/pi-tui";
30
31
  import { homedir } from "os";
@@ -44,11 +45,12 @@ function shortenPath(path: string): string {
44
45
  const toolCache = new Map<string, ReturnType<typeof createBuiltInTools>>();
45
46
 
46
47
  function createBuiltInTools(cwd: string) {
48
+ const fileMutationIntents = new FileMutationIntentController();
47
49
  return {
48
50
  read: createReadTool(cwd),
49
51
  bash: createBashTool(cwd),
50
- edit: createEditTool(cwd),
51
- write: createWriteTool(cwd),
52
+ edit: createEditTool(cwd, { intentController: fileMutationIntents }),
53
+ write: createWriteTool(cwd, { intentController: fileMutationIntents }),
52
54
  find: createFindTool(cwd),
53
55
  grep: createGrepTool(cwd),
54
56
  ls: createLsTool(cwd),
@@ -167,8 +169,7 @@ export default function (pi: ExtensionAPI) {
167
169
  pi.registerTool({
168
170
  name: "write",
169
171
  label: "write",
170
- description:
171
- "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.",
172
+ description: "Create a new file through path-only preflight. Existing paths are never overwritten.",
172
173
  parameters: getBuiltInTools(process.cwd()).write.parameters,
173
174
 
174
175
  async execute(toolCallId, params, signal, onUpdate, ctx) {
@@ -179,10 +180,10 @@ export default function (pi: ExtensionAPI) {
179
180
  renderCall(args, theme, _context) {
180
181
  const path = shortenPath(args.path || "");
181
182
  const pathDisplay = path ? theme.fg("accent", path) : theme.fg("toolOutput", "...");
182
- const lineCount = args.content ? args.content.split("\n").length : 0;
183
- const lineInfo = lineCount > 0 ? theme.fg("muted", ` (${lineCount} lines)`) : "";
183
+ const writeContent = args.action === "commit" && "content" in args ? args.content : undefined;
184
+ const sizeInfo = writeContent ? theme.fg("muted", ` (${writeContent.length} characters)`) : "";
184
185
 
185
- return new Text(`${theme.fg("toolTitle", theme.bold("write"))} ${pathDisplay}${lineInfo}`, 0, 0);
186
+ return new Text(`${theme.fg("toolTitle", theme.bold("write"))} ${pathDisplay}${sizeInfo}`, 0, 0);
186
187
  },
187
188
 
188
189
  renderResult(result, { expanded }, theme, _context) {
@@ -37,12 +37,14 @@ function addToolGuidance(options: BuildSystemPromptOptions, basePrompt: string):
37
37
 
38
38
  if (hasTool("edit")) {
39
39
  parts.push(
40
- "• Use the `edit` tool for precise text replacements in files. Match exact content including whitespace.",
40
+ "• Use `edit` action=prepare before generating precise replacements, then commit with the returned intent. Match exact content including whitespace.",
41
41
  );
42
42
  }
43
43
 
44
44
  if (hasTool("write")) {
45
- parts.push("• Use the `write` tool to create new files or overwrite existing ones completely.");
45
+ parts.push(
46
+ "• Use `write` action=prepare before generating content; commit creates a new file and never overwrites.",
47
+ );
46
48
  }
47
49
 
48
50
  if (options.skills && options.skills.length > 0) {
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pi-extension-sandbox",
3
- "version": "0.81.43",
3
+ "version": "0.85.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pi-extension-sandbox",
9
- "version": "0.81.43",
9
+ "version": "0.85.0",
10
10
  "dependencies": {
11
11
  "@anthropic-ai/sandbox-runtime": "^0.0.26"
12
12
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-extension-sandbox",
3
3
  "private": true,
4
- "version": "0.81.43",
4
+ "version": "0.85.0",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "clean": "echo 'nothing to clean'",
@@ -10,10 +10,11 @@
10
10
  *
11
11
  * Requirements:
12
12
  * - SSH key-based auth (no password prompts)
13
- * - bash on remote
13
+ * - bash and GNU coreutils (sha256sum, stat) on remote
14
14
  */
15
15
 
16
16
  import { spawn } from "node:child_process";
17
+ import { constants } from "node:fs";
17
18
  import type { ExtensionAPI } from "@caupulican/pi-adaptative";
18
19
  import {
19
20
  type BashOperations,
@@ -22,13 +23,20 @@ import {
22
23
  createReadTool,
23
24
  createWriteTool,
24
25
  type EditOperations,
26
+ FileMutationIntentController,
27
+ type FileMutationIntentOperations,
28
+ type FilePathInspection,
25
29
  type ReadOperations,
26
30
  type WriteOperations,
27
31
  } from "@caupulican/pi-adaptative";
28
32
 
29
- function sshExec(remote: string, command: string): Promise<Buffer> {
33
+ function shellQuote(value: string): string {
34
+ return `'${value.replaceAll("'", `'"'"'`)}'`;
35
+ }
36
+
37
+ function sshExec(remote: string, command: string, stdin?: string | Buffer): Promise<Buffer> {
30
38
  return new Promise((resolve, reject) => {
31
- const child = spawn("ssh", [remote, command], { stdio: ["ignore", "pipe", "pipe"] });
39
+ const child = spawn("ssh", [remote, command], { stdio: ["pipe", "pipe", "pipe"] });
32
40
  const chunks: Buffer[] = [];
33
41
  const errChunks: Buffer[] = [];
34
42
  child.stdout.on("data", (data) => chunks.push(data));
@@ -41,17 +49,18 @@ function sshExec(remote: string, command: string): Promise<Buffer> {
41
49
  resolve(Buffer.concat(chunks));
42
50
  }
43
51
  });
52
+ child.stdin.end(stdin);
44
53
  });
45
54
  }
46
55
 
47
56
  function createRemoteReadOps(remote: string, remoteCwd: string, localCwd: string): ReadOperations {
48
57
  const toRemote = (p: string) => p.replace(localCwd, remoteCwd);
49
58
  return {
50
- readFile: (p) => sshExec(remote, `cat ${JSON.stringify(toRemote(p))}`),
51
- access: (p) => sshExec(remote, `test -r ${JSON.stringify(toRemote(p))}`).then(() => {}),
59
+ readFile: (p) => sshExec(remote, `cat -- ${shellQuote(toRemote(p))}`),
60
+ access: (p) => sshExec(remote, `test -r ${shellQuote(toRemote(p))}`).then(() => {}),
52
61
  detectImageMimeType: async (p) => {
53
62
  try {
54
- const r = await sshExec(remote, `file --mime-type -b ${JSON.stringify(toRemote(p))}`);
63
+ const r = await sshExec(remote, `file --mime-type -b -- ${shellQuote(toRemote(p))}`);
55
64
  const m = r.toString().trim();
56
65
  return ["image/jpeg", "image/png", "image/gif", "image/webp"].includes(m) ? m : null;
57
66
  } catch {
@@ -61,21 +70,86 @@ function createRemoteReadOps(remote: string, remoteCwd: string, localCwd: string
61
70
  };
62
71
  }
63
72
 
73
+ async function writeRemoteContent(remote: string, path: string, content: string, exclusive: boolean): Promise<void> {
74
+ const noClobber = exclusive ? "set -o noclobber; " : "";
75
+ await sshExec(remote, `${noClobber}cat > ${shellQuote(path)}`, content);
76
+ }
77
+
64
78
  function createRemoteWriteOps(remote: string, remoteCwd: string, localCwd: string): WriteOperations {
65
79
  const toRemote = (p: string) => p.replace(localCwd, remoteCwd);
66
80
  return {
67
- writeFile: async (p, content) => {
68
- const b64 = Buffer.from(content).toString("base64");
69
- await sshExec(remote, `echo ${JSON.stringify(b64)} | base64 -d > ${JSON.stringify(toRemote(p))}`);
70
- },
71
- mkdir: (dir) => sshExec(remote, `mkdir -p ${JSON.stringify(toRemote(dir))}`).then(() => {}),
81
+ createFile: (p, content) => writeRemoteContent(remote, toRemote(p), content, true),
82
+ mkdir: (dir) => sshExec(remote, `mkdir -p -- ${shellQuote(toRemote(dir))}`).then(() => {}),
72
83
  };
73
84
  }
74
85
 
75
86
  function createRemoteEditOps(remote: string, remoteCwd: string, localCwd: string): EditOperations {
76
87
  const r = createRemoteReadOps(remote, remoteCwd, localCwd);
77
- const w = createRemoteWriteOps(remote, remoteCwd, localCwd);
78
- return { readFile: r.readFile, access: r.access, writeFile: w.writeFile };
88
+ const toRemote = (p: string) => p.replace(localCwd, remoteCwd);
89
+ return {
90
+ readFile: r.readFile,
91
+ writeFile: (p, content) => writeRemoteContent(remote, toRemote(p), content, false),
92
+ };
93
+ }
94
+
95
+ function createRemoteIntentOps(remote: string, remoteCwd: string, localCwd: string): FileMutationIntentOperations {
96
+ const toRemote = (p: string) => p.replace(localCwd, remoteCwd);
97
+ return {
98
+ async inspect(p, followSymlinks): Promise<FilePathInspection | undefined> {
99
+ const path = shellQuote(toRemote(p));
100
+ const kind = followSymlinks
101
+ ? `if [ -f ${path} ]; then printf 'file\\t'; elif [ -d ${path} ]; then printf 'directory\\t'; elif [ -e ${path} ]; then printf 'other\\t'; else exit 44; fi`
102
+ : `if [ -L ${path} ]; then printf 'other\\t'; elif [ -f ${path} ]; then printf 'file\\t'; elif [ -d ${path} ]; then printf 'directory\\t'; elif [ -e ${path} ]; then printf 'other\\t'; else exit 44; fi`;
103
+ const follow = followSymlinks ? "-L " : "";
104
+ try {
105
+ const output = (
106
+ await sshExec(
107
+ remote,
108
+ `${kind}; stat ${follow}--printf ${shellQuote("%d\\t%i\\t%f\\t%s\\t%y\\t%z")} -- ${path}`,
109
+ )
110
+ )
111
+ .toString("utf8")
112
+ .trimEnd();
113
+ const [entryKind, dev, ino, mode, size, mtimeMs, ctimeMs] = output.split("\t");
114
+ if (
115
+ (entryKind !== "file" && entryKind !== "directory" && entryKind !== "other") ||
116
+ !dev ||
117
+ !ino ||
118
+ !mode ||
119
+ !size ||
120
+ !mtimeMs ||
121
+ !ctimeMs
122
+ ) {
123
+ throw new Error(`Unexpected remote stat response for ${p}`);
124
+ }
125
+ return { kind: entryKind, identity: { dev, ino, mode, size, mtimeMs, ctimeMs } };
126
+ } catch (error) {
127
+ if (String(error).includes("SSH failed (44)")) return undefined;
128
+ throw error;
129
+ }
130
+ },
131
+ access(p, mode) {
132
+ const path = shellQuote(toRemote(p));
133
+ const checks = [
134
+ mode & constants.R_OK ? `test -r ${path}` : undefined,
135
+ mode & constants.W_OK ? `test -w ${path}` : undefined,
136
+ ].filter((check): check is string => check !== undefined);
137
+ return sshExec(remote, checks.length > 0 ? checks.join(" && ") : `test -e ${path}`).then(() => {});
138
+ },
139
+ copyFileExclusive(sourcePath, targetPath) {
140
+ return sshExec(
141
+ remote,
142
+ `set -o noclobber; cat -- ${shellQuote(toRemote(sourcePath))} > ${shellQuote(toRemote(targetPath))}`,
143
+ ).then(() => {});
144
+ },
145
+ async hashFile(p) {
146
+ const output = await sshExec(remote, `sha256sum -- ${shellQuote(toRemote(p))}`);
147
+ const digest = output.toString("utf8").trim().split(/\s+/, 1)[0];
148
+ if (!digest || !/^[0-9a-f]{64}$/i.test(digest)) throw new Error(`Invalid remote sha256 for ${p}`);
149
+ return digest.toLowerCase();
150
+ },
151
+ removeFile: (p) => sshExec(remote, `rm -- ${shellQuote(toRemote(p))}`).then(() => {}),
152
+ };
79
153
  }
80
154
 
81
155
  function createRemoteBashOps(remote: string, remoteCwd: string, localCwd: string): BashOperations {
@@ -83,7 +157,7 @@ function createRemoteBashOps(remote: string, remoteCwd: string, localCwd: string
83
157
  return {
84
158
  exec: (command, cwd, { onData, signal, timeout }) =>
85
159
  new Promise((resolve, reject) => {
86
- const cmd = `cd ${JSON.stringify(toRemote(cwd))} && ${command}`;
160
+ const cmd = `cd ${shellQuote(toRemote(cwd))} && ${command}`;
87
161
  const child = spawn("ssh", [remote, cmd], { stdio: ["ignore", "pipe", "pipe"] });
88
162
  let timedOut = false;
89
163
  const timer = timeout
@@ -115,15 +189,35 @@ export default function (pi: ExtensionAPI) {
115
189
  pi.registerFlag("ssh", { description: "SSH remote: user@host or user@host:/path", type: "string" });
116
190
 
117
191
  const localCwd = process.cwd();
192
+ const localFileMutationIntents = new FileMutationIntentController();
118
193
  const localRead = createReadTool(localCwd);
119
- const localWrite = createWriteTool(localCwd);
120
- const localEdit = createEditTool(localCwd);
194
+ const localWrite = createWriteTool(localCwd, { intentController: localFileMutationIntents });
195
+ const localEdit = createEditTool(localCwd, { intentController: localFileMutationIntents });
121
196
  const localBash = createBashTool(localCwd);
122
197
 
123
198
  // Resolved lazily on session_start (CLI flags not available during factory)
124
199
  let resolvedSsh: { remote: string; remoteCwd: string } | null = null;
200
+ let remoteFileTools: { write: ReturnType<typeof createWriteTool>; edit: ReturnType<typeof createEditTool> } | null =
201
+ null;
125
202
 
126
203
  const getSsh = () => resolvedSsh;
204
+ const getRemoteFileTools = (ssh: { remote: string; remoteCwd: string }) => {
205
+ if (remoteFileTools) return remoteFileTools;
206
+ const intentController = new FileMutationIntentController({
207
+ operations: createRemoteIntentOps(ssh.remote, ssh.remoteCwd, localCwd),
208
+ });
209
+ remoteFileTools = {
210
+ write: createWriteTool(localCwd, {
211
+ operations: createRemoteWriteOps(ssh.remote, ssh.remoteCwd, localCwd),
212
+ intentController,
213
+ }),
214
+ edit: createEditTool(localCwd, {
215
+ operations: createRemoteEditOps(ssh.remote, ssh.remoteCwd, localCwd),
216
+ intentController,
217
+ }),
218
+ };
219
+ return remoteFileTools;
220
+ };
127
221
 
128
222
  pi.registerTool({
129
223
  ...localRead,
@@ -144,10 +238,7 @@ export default function (pi: ExtensionAPI) {
144
238
  async execute(id, params, signal, onUpdate, _ctx) {
145
239
  const ssh = getSsh();
146
240
  if (ssh) {
147
- const tool = createWriteTool(localCwd, {
148
- operations: createRemoteWriteOps(ssh.remote, ssh.remoteCwd, localCwd),
149
- });
150
- return tool.execute(id, params, signal, onUpdate);
241
+ return getRemoteFileTools(ssh).write.execute(id, params, signal, onUpdate);
151
242
  }
152
243
  return localWrite.execute(id, params, signal, onUpdate);
153
244
  },
@@ -158,10 +249,7 @@ export default function (pi: ExtensionAPI) {
158
249
  async execute(id, params, signal, onUpdate, _ctx) {
159
250
  const ssh = getSsh();
160
251
  if (ssh) {
161
- const tool = createEditTool(localCwd, {
162
- operations: createRemoteEditOps(ssh.remote, ssh.remoteCwd, localCwd),
163
- });
164
- return tool.execute(id, params, signal, onUpdate);
252
+ return getRemoteFileTools(ssh).edit.execute(id, params, signal, onUpdate);
165
253
  }
166
254
  return localEdit.execute(id, params, signal, onUpdate);
167
255
  },
@@ -185,6 +273,7 @@ export default function (pi: ExtensionAPI) {
185
273
  // Resolve SSH config now that CLI flags are available
186
274
  const arg = pi.getFlag("ssh") as string | undefined;
187
275
  if (arg) {
276
+ remoteFileTools = null;
188
277
  if (arg.includes(":")) {
189
278
  const [remote, path] = arg.split(":");
190
279
  resolvedSsh = { remote, remoteCwd: path };
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pi-extension-with-deps",
3
- "version": "0.81.43",
3
+ "version": "0.85.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pi-extension-with-deps",
9
- "version": "0.81.43",
9
+ "version": "0.85.0",
10
10
  "dependencies": {
11
11
  "ms": "^2.1.3"
12
12
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-extension-with-deps",
3
3
  "private": true,
4
- "version": "0.81.43",
4
+ "version": "0.85.0",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "clean": "echo 'nothing to clean'",