@ai-agent-forge/plugin-coding-tools 0.88.14 → 0.88.15

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 (47) hide show
  1. package/agent-forge.json +1 -1
  2. package/dist/atomic-ops.d.ts +35 -0
  3. package/dist/atomic-ops.d.ts.map +1 -0
  4. package/dist/atomic-ops.js +62 -0
  5. package/dist/atomic-ops.js.map +1 -0
  6. package/dist/capability.d.ts +2 -0
  7. package/dist/capability.d.ts.map +1 -1
  8. package/dist/capability.js +47 -4
  9. package/dist/capability.js.map +1 -1
  10. package/dist/edit.d.ts +25 -0
  11. package/dist/edit.d.ts.map +1 -1
  12. package/dist/edit.js +162 -35
  13. package/dist/edit.js.map +1 -1
  14. package/dist/find.d.ts.map +1 -1
  15. package/dist/find.js +25 -1
  16. package/dist/find.js.map +1 -1
  17. package/dist/grep.d.ts.map +1 -1
  18. package/dist/grep.js +3 -1
  19. package/dist/grep.js.map +1 -1
  20. package/dist/index.d.ts +1 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js.map +1 -1
  23. package/dist/lease-check.d.ts +48 -0
  24. package/dist/lease-check.d.ts.map +1 -0
  25. package/dist/lease-check.js +77 -0
  26. package/dist/lease-check.js.map +1 -0
  27. package/dist/read.d.ts.map +1 -1
  28. package/dist/read.js +48 -8
  29. package/dist/read.js.map +1 -1
  30. package/dist/snapshot-ops.d.ts +31 -0
  31. package/dist/snapshot-ops.d.ts.map +1 -0
  32. package/dist/snapshot-ops.js +67 -0
  33. package/dist/snapshot-ops.js.map +1 -0
  34. package/dist/version-registry.d.ts +23 -0
  35. package/dist/version-registry.d.ts.map +1 -0
  36. package/dist/version-registry.js +54 -0
  37. package/dist/version-registry.js.map +1 -0
  38. package/dist/write-scope.d.ts +12 -0
  39. package/dist/write-scope.d.ts.map +1 -0
  40. package/dist/write-scope.js +46 -0
  41. package/dist/write-scope.js.map +1 -0
  42. package/dist/write.d.ts +27 -2
  43. package/dist/write.d.ts.map +1 -1
  44. package/dist/write.js +193 -13
  45. package/dist/write.js.map +1 -1
  46. package/package.json +3 -4
  47. package/plugin.json +8 -1
@@ -0,0 +1 @@
1
+ {"version":3,"file":"version-registry.js","sourceRoot":"","sources":["../src/version-registry.ts"],"names":[],"mappings":"AAAA,8DAA8D;AAC9D,EAAE;AACF,0EAA0E;AAC1E,4EAA4E;AAC5E,2EAA2E;AAC3E,+EAA8D;AAe9D,MAAM,uBAAuB,GAAG,IAAI,CAAC;AAErC,qFAAqF;AACrF,MAAM,UAAU,GAAG,IAAI,GAAG,EAA0C,CAAC;AAErE,SAAS,eAAe,CAAC,SAAiB,EAAkC;IAC3E,IAAI,QAAQ,GAAG,UAAU,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;IACzC,IAAI,QAAQ,KAAK,SAAS,EAAE,CAAC;QAC5B,QAAQ,GAAG,IAAI,GAAG,EAAE,CAAC;QACrB,UAAU,CAAC,GAAG,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAC;IACrC,CAAC;IACD,OAAO,QAAQ,CAAC;AAAA,CAChB;AAED,SAAS,KAAK,CAAC,QAAwC,EAAE,GAAW,EAAE,KAAwB,EAAQ;IACrG,QAAQ,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;IACrB,QAAQ,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;IACzB,OAAO,QAAQ,CAAC,IAAI,GAAG,uBAAuB,EAAE,CAAC;QAChD,MAAM,MAAM,GAAG,QAAQ,CAAC,IAAI,EAAE,CAAC,IAAI,EAAE,CAAC;QACtC,IAAI,MAAM,CAAC,IAAI,KAAK,IAAI;YAAE,MAAM;QAChC,QAAQ,CAAC,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IAC/B,CAAC;AAAA,CACD;AAED,sEAAsE;AACtE,MAAM,UAAU,aAAa,CAAC,SAAiB,EAAE,aAAqB,EAAE,OAA0B,EAAQ;IACzG,KAAK,CAAC,eAAe,CAAC,SAAS,CAAC,EAAE,aAAa,EAAE,OAAO,CAAC,CAAC;AAAA,CAC1D;AAED,wEAAwE;AACxE,MAAM,UAAU,kBAAkB,CAAC,SAAiB,EAAE,aAAqB,EAAiC;IAC3G,MAAM,QAAQ,GAAG,UAAU,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;IAC3C,IAAI,QAAQ,KAAK,SAAS;QAAE,OAAO,SAAS,CAAC;IAC7C,MAAM,KAAK,GAAG,QAAQ,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC;IAC1C,IAAI,KAAK,KAAK,SAAS;QAAE,KAAK,CAAC,QAAQ,EAAE,aAAa,EAAE,KAAK,CAAC,CAAC;IAC/D,OAAO,KAAK,CAAC;AAAA,CACb;AAED,kFAAkF;AAClF,MAAM,UAAU,aAAa,CAAC,SAAiB,EAAE,aAAqB,EAAQ;IAC7E,UAAU,CAAC,GAAG,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC,aAAa,CAAC,CAAC;AAAA,CACjD;AAED,qEAAqE;AACrE,MAAM,UAAU,oBAAoB,CAAC,SAAiB,EAAU;IAC/D,OAAO,UAAU,CAAC,GAAG,CAAC,SAAS,CAAC,EAAE,IAAI,IAAI,CAAC,CAAC;AAAA,CAC5C;AAED,mDAAmD;AACnD,MAAM,UAAU,oBAAoB,CAAC,SAAiB,EAAQ;IAC7D,UAAU,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;AAAA,CAC7B","sourcesContent":["// Session-scoped version registry for the default file tools.\n//\n// Records what a session last observed for a path so `write` can refuse a\n// blind overwrite and `edit` can skip a second hash when stat is unchanged.\n// Entries never leave this process: a resumed session starts empty and the\n// first overwrite asks for a read (design: 文件写入协调设计.md §6.2).\n\nexport interface RecordedVersionV1 {\n\t/** sha256 of the raw bytes that were observed. */\n\treadonly hash: string;\n\t/** Version facts from the same fd as `hash`; absent for custom operations without fd snapshots. */\n\treadonly stat?: {\n\t\treadonly ino: string;\n\t\treadonly size: string;\n\t\treadonly mtimeNs: string;\n\t\treadonly ctimeNs: string;\n\t\treadonly mode: number;\n\t};\n}\n\nconst MAX_ENTRIES_PER_SESSION = 1024;\n\n/** Insertion order doubles as LRU order (Map preserves it; delete+set refreshes). */\nconst registries = new Map<string, Map<string, RecordedVersionV1>>();\n\nfunction sessionRegistry(sessionId: string): Map<string, RecordedVersionV1> {\n\tlet registry = registries.get(sessionId);\n\tif (registry === undefined) {\n\t\tregistry = new Map();\n\t\tregistries.set(sessionId, registry);\n\t}\n\treturn registry;\n}\n\nfunction touch(registry: Map<string, RecordedVersionV1>, key: string, value: RecordedVersionV1): void {\n\tregistry.delete(key);\n\tregistry.set(key, value);\n\twhile (registry.size > MAX_ENTRIES_PER_SESSION) {\n\t\tconst oldest = registry.keys().next();\n\t\tif (oldest.done === true) break;\n\t\tregistry.delete(oldest.value);\n\t}\n}\n\n/** Remember the version a `read` (or a successful write) observed. */\nexport function recordVersion(sessionId: string, canonicalPath: string, version: RecordedVersionV1): void {\n\ttouch(sessionRegistry(sessionId), canonicalPath, version);\n}\n\n/** What this session last observed for `canonicalPath`, if anything. */\nexport function getRecordedVersion(sessionId: string, canonicalPath: string): RecordedVersionV1 | undefined {\n\tconst registry = registries.get(sessionId);\n\tif (registry === undefined) return undefined;\n\tconst value = registry.get(canonicalPath);\n\tif (value !== undefined) touch(registry, canonicalPath, value);\n\treturn value;\n}\n\n/** Drop the entry after the file disappeared, so the next write may create it. */\nexport function forgetVersion(sessionId: string, canonicalPath: string): void {\n\tregistries.get(sessionId)?.delete(canonicalPath);\n}\n\n/** Test/diagnostic helper: number of tracked paths for a session. */\nexport function recordedVersionCount(sessionId: string): number {\n\treturn registries.get(sessionId)?.size ?? 0;\n}\n\n/** Drop a session's registry (session dispose). */\nexport function clearSessionVersions(sessionId: string): void {\n\tregistries.delete(sessionId);\n}\n"]}
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Child write-scope enforcement for the default file tools (design §7.2).
3
+ *
4
+ * Scope patterns are relative to the session cwd and matched against the
5
+ * canonical target path (`resolveSubmitTarget`), so a symlink cannot escape by
6
+ * pointing outside the declared tree. This is a collaboration policy, not a
7
+ * sandbox: `bash` is outside this check by design.
8
+ */
9
+ /** True when the canonical target is inside `cwd` and matches one scope pattern. */
10
+ export declare function isWithinWriteScope(targetPath: string, scopeCwd: string, patterns: readonly string[]): boolean;
11
+ export declare function assertWithinWriteScope(targetPath: string, scopeCwd: string, patterns: readonly string[] | undefined): void;
12
+ //# sourceMappingURL=write-scope.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"write-scope.d.ts","sourceRoot":"","sources":["../src/write-scope.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AA6BH,oFAAoF;AACpF,wBAAgB,kBAAkB,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAM7G;AAED,wBAAgB,sBAAsB,CACrC,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,SAAS,MAAM,EAAE,GAAG,SAAS,GACrC,IAAI,CAGN","sourcesContent":["/**\n * Child write-scope enforcement for the default file tools (design §7.2).\n *\n * Scope patterns are relative to the session cwd and matched against the\n * canonical target path (`resolveSubmitTarget`), so a symlink cannot escape by\n * pointing outside the declared tree. This is a collaboration policy, not a\n * sandbox: `bash` is outside this check by design.\n */\n\nimport { realpathSync } from \"node:fs\";\nimport { isAbsolute, relative, sep } from \"node:path\";\nimport { FileCoordinationError } from \"@agent-forge/host/file-lock\";\nimport { matchesAnyPathGlob } from \"@agent-forge/host/glob\";\n\nfunction denied(targetPath: string, patterns: readonly string[]): FileCoordinationError {\n\treturn new FileCoordinationError(\n\t\t\"write_scope_denied\",\n\t\t`${targetPath} is outside this subagent's write scope. Allowed patterns: ${patterns.length === 0 ? \"(none)\" : patterns.join(\", \")}.`,\n\t\t{ path: targetPath, writeScope: [...patterns] },\n\t);\n}\n\n/**\n * Scope root must live in the same path space as the canonical target: on\n * macOS the session cwd may arrive through `/var` while\n * `resolveSubmitTarget` canonicalizes to `/private/var`, which would make\n * every relative path escape. The target is already real; bring the root over.\n */\nfunction scopeRoot(scopeCwd: string): string {\n\ttry {\n\t\treturn realpathSync(scopeCwd);\n\t} catch {\n\t\treturn scopeCwd;\n\t}\n}\n\n/** True when the canonical target is inside `cwd` and matches one scope pattern. */\nexport function isWithinWriteScope(targetPath: string, scopeCwd: string, patterns: readonly string[]): boolean {\n\tconst relativePath = relative(scopeRoot(scopeCwd), targetPath);\n\tif (relativePath === \"\" || isAbsolute(relativePath)) return false;\n\tconst normalized = relativePath.split(sep).join(\"/\");\n\tif (normalized === \"..\" || normalized.startsWith(\"../\")) return false;\n\treturn matchesAnyPathGlob(patterns, normalized);\n}\n\nexport function assertWithinWriteScope(\n\ttargetPath: string,\n\tscopeCwd: string,\n\tpatterns: readonly string[] | undefined,\n): void {\n\tif (patterns === undefined) return;\n\tif (!isWithinWriteScope(targetPath, scopeCwd, patterns)) throw denied(targetPath, patterns);\n}\n"]}
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Child write-scope enforcement for the default file tools (design §7.2).
3
+ *
4
+ * Scope patterns are relative to the session cwd and matched against the
5
+ * canonical target path (`resolveSubmitTarget`), so a symlink cannot escape by
6
+ * pointing outside the declared tree. This is a collaboration policy, not a
7
+ * sandbox: `bash` is outside this check by design.
8
+ */
9
+ import { realpathSync } from "node:fs";
10
+ import { isAbsolute, relative, sep } from "node:path";
11
+ import { FileCoordinationError } from "@agent-forge/host/file-lock";
12
+ import { matchesAnyPathGlob } from "@agent-forge/host/glob";
13
+ function denied(targetPath, patterns) {
14
+ return new FileCoordinationError("write_scope_denied", `${targetPath} is outside this subagent's write scope. Allowed patterns: ${patterns.length === 0 ? "(none)" : patterns.join(", ")}.`, { path: targetPath, writeScope: [...patterns] });
15
+ }
16
+ /**
17
+ * Scope root must live in the same path space as the canonical target: on
18
+ * macOS the session cwd may arrive through `/var` while
19
+ * `resolveSubmitTarget` canonicalizes to `/private/var`, which would make
20
+ * every relative path escape. The target is already real; bring the root over.
21
+ */
22
+ function scopeRoot(scopeCwd) {
23
+ try {
24
+ return realpathSync(scopeCwd);
25
+ }
26
+ catch {
27
+ return scopeCwd;
28
+ }
29
+ }
30
+ /** True when the canonical target is inside `cwd` and matches one scope pattern. */
31
+ export function isWithinWriteScope(targetPath, scopeCwd, patterns) {
32
+ const relativePath = relative(scopeRoot(scopeCwd), targetPath);
33
+ if (relativePath === "" || isAbsolute(relativePath))
34
+ return false;
35
+ const normalized = relativePath.split(sep).join("/");
36
+ if (normalized === ".." || normalized.startsWith("../"))
37
+ return false;
38
+ return matchesAnyPathGlob(patterns, normalized);
39
+ }
40
+ export function assertWithinWriteScope(targetPath, scopeCwd, patterns) {
41
+ if (patterns === undefined)
42
+ return;
43
+ if (!isWithinWriteScope(targetPath, scopeCwd, patterns))
44
+ throw denied(targetPath, patterns);
45
+ }
46
+ //# sourceMappingURL=write-scope.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"write-scope.js","sourceRoot":"","sources":["../src/write-scope.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AACvC,OAAO,EAAE,UAAU,EAAE,QAAQ,EAAE,GAAG,EAAE,MAAM,WAAW,CAAC;AACtD,OAAO,EAAE,qBAAqB,EAAE,MAAM,6BAA6B,CAAC;AACpE,OAAO,EAAE,kBAAkB,EAAE,MAAM,wBAAwB,CAAC;AAE5D,SAAS,MAAM,CAAC,UAAkB,EAAE,QAA2B,EAAyB;IACvF,OAAO,IAAI,qBAAqB,CAC/B,oBAAoB,EACpB,GAAG,UAAU,8DAA8D,QAAQ,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,EACpI,EAAE,IAAI,EAAE,UAAU,EAAE,UAAU,EAAE,CAAC,GAAG,QAAQ,CAAC,EAAE,CAC/C,CAAC;AAAA,CACF;AAED;;;;;GAKG;AACH,SAAS,SAAS,CAAC,QAAgB,EAAU;IAC5C,IAAI,CAAC;QACJ,OAAO,YAAY,CAAC,QAAQ,CAAC,CAAC;IAC/B,CAAC;IAAC,MAAM,CAAC;QACR,OAAO,QAAQ,CAAC;IACjB,CAAC;AAAA,CACD;AAED,oFAAoF;AACpF,MAAM,UAAU,kBAAkB,CAAC,UAAkB,EAAE,QAAgB,EAAE,QAA2B,EAAW;IAC9G,MAAM,YAAY,GAAG,QAAQ,CAAC,SAAS,CAAC,QAAQ,CAAC,EAAE,UAAU,CAAC,CAAC;IAC/D,IAAI,YAAY,KAAK,EAAE,IAAI,UAAU,CAAC,YAAY,CAAC;QAAE,OAAO,KAAK,CAAC;IAClE,MAAM,UAAU,GAAG,YAAY,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IACrD,IAAI,UAAU,KAAK,IAAI,IAAI,UAAU,CAAC,UAAU,CAAC,KAAK,CAAC;QAAE,OAAO,KAAK,CAAC;IACtE,OAAO,kBAAkB,CAAC,QAAQ,EAAE,UAAU,CAAC,CAAC;AAAA,CAChD;AAED,MAAM,UAAU,sBAAsB,CACrC,UAAkB,EAClB,QAAgB,EAChB,QAAuC,EAChC;IACP,IAAI,QAAQ,KAAK,SAAS;QAAE,OAAO;IACnC,IAAI,CAAC,kBAAkB,CAAC,UAAU,EAAE,QAAQ,EAAE,QAAQ,CAAC;QAAE,MAAM,MAAM,CAAC,UAAU,EAAE,QAAQ,CAAC,CAAC;AAAA,CAC5F","sourcesContent":["/**\n * Child write-scope enforcement for the default file tools (design §7.2).\n *\n * Scope patterns are relative to the session cwd and matched against the\n * canonical target path (`resolveSubmitTarget`), so a symlink cannot escape by\n * pointing outside the declared tree. This is a collaboration policy, not a\n * sandbox: `bash` is outside this check by design.\n */\n\nimport { realpathSync } from \"node:fs\";\nimport { isAbsolute, relative, sep } from \"node:path\";\nimport { FileCoordinationError } from \"@agent-forge/host/file-lock\";\nimport { matchesAnyPathGlob } from \"@agent-forge/host/glob\";\n\nfunction denied(targetPath: string, patterns: readonly string[]): FileCoordinationError {\n\treturn new FileCoordinationError(\n\t\t\"write_scope_denied\",\n\t\t`${targetPath} is outside this subagent's write scope. Allowed patterns: ${patterns.length === 0 ? \"(none)\" : patterns.join(\", \")}.`,\n\t\t{ path: targetPath, writeScope: [...patterns] },\n\t);\n}\n\n/**\n * Scope root must live in the same path space as the canonical target: on\n * macOS the session cwd may arrive through `/var` while\n * `resolveSubmitTarget` canonicalizes to `/private/var`, which would make\n * every relative path escape. The target is already real; bring the root over.\n */\nfunction scopeRoot(scopeCwd: string): string {\n\ttry {\n\t\treturn realpathSync(scopeCwd);\n\t} catch {\n\t\treturn scopeCwd;\n\t}\n}\n\n/** True when the canonical target is inside `cwd` and matches one scope pattern. */\nexport function isWithinWriteScope(targetPath: string, scopeCwd: string, patterns: readonly string[]): boolean {\n\tconst relativePath = relative(scopeRoot(scopeCwd), targetPath);\n\tif (relativePath === \"\" || isAbsolute(relativePath)) return false;\n\tconst normalized = relativePath.split(sep).join(\"/\");\n\tif (normalized === \"..\" || normalized.startsWith(\"../\")) return false;\n\treturn matchesAnyPathGlob(patterns, normalized);\n}\n\nexport function assertWithinWriteScope(\n\ttargetPath: string,\n\tscopeCwd: string,\n\tpatterns: readonly string[] | undefined,\n): void {\n\tif (patterns === undefined) return;\n\tif (!isWithinWriteScope(targetPath, scopeCwd, patterns)) throw denied(targetPath, patterns);\n}\n"]}
package/dist/write.d.ts CHANGED
@@ -1,30 +1,55 @@
1
+ import { type StatVersionV1 } from "@agent-forge/host/file-lock";
1
2
  import type { ToolDefinition } from "@agent-forge/host/tool-definition";
2
3
  import { type Static, Type } from "typebox";
4
+ import { type CoordinationLeaseCheckV1 } from "./lease-check.ts";
3
5
  import { type AgentTool } from "./types.ts";
4
6
  declare const writeSchema: Type.TObject<{
5
7
  path: Type.TString;
6
8
  content: Type.TString;
9
+ expectedVersion: Type.TOptional<Type.TString>;
7
10
  }>;
8
11
  export declare const writeToolSystemPromptContribution: {
9
12
  readonly snippet: "Create or overwrite files";
10
- readonly guidelines: readonly ["Use write only for new files or complete rewrites."];
13
+ readonly guidelines: readonly ["Use write only for new files or complete rewrites.", "Reading the file first is required before overwriting an existing file; without a prior read the write is rejected."];
11
14
  };
12
15
  export type WriteToolInput = Static<typeof writeSchema>;
16
+ export interface WriteToolDetails {
17
+ /** Lines added by the write (empty text is 0 lines; "a" and "a\n" are both 1 line). */
18
+ addedLines: number;
19
+ /** Lines removed from the previous content. Omitted when the previous content could not be read. */
20
+ deletedLines?: number;
21
+ /** Set when the file did not exist before the write. */
22
+ created?: true;
23
+ /** Present only when the operations could not commit atomically (design §13.2 tier B). */
24
+ coordination?: {
25
+ atomic: false;
26
+ };
27
+ }
13
28
  /**
14
29
  * Pluggable operations for the write tool.
15
30
  * Override these to delegate file writing to remote systems (for example SSH).
16
31
  */
17
32
  export interface WriteOperations {
33
+ /** Read the current file contents (used to compute line-count details). */
34
+ readFile?: (absolutePath: string) => Promise<Buffer>;
18
35
  /** Write content to a file */
19
36
  writeFile: (absolutePath: string, content: string) => Promise<void>;
20
37
  /** Create directory recursively */
21
38
  mkdir: (dir: string) => Promise<void>;
39
+ /** Atomic-commit tier (design §13.2): all three required; see EditOperations. */
40
+ statVersion?: (absolutePath: string) => Promise<StatVersionV1 | undefined>;
41
+ writeTemp?: (absolutePath: string, content: string) => Promise<string>;
42
+ commitSync?: (tempPath: string, targetPath: string) => void;
22
43
  }
23
44
  export interface WriteToolOptions {
24
45
  /** Custom operations for file writing. Default: local filesystem */
25
46
  operations?: WriteOperations;
47
+ /** Relative write-path globs from the child task profile; absent = unrestricted. */
48
+ writeScope?: readonly string[];
49
+ /** `file.coordination@1` consumer face; absent = no advisory lease checks. */
50
+ coordination?: CoordinationLeaseCheckV1;
26
51
  }
27
- export declare function createWriteToolDefinition(cwd: string, options?: WriteToolOptions): ToolDefinition<typeof writeSchema, undefined>;
52
+ export declare function createWriteToolDefinition(cwd: string, options?: WriteToolOptions): ToolDefinition<typeof writeSchema, WriteToolDetails | undefined>;
28
53
  export declare function createWriteTool(cwd: string, options?: WriteToolOptions): AgentTool<typeof writeSchema>;
29
54
  export {};
30
55
  //# sourceMappingURL=write.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"write.d.ts","sourceRoot":"","sources":["../src/write.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,cAAc,EAA+C,MAAM,mCAAmC,CAAC;AAErH,OAAO,EAAE,KAAK,MAAM,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AAC5C,OAAO,EAAE,KAAK,SAAS,EAAsB,MAAM,YAAY,CAAC;AAEhE,QAAA,MAAM,WAAW;;;EAGf,CAAC;AAEH,eAAO,MAAM,iCAAiC;;;CAGpC,CAAC;AAEX,MAAM,MAAM,cAAc,GAAG,MAAM,CAAC,OAAO,WAAW,CAAC,CAAC;AAExD;;;GAGG;AACH,MAAM,WAAW,eAAe;IAC/B,8BAA8B;IAC9B,SAAS,EAAE,CAAC,YAAY,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACpE,mCAAmC;IACnC,KAAK,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACtC;AAOD,MAAM,WAAW,gBAAgB;IAChC,oEAAoE;IACpE,UAAU,CAAC,EAAE,eAAe,CAAC;CAC7B;AA6ID,wBAAgB,yBAAyB,CACxC,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE,gBAAgB,GACxB,cAAc,CAAC,OAAO,WAAW,EAAE,SAAS,CAAC,CAgF/C;AAED,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,gBAAgB,GAAG,SAAS,CAAC,OAAO,WAAW,CAAC,CAEtG","sourcesContent":["// Migrated from packages/agent-forge/src/core/tools/write (batch 1, copy-then-delete);\n// host modules are imported through supply keys (@agent-forge/host/*).\n\nimport { mkdir as fsMkdir, writeFile as fsWriteFile } from \"node:fs/promises\";\nimport { dirname } from \"node:path\";\nimport { withFileMutationQueue } from \"@agent-forge/host/file-mutation-queue\";\nimport { t } from \"@agent-forge/host/i18n\";\nimport { keyHint } from \"@agent-forge/host/keybinding-hints\";\nimport { resolveToCwd } from \"@agent-forge/host/path-utils\";\nimport { normalizeDisplayText, renderToolPath, replaceTabs, str } from \"@agent-forge/host/render-utils\";\nimport { getLanguageFromPath, highlightCode, type Theme } from \"@agent-forge/host/theme\";\nimport type { ToolDefinition, ToolExecuteContext, ToolRenderResultOptions } from \"@agent-forge/host/tool-definition\";\nimport { Container, Text } from \"@agent-forge/tui\";\nimport { type Static, Type } from \"typebox\";\nimport { type AgentTool, wrapToolDefinition } from \"./types.ts\";\n\nconst writeSchema = Type.Object({\n\tpath: Type.String({ description: \"Path to the file to write (relative or absolute)\" }),\n\tcontent: Type.String({ description: \"Content to write to the file\" }),\n});\n\nexport const writeToolSystemPromptContribution = {\n\tsnippet: \"Create or overwrite files\",\n\tguidelines: [\"Use write only for new files or complete rewrites.\"],\n} as const;\n\nexport type WriteToolInput = Static<typeof writeSchema>;\n\n/**\n * Pluggable operations for the write tool.\n * Override these to delegate file writing to remote systems (for example SSH).\n */\nexport interface WriteOperations {\n\t/** Write content to a file */\n\twriteFile: (absolutePath: string, content: string) => Promise<void>;\n\t/** Create directory recursively */\n\tmkdir: (dir: string) => Promise<void>;\n}\n\nconst defaultWriteOperations: WriteOperations = {\n\twriteFile: (path, content) => fsWriteFile(path, content, \"utf-8\"),\n\tmkdir: (dir) => fsMkdir(dir, { recursive: true }).then(() => {}),\n};\n\nexport interface WriteToolOptions {\n\t/** Custom operations for file writing. Default: local filesystem */\n\toperations?: WriteOperations;\n}\n\ntype WriteHighlightCache = {\n\trawPath: string | null;\n\tlang: string;\n\trawContent: string;\n\tnormalizedLines: string[];\n\thighlightedLines: string[];\n};\n\nclass WriteCallRenderComponent extends Text {\n\tcache?: WriteHighlightCache;\n\n\tconstructor() {\n\t\tsuper(\"\", 0, 0);\n\t}\n}\n\nconst WRITE_PARTIAL_FULL_HIGHLIGHT_LINES = 50;\n\nfunction highlightSingleLine(line: string, lang: string): string {\n\tconst highlighted = highlightCode(line, lang);\n\treturn highlighted[0] ?? \"\";\n}\n\nfunction refreshWriteHighlightPrefix(cache: WriteHighlightCache): void {\n\tconst prefixCount = Math.min(WRITE_PARTIAL_FULL_HIGHLIGHT_LINES, cache.normalizedLines.length);\n\tif (prefixCount === 0) return;\n\tconst prefixSource = cache.normalizedLines.slice(0, prefixCount).join(\"\\n\");\n\tconst prefixHighlighted = highlightCode(prefixSource, cache.lang);\n\tfor (let i = 0; i < prefixCount; i++) {\n\t\tcache.highlightedLines[i] =\n\t\t\tprefixHighlighted[i] ?? highlightSingleLine(cache.normalizedLines[i] ?? \"\", cache.lang);\n\t}\n}\n\nfunction rebuildWriteHighlightCacheFull(rawPath: string | null, fileContent: string): WriteHighlightCache | undefined {\n\tconst lang = rawPath ? getLanguageFromPath(rawPath) : undefined;\n\tif (!lang) return undefined;\n\tconst displayContent = normalizeDisplayText(fileContent);\n\tconst normalized = replaceTabs(displayContent);\n\treturn {\n\t\trawPath,\n\t\tlang,\n\t\trawContent: fileContent,\n\t\tnormalizedLines: normalized.split(\"\\n\"),\n\t\thighlightedLines: highlightCode(normalized, lang),\n\t};\n}\n\nfunction updateWriteHighlightCacheIncremental(\n\tcache: WriteHighlightCache | undefined,\n\trawPath: string | null,\n\tfileContent: string,\n): WriteHighlightCache | undefined {\n\tconst lang = rawPath ? getLanguageFromPath(rawPath) : undefined;\n\tif (!lang) return undefined;\n\tif (!cache) return rebuildWriteHighlightCacheFull(rawPath, fileContent);\n\tif (cache.lang !== lang || cache.rawPath !== rawPath) return rebuildWriteHighlightCacheFull(rawPath, fileContent);\n\tif (!fileContent.startsWith(cache.rawContent)) return rebuildWriteHighlightCacheFull(rawPath, fileContent);\n\tif (fileContent.length === cache.rawContent.length) return cache;\n\n\tconst deltaRaw = fileContent.slice(cache.rawContent.length);\n\tconst deltaDisplay = normalizeDisplayText(deltaRaw);\n\tconst deltaNormalized = replaceTabs(deltaDisplay);\n\tcache.rawContent = fileContent;\n\tif (cache.normalizedLines.length === 0) {\n\t\tcache.normalizedLines.push(\"\");\n\t\tcache.highlightedLines.push(\"\");\n\t}\n\n\tconst segments = deltaNormalized.split(\"\\n\");\n\tconst lastIndex = cache.normalizedLines.length - 1;\n\tcache.normalizedLines[lastIndex] += segments[0];\n\tcache.highlightedLines[lastIndex] = highlightSingleLine(cache.normalizedLines[lastIndex], cache.lang);\n\tfor (let i = 1; i < segments.length; i++) {\n\t\tcache.normalizedLines.push(segments[i]);\n\t\tcache.highlightedLines.push(highlightSingleLine(segments[i], cache.lang));\n\t}\n\trefreshWriteHighlightPrefix(cache);\n\treturn cache;\n}\n\nfunction trimTrailingEmptyLines(lines: string[]): string[] {\n\tlet end = lines.length;\n\twhile (end > 0 && lines[end - 1] === \"\") {\n\t\tend--;\n\t}\n\treturn lines.slice(0, end);\n}\n\nfunction formatWriteCall(\n\targs: { path?: string; file_path?: string; content?: string } | undefined,\n\toptions: ToolRenderResultOptions,\n\ttheme: Theme,\n\tcache: WriteHighlightCache | undefined,\n\tcwd: string,\n): string {\n\tconst rawPath = str(args?.file_path ?? args?.path);\n\tconst fileContent = str(args?.content);\n\tconst pathDisplay = renderToolPath(rawPath, theme, cwd);\n\tlet text = `${theme.fg(\"toolTitle\", theme.bold(\"write\"))} ${pathDisplay}`;\n\n\tif (fileContent === null) {\n\t\ttext += `\\n\\n${theme.fg(\"error\", \"[invalid content arg - expected string]\")}`;\n\t} else if (fileContent) {\n\t\tconst lang = rawPath ? getLanguageFromPath(rawPath) : undefined;\n\t\tconst renderedLines = lang\n\t\t\t? (cache?.highlightedLines ?? highlightCode(replaceTabs(normalizeDisplayText(fileContent)), lang))\n\t\t\t: normalizeDisplayText(fileContent).split(\"\\n\");\n\t\tconst lines = trimTrailingEmptyLines(renderedLines);\n\t\tconst totalLines = lines.length;\n\t\tconst maxLines = options.expanded ? lines.length : 10;\n\t\tconst displayLines = lines.slice(0, maxLines);\n\t\tconst remaining = lines.length - maxLines;\n\t\ttext += `\\n\\n${displayLines.map((line) => (lang ? line : theme.fg(\"toolOutput\", replaceTabs(line)))).join(\"\\n\")}`;\n\t\tif (remaining > 0) {\n\t\t\ttext += `${theme.fg(\"muted\", `\\n... (${remaining} more lines, ${totalLines} total,`)} ${keyHint(\"app.tools.expand\", \"to expand\")}${theme.fg(\"muted\", \")\")}`;\n\t\t}\n\t}\n\n\treturn text;\n}\n\nfunction formatWriteResult(\n\tresult: { content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; isError?: boolean },\n\ttheme: Theme,\n): string | undefined {\n\tif (!result.isError) {\n\t\treturn undefined;\n\t}\n\tconst output = result.content\n\t\t.filter((c) => c.type === \"text\")\n\t\t.map((c) => c.text || \"\")\n\t\t.join(\"\\n\");\n\tif (!output) {\n\t\treturn undefined;\n\t}\n\treturn `\\n${theme.fg(\"error\", output)}`;\n}\n\nexport function createWriteToolDefinition(\n\tcwd: string,\n\toptions?: WriteToolOptions,\n): ToolDefinition<typeof writeSchema, undefined> {\n\tconst ops = options?.operations ?? defaultWriteOperations;\n\treturn {\n\t\tname: \"write\",\n\t\tlabel: \"write\",\n\t\tdescription:\n\t\t\t\"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.\",\n\t\tpromptSnippet: writeToolSystemPromptContribution.snippet,\n\t\tpromptGuidelines: [...writeToolSystemPromptContribution.guidelines],\n\t\tparameters: writeSchema,\n\t\tconstrainedSampling: { type: \"json_schema\", strict: \"prefer\" },\n\t\tasync execute(\n\t\t\t_toolCallId,\n\t\t\t{ path, content }: { path: string; content: string },\n\t\t\tsignal?: AbortSignal,\n\t\t\t_onUpdate?,\n\t\t\tctx?: ToolExecuteContext,\n\t\t) {\n\t\t\tconst absolutePath = resolveToCwd(path, ctx?.cwd || cwd);\n\t\t\tconst dir = dirname(absolutePath);\n\t\t\treturn withFileMutationQueue(absolutePath, async () => {\n\t\t\t\t// Do not reject from an abort event listener here: that would release the\n\t\t\t\t// mutation queue while an in-flight filesystem operation may still finish.\n\t\t\t\t// Checking signal.aborted after each await observes the same aborts while\n\t\t\t\t// keeping the queue locked until the current operation has settled.\n\t\t\t\tconst throwIfAborted = (): void => {\n\t\t\t\t\tif (signal?.aborted) throw new Error(t(\"Operation aborted\"));\n\t\t\t\t};\n\n\t\t\t\tthrowIfAborted();\n\t\t\t\t// Create parent directories if needed.\n\t\t\t\tawait ops.mkdir(dir);\n\t\t\t\tthrowIfAborted();\n\n\t\t\t\t// Write the file contents.\n\t\t\t\tawait ops.writeFile(absolutePath, content);\n\t\t\t\tthrowIfAborted();\n\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [{ type: \"text\", text: `Successfully wrote to ${path}` }],\n\t\t\t\t\tdetails: undefined,\n\t\t\t\t};\n\t\t\t});\n\t\t},\n\t\trenderCall(args, theme, context) {\n\t\t\tconst renderArgs = args as { path?: string; file_path?: string; content?: string } | undefined;\n\t\t\tconst rawPath = str(renderArgs?.file_path ?? renderArgs?.path);\n\t\t\tconst fileContent = str(renderArgs?.content);\n\t\t\tconst component =\n\t\t\t\t(context.lastComponent as WriteCallRenderComponent | undefined) ?? new WriteCallRenderComponent();\n\t\t\tif (fileContent !== null) {\n\t\t\t\tcomponent.cache = context.argsComplete\n\t\t\t\t\t? rebuildWriteHighlightCacheFull(rawPath, fileContent)\n\t\t\t\t\t: updateWriteHighlightCacheIncremental(component.cache, rawPath, fileContent);\n\t\t\t} else {\n\t\t\t\tcomponent.cache = undefined;\n\t\t\t}\n\t\t\tcomponent.setText(\n\t\t\t\tformatWriteCall(\n\t\t\t\t\trenderArgs,\n\t\t\t\t\t{ expanded: context.expanded, isPartial: context.isPartial },\n\t\t\t\t\ttheme,\n\t\t\t\t\tcomponent.cache,\n\t\t\t\t\tcontext.cwd,\n\t\t\t\t),\n\t\t\t);\n\t\t\treturn component;\n\t\t},\n\t\trenderResult(result, _options, theme, context) {\n\t\t\tconst output = formatWriteResult({ ...result, isError: context.isError }, theme);\n\t\t\tif (!output) {\n\t\t\t\tconst component = (context.lastComponent as Container | undefined) ?? new Container();\n\t\t\t\tcomponent.clear();\n\t\t\t\treturn component;\n\t\t\t}\n\t\t\tconst text = (context.lastComponent as Text | undefined) ?? new Text(\"\", 0, 0);\n\t\t\ttext.setText(output);\n\t\t\treturn text;\n\t\t},\n\t};\n}\n\nexport function createWriteTool(cwd: string, options?: WriteToolOptions): AgentTool<typeof writeSchema> {\n\treturn wrapToolDefinition(createWriteToolDefinition(cwd, options));\n}\n"]}
1
+ {"version":3,"file":"write.d.ts","sourceRoot":"","sources":["../src/write.ts"],"names":[],"mappings":"AAaA,OAAO,EAEN,KAAK,aAAa,EAMlB,MAAM,6BAA6B,CAAC;AAQrC,OAAO,KAAK,EAAE,cAAc,EAA+C,MAAM,mCAAmC,CAAC;AAErH,OAAO,EAAE,KAAK,MAAM,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AAE5C,OAAO,EAAE,KAAK,wBAAwB,EAAoB,MAAM,kBAAkB,CAAC;AAEnF,OAAO,EAAE,KAAK,SAAS,EAAsB,MAAM,YAAY,CAAC;AAIhE,QAAA,MAAM,WAAW;;;;EASf,CAAC;AAEH,eAAO,MAAM,iCAAiC;;;CAMpC,CAAC;AAEX,MAAM,MAAM,cAAc,GAAG,MAAM,CAAC,OAAO,WAAW,CAAC,CAAC;AAExD,MAAM,WAAW,gBAAgB;IAChC,uFAAuF;IACvF,UAAU,EAAE,MAAM,CAAC;IACnB,oGAAoG;IACpG,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,wDAAwD;IACxD,OAAO,CAAC,EAAE,IAAI,CAAC;IACf,2FAA0F;IAC1F,YAAY,CAAC,EAAE;QAAE,MAAM,EAAE,KAAK,CAAA;KAAE,CAAC;CACjC;AAED;;;GAGG;AACH,MAAM,WAAW,eAAe;IAC/B,2EAA2E;IAC3E,QAAQ,CAAC,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IACrD,8BAA8B;IAC9B,SAAS,EAAE,CAAC,YAAY,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACpE,mCAAmC;IACnC,KAAK,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACtC,kFAAiF;IACjF,WAAW,CAAC,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,OAAO,CAAC,aAAa,GAAG,SAAS,CAAC,CAAC;IAC3E,SAAS,CAAC,EAAE,CAAC,YAAY,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IACvE,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,KAAK,IAAI,CAAC;CAC5D;AAeD,MAAM,WAAW,gBAAgB;IAChC,oEAAoE;IACpE,UAAU,CAAC,EAAE,eAAe,CAAC;IAC7B,oFAAoF;IACpF,UAAU,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC/B,8EAA8E;IAC9E,YAAY,CAAC,EAAE,wBAAwB,CAAC;CACxC;AA+JD,wBAAgB,yBAAyB,CACxC,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE,gBAAgB,GACxB,cAAc,CAAC,OAAO,WAAW,EAAE,gBAAgB,GAAG,SAAS,CAAC,CA2PlE;AAED,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,gBAAgB,GAAG,SAAS,CAAC,OAAO,WAAW,CAAC,CAEtG","sourcesContent":["// Migrated from packages/agent-forge/src/core/tools/write (batch 1, copy-then-delete);\n// host modules are imported through supply keys (@agent-forge/host/*).\n\nimport { randomUUID } from \"node:crypto\";\nimport { constants, renameSync } from \"node:fs\";\nimport {\n\taccess as fsAccess,\n\tmkdir as fsMkdir,\n\treadFile as fsReadFile,\n\twriteFile as fsWriteFile,\n} from \"node:fs/promises\";\nimport { dirname } from \"node:path\";\nimport { countDiffLines } from \"@agent-forge/host/edit-diff\";\nimport {\n\tFileCoordinationError,\n\ttype StatVersionV1,\n\tsha256Hex,\n\tstatLooksChanged,\n\tstatVersionOf,\n\tstatVersionSync,\n\twithFileMutationLock,\n} from \"@agent-forge/host/file-lock\";\nimport { withFileMutationQueue } from \"@agent-forge/host/file-mutation-queue\";\nimport { t } from \"@agent-forge/host/i18n\";\nimport { keyHint } from \"@agent-forge/host/keybinding-hints\";\nimport { resolveToCwd } from \"@agent-forge/host/path-utils\";\nimport { formatPathRelativeToCwdOrAbsolute } from \"@agent-forge/host/paths\";\nimport { normalizeDisplayText, renderToolPath, replaceTabs, str } from \"@agent-forge/host/render-utils\";\nimport { getLanguageFromPath, highlightCode, type Theme } from \"@agent-forge/host/theme\";\nimport type { ToolDefinition, ToolExecuteContext, ToolRenderResultOptions } from \"@agent-forge/host/tool-definition\";\nimport { Container, Text } from \"@agent-forge/tui\";\nimport { type Static, Type } from \"typebox\";\nimport { commitPreparedContent } from \"./atomic-ops.ts\";\nimport { type CoordinationLeaseCheckV1, checkIntentLease } from \"./lease-check.ts\";\nimport { hasAtomicTier, probeSnapshot, readSnapshot } from \"./snapshot-ops.ts\";\nimport { type AgentTool, wrapToolDefinition } from \"./types.ts\";\nimport { forgetVersion, getRecordedVersion, recordVersion } from \"./version-registry.ts\";\nimport { assertWithinWriteScope } from \"./write-scope.ts\";\n\nconst writeSchema = Type.Object({\n\tpath: Type.String({ description: \"Path to the file to write (relative or absolute)\" }),\n\tcontent: Type.String({ description: \"Content to write to the file\" }),\n\texpectedVersion: Type.Optional(\n\t\tType.String({\n\t\t\tdescription:\n\t\t\t\t\"Optional sha256 of the current content. When set, the write fails if the file changed. Programmatic callers use this instead of an in-session read.\",\n\t\t}),\n\t),\n});\n\nexport const writeToolSystemPromptContribution = {\n\tsnippet: \"Create or overwrite files\",\n\tguidelines: [\n\t\t\"Use write only for new files or complete rewrites.\",\n\t\t\"Reading the file first is required before overwriting an existing file; without a prior read the write is rejected.\",\n\t],\n} as const;\n\nexport type WriteToolInput = Static<typeof writeSchema>;\n\nexport interface WriteToolDetails {\n\t/** Lines added by the write (empty text is 0 lines; \"a\" and \"a\\n\" are both 1 line). */\n\taddedLines: number;\n\t/** Lines removed from the previous content. Omitted when the previous content could not be read. */\n\tdeletedLines?: number;\n\t/** Set when the file did not exist before the write. */\n\tcreated?: true;\n\t/** Present only when the operations could not commit atomically (design §13.2 tier B). */\n\tcoordination?: { atomic: false };\n}\n\n/**\n * Pluggable operations for the write tool.\n * Override these to delegate file writing to remote systems (for example SSH).\n */\nexport interface WriteOperations {\n\t/** Read the current file contents (used to compute line-count details). */\n\treadFile?: (absolutePath: string) => Promise<Buffer>;\n\t/** Write content to a file */\n\twriteFile: (absolutePath: string, content: string) => Promise<void>;\n\t/** Create directory recursively */\n\tmkdir: (dir: string) => Promise<void>;\n\t/** Atomic-commit tier (design §13.2): all three required; see EditOperations. */\n\tstatVersion?: (absolutePath: string) => Promise<StatVersionV1 | undefined>;\n\twriteTemp?: (absolutePath: string, content: string) => Promise<string>;\n\tcommitSync?: (tempPath: string, targetPath: string) => void;\n}\n\nconst defaultWriteOperations: WriteOperations = {\n\treadFile: (path) => fsReadFile(path),\n\twriteFile: (path, content) => fsWriteFile(path, content, \"utf-8\"),\n\tmkdir: (dir) => fsMkdir(dir, { recursive: true }).then(() => {}),\n\tstatVersion: (path) => statVersionOf(path),\n\twriteTemp: async (path, content) => {\n\t\tconst tempPath = `${path}.${randomUUID()}.agent-forge-tmp`;\n\t\tawait fsWriteFile(tempPath, content, { encoding: \"utf-8\", mode: 0o600 });\n\t\treturn tempPath;\n\t},\n\tcommitSync: (tempPath, targetPath) => renameSync(tempPath, targetPath),\n};\n\nexport interface WriteToolOptions {\n\t/** Custom operations for file writing. Default: local filesystem */\n\toperations?: WriteOperations;\n\t/** Relative write-path globs from the child task profile; absent = unrestricted. */\n\twriteScope?: readonly string[];\n\t/** `file.coordination@1` consumer face; absent = no advisory lease checks. */\n\tcoordination?: CoordinationLeaseCheckV1;\n}\n\nfunction versionConflict(path: string, currentVersion: string, expectedVersion: string): FileCoordinationError {\n\treturn new FileCoordinationError(\n\t\t\"file_version_conflict\",\n\t\t`${path} changed on disk since it was read. Re-read the file and retry.`,\n\t\t{ path, currentVersion, expectedVersion },\n\t);\n}\n\n/** Reject a read-only target so the atomic replace cannot bypass the permission bit. */\nasync function ensureWritable(target: string, displayPath: string): Promise<void> {\n\ttry {\n\t\tawait fsAccess(target, constants.W_OK);\n\t} catch (error: unknown) {\n\t\tconst errorMessage = error instanceof Error && \"code\" in error ? `Error code: ${error.code}` : String(error);\n\t\tthrow new Error(t(\"Could not write file: {path}. {errorMessage}.\", { path: displayPath, errorMessage }));\n\t}\n}\n\ntype WriteHighlightCache = {\n\trawPath: string | null;\n\tlang: string;\n\trawContent: string;\n\tnormalizedLines: string[];\n\thighlightedLines: string[];\n};\n\nclass WriteCallRenderComponent extends Text {\n\tcache?: WriteHighlightCache;\n\n\tconstructor() {\n\t\tsuper(\"\", 0, 0);\n\t}\n}\n\nconst WRITE_PARTIAL_FULL_HIGHLIGHT_LINES = 50;\n\nfunction highlightSingleLine(line: string, lang: string): string {\n\tconst highlighted = highlightCode(line, lang);\n\treturn highlighted[0] ?? \"\";\n}\n\nfunction refreshWriteHighlightPrefix(cache: WriteHighlightCache): void {\n\tconst prefixCount = Math.min(WRITE_PARTIAL_FULL_HIGHLIGHT_LINES, cache.normalizedLines.length);\n\tif (prefixCount === 0) return;\n\tconst prefixSource = cache.normalizedLines.slice(0, prefixCount).join(\"\\n\");\n\tconst prefixHighlighted = highlightCode(prefixSource, cache.lang);\n\tfor (let i = 0; i < prefixCount; i++) {\n\t\tcache.highlightedLines[i] =\n\t\t\tprefixHighlighted[i] ?? highlightSingleLine(cache.normalizedLines[i] ?? \"\", cache.lang);\n\t}\n}\n\nfunction rebuildWriteHighlightCacheFull(rawPath: string | null, fileContent: string): WriteHighlightCache | undefined {\n\tconst lang = rawPath ? getLanguageFromPath(rawPath) : undefined;\n\tif (!lang) return undefined;\n\tconst displayContent = normalizeDisplayText(fileContent);\n\tconst normalized = replaceTabs(displayContent);\n\treturn {\n\t\trawPath,\n\t\tlang,\n\t\trawContent: fileContent,\n\t\tnormalizedLines: normalized.split(\"\\n\"),\n\t\thighlightedLines: highlightCode(normalized, lang),\n\t};\n}\n\nfunction updateWriteHighlightCacheIncremental(\n\tcache: WriteHighlightCache | undefined,\n\trawPath: string | null,\n\tfileContent: string,\n): WriteHighlightCache | undefined {\n\tconst lang = rawPath ? getLanguageFromPath(rawPath) : undefined;\n\tif (!lang) return undefined;\n\tif (!cache) return rebuildWriteHighlightCacheFull(rawPath, fileContent);\n\tif (cache.lang !== lang || cache.rawPath !== rawPath) return rebuildWriteHighlightCacheFull(rawPath, fileContent);\n\tif (!fileContent.startsWith(cache.rawContent)) return rebuildWriteHighlightCacheFull(rawPath, fileContent);\n\tif (fileContent.length === cache.rawContent.length) return cache;\n\n\tconst deltaRaw = fileContent.slice(cache.rawContent.length);\n\tconst deltaDisplay = normalizeDisplayText(deltaRaw);\n\tconst deltaNormalized = replaceTabs(deltaDisplay);\n\tcache.rawContent = fileContent;\n\tif (cache.normalizedLines.length === 0) {\n\t\tcache.normalizedLines.push(\"\");\n\t\tcache.highlightedLines.push(\"\");\n\t}\n\n\tconst segments = deltaNormalized.split(\"\\n\");\n\tconst lastIndex = cache.normalizedLines.length - 1;\n\tcache.normalizedLines[lastIndex] += segments[0];\n\tcache.highlightedLines[lastIndex] = highlightSingleLine(cache.normalizedLines[lastIndex], cache.lang);\n\tfor (let i = 1; i < segments.length; i++) {\n\t\tcache.normalizedLines.push(segments[i]);\n\t\tcache.highlightedLines.push(highlightSingleLine(segments[i], cache.lang));\n\t}\n\trefreshWriteHighlightPrefix(cache);\n\treturn cache;\n}\n\nfunction trimTrailingEmptyLines(lines: string[]): string[] {\n\tlet end = lines.length;\n\twhile (end > 0 && lines[end - 1] === \"\") {\n\t\tend--;\n\t}\n\treturn lines.slice(0, end);\n}\n\nfunction formatWriteCall(\n\targs: { path?: string; file_path?: string; content?: string } | undefined,\n\toptions: ToolRenderResultOptions,\n\ttheme: Theme,\n\tcache: WriteHighlightCache | undefined,\n\tcwd: string,\n): string {\n\tconst rawPath = str(args?.file_path ?? args?.path);\n\tconst fileContent = str(args?.content);\n\tconst pathDisplay = renderToolPath(rawPath, theme, cwd);\n\tlet text = `${theme.fg(\"toolTitle\", theme.bold(\"write\"))} ${pathDisplay}`;\n\n\tif (fileContent === null) {\n\t\ttext += `\\n\\n${theme.fg(\"error\", \"[invalid content arg - expected string]\")}`;\n\t} else if (fileContent) {\n\t\tconst lang = rawPath ? getLanguageFromPath(rawPath) : undefined;\n\t\tconst renderedLines = lang\n\t\t\t? (cache?.highlightedLines ?? highlightCode(replaceTabs(normalizeDisplayText(fileContent)), lang))\n\t\t\t: normalizeDisplayText(fileContent).split(\"\\n\");\n\t\tconst lines = trimTrailingEmptyLines(renderedLines);\n\t\tconst totalLines = lines.length;\n\t\tconst maxLines = options.expanded ? lines.length : 10;\n\t\tconst displayLines = lines.slice(0, maxLines);\n\t\tconst remaining = lines.length - maxLines;\n\t\ttext += `\\n\\n${displayLines.map((line) => (lang ? line : theme.fg(\"toolOutput\", replaceTabs(line)))).join(\"\\n\")}`;\n\t\tif (remaining > 0) {\n\t\t\ttext += `${theme.fg(\"muted\", `\\n... (${remaining} more lines, ${totalLines} total,`)} ${keyHint(\"app.tools.expand\", \"to expand\")}${theme.fg(\"muted\", \")\")}`;\n\t\t}\n\t}\n\n\treturn text;\n}\n\nfunction formatWriteResult(\n\tresult: { content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; isError?: boolean },\n\ttheme: Theme,\n): string | undefined {\n\tif (!result.isError) {\n\t\treturn undefined;\n\t}\n\tconst output = result.content\n\t\t.filter((c) => c.type === \"text\")\n\t\t.map((c) => c.text || \"\")\n\t\t.join(\"\\n\");\n\tif (!output) {\n\t\treturn undefined;\n\t}\n\treturn `\\n${theme.fg(\"error\", output)}`;\n}\n\nexport function createWriteToolDefinition(\n\tcwd: string,\n\toptions?: WriteToolOptions,\n): ToolDefinition<typeof writeSchema, WriteToolDetails | undefined> {\n\tconst ops = options?.operations ?? defaultWriteOperations;\n\tconst writeScope = options?.writeScope;\n\tconst coordination = options?.coordination;\n\treturn {\n\t\tname: \"write\",\n\t\tlabel: \"write\",\n\t\tdescription:\n\t\t\t\"Write content to a file. Creates the file if it doesn't exist. Overwriting an existing file requires that you have read it in this session first (or supplied expectedVersion); otherwise the write is rejected so a concurrent change is never silently lost. Automatically creates parent directories.\",\n\t\tpromptSnippet: writeToolSystemPromptContribution.snippet,\n\t\tpromptGuidelines: [...writeToolSystemPromptContribution.guidelines],\n\t\tparameters: writeSchema,\n\t\tconstrainedSampling: { type: \"json_schema\", strict: \"prefer\" },\n\t\tasync execute(\n\t\t\t_toolCallId,\n\t\t\t{ path, content, expectedVersion }: { path: string; content: string; expectedVersion?: string },\n\t\t\tsignal?: AbortSignal,\n\t\t\t_onUpdate?,\n\t\t\tctx?: ToolExecuteContext,\n\t\t) {\n\t\t\tconst absolutePath = resolveToCwd(path, ctx?.cwd || cwd);\n\t\t\tconst dir = dirname(absolutePath);\n\t\t\tconst sessionId = ctx?.sessionManager?.getSessionId() ?? \"\";\n\n\t\t\treturn withFileMutationQueue(absolutePath, async () => {\n\t\t\t\t// Do not reject from an abort event listener here: that would release the\n\t\t\t\t// mutation queue while an in-flight filesystem operation may still finish.\n\t\t\t\t// Checking signal.aborted after each await observes the same aborts while\n\t\t\t\t// keeping the queue locked until the current operation has settled.\n\t\t\t\tconst throwIfAborted = (): void => {\n\t\t\t\t\tif (signal?.aborted) throw new Error(t(\"Operation aborted\"));\n\t\t\t\t};\n\n\t\t\t\tthrowIfAborted();\n\n\t\t\t\tconst lockOptions = {\n\t\t\t\t\t...(sessionId === \"\" ? {} : { sessionId }),\n\t\t\t\t\t...(signal === undefined ? {} : { signal }),\n\t\t\t\t};\n\t\t\t\treturn withFileMutationLock(absolutePath, lockOptions, async (lock) => {\n\t\t\t\t\tassertWithinWriteScope(lock.canonicalPath, ctx?.cwd || cwd, writeScope);\n\t\t\t\t\tconst target = lock.canonicalPath;\n\t\t\t\t\t// Advisory lease check (design §8.8): whole-file for write, inside\n\t\t\t\t\t// the lock, before any state mutation. Own leases are clear.\n\t\t\t\t\tawait checkIntentLease({\n\t\t\t\t\t\tcoordination,\n\t\t\t\t\t\townerSessionId: sessionId === \"\" ? undefined : sessionId,\n\t\t\t\t\t\tcanonicalPath: target,\n\t\t\t\t\t\tlockToken: lock.token,\n\t\t\t\t\t\tdisplayPath: path,\n\t\t\t\t\t});\n\t\t\t\t\tconst atomicTier = hasAtomicTier(ops);\n\t\t\t\t\tconst live = await probeSnapshot(ops, target);\n\t\t\t\t\tconst recorded = sessionId === \"\" ? undefined : getRecordedVersion(sessionId, target);\n\n\t\t\t\t\tlet oldContent: string | undefined;\n\t\t\t\t\tlet created: true | undefined;\n\t\t\t\t\tlet expectedStat = live.stat;\n\t\t\t\t\t// Tier B has no stat: remember the version we validated so the commit can\n\t\t\t\t\t// compare content instead of mtime.\n\t\t\t\t\tlet expectedHash: string | undefined = live.snapshot?.hash;\n\n\t\t\t\t\tif (live.exists === false) {\n\t\t\t\t\t\t// The file is gone. If this session (or the caller) expected it to exist,\n\t\t\t\t\t\t// say so instead of silently recreating it.\n\t\t\t\t\t\tif (expectedVersion !== undefined) {\n\t\t\t\t\t\t\tthrow new FileCoordinationError(\n\t\t\t\t\t\t\t\t\"file_missing\",\n\t\t\t\t\t\t\t\t`${path} no longer exists, but expectedVersion was supplied. Re-read before writing.`,\n\t\t\t\t\t\t\t\t{ path, expectedVersion },\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif (recorded !== undefined) {\n\t\t\t\t\t\t\tif (sessionId !== \"\") forgetVersion(sessionId, target);\n\t\t\t\t\t\t\tthrow new FileCoordinationError(\n\t\t\t\t\t\t\t\t\"file_missing\",\n\t\t\t\t\t\t\t\t`${path} was deleted after this session read it. The recorded version was cleared; write again to recreate it.`,\n\t\t\t\t\t\t\t\t{ path },\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t}\n\t\t\t\t\t\tcreated = true;\n\t\t\t\t\t\toldContent = \"\";\n\t\t\t\t\t} else if (live.exists === \"unknown\") {\n\t\t\t\t\t\t// No read primitive: existence and version are unknowable, so the\n\t\t\t\t\t\t// read-before-write guarantee cannot be enforced. Fail rather than\n\t\t\t\t\t\t// silently falling back to a blind overwrite.\n\t\t\t\t\t\tthrow new FileCoordinationError(\n\t\t\t\t\t\t\t\"coordination_unavailable\",\n\t\t\t\t\t\t\t`${path}: write operations cannot read the target, so overwrite safety cannot be enforced.`,\n\t\t\t\t\t\t\t{ path, reason: \"operations_cannot_read\" },\n\t\t\t\t\t\t);\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// Existing file: a blind overwrite is exactly the lost-update case this\n\t\t\t\t\t\t// design exists to prevent.\n\t\t\t\t\t\tif (expectedVersion === undefined && recorded === undefined) {\n\t\t\t\t\t\t\tthrow new FileCoordinationError(\n\t\t\t\t\t\t\t\t\"write_read_required\",\n\t\t\t\t\t\t\t\t`${path} already exists and this session has not read it. Call read first, or pass expectedVersion.`,\n\t\t\t\t\t\t\t\t{ path },\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t}\n\t\t\t\t\t\t// A read-only target must keep failing: atomic replace would otherwise\n\t\t\t\t\t\t// bypass the permission bit. Only tier A can check this locally; tier B\n\t\t\t\t\t\t// has no access primitive in its operations contract.\n\t\t\t\t\t\tif (atomicTier) await ensureWritable(target, path);\n\n\t\t\t\t\t\tif (expectedVersion !== undefined) {\n\t\t\t\t\t\t\t// Explicit expectation always wins and is never satisfied by the cache.\n\t\t\t\t\t\t\tconst snapshot = await readSnapshot(ops, target);\n\t\t\t\t\t\t\tif (snapshot === undefined) {\n\t\t\t\t\t\t\t\tthrow new FileCoordinationError(\"file_missing\", `${path} disappeared during the write.`, {\n\t\t\t\t\t\t\t\t\tpath,\n\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tif (snapshot.hash !== expectedVersion) {\n\t\t\t\t\t\t\t\tthrow versionConflict(path, snapshot.hash, expectedVersion);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\toldContent = snapshot.content.toString(\"utf-8\");\n\t\t\t\t\t\t\texpectedStat = snapshot.stat;\n\t\t\t\t\t\t\texpectedHash = snapshot.hash;\n\t\t\t\t\t\t} else if (recorded !== undefined) {\n\t\t\t\t\t\t\t// Tier A can skip the re-read when stat is unchanged; tier B must hash.\n\t\t\t\t\t\t\tconst unchanged =\n\t\t\t\t\t\t\t\tatomicTier && live.stat !== undefined && !statLooksChanged(live.stat, recorded.stat);\n\t\t\t\t\t\t\tif (!unchanged) {\n\t\t\t\t\t\t\t\tconst snapshot = await readSnapshot(ops, target);\n\t\t\t\t\t\t\t\tif (snapshot === undefined) {\n\t\t\t\t\t\t\t\t\tthrow new FileCoordinationError(\"file_missing\", `${path} disappeared during the write.`, {\n\t\t\t\t\t\t\t\t\t\tpath,\n\t\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tif (snapshot.hash !== recorded.hash) {\n\t\t\t\t\t\t\t\t\tthrow versionConflict(path, snapshot.hash, recorded.hash);\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\toldContent = snapshot.content.toString(\"utf-8\");\n\t\t\t\t\t\t\t\texpectedStat = snapshot.stat;\n\t\t\t\t\t\t\t\texpectedHash = snapshot.hash;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t// stat unchanged: skip the re-read and the hash (same-fd snapshot guarantees\n\t\t\t\t\t\t\t// the recorded hash belongs to this stat). deletedLines stays omitted.\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\tthrowIfAborted();\n\t\t\t\t\tawait ops.mkdir(dir);\n\t\t\t\t\tthrowIfAborted();\n\n\t\t\t\t\tconst committed = await commitPreparedContent({\n\t\t\t\t\t\tops,\n\t\t\t\t\t\tlock,\n\t\t\t\t\t\ttargetPath: target,\n\t\t\t\t\t\tcontent,\n\t\t\t\t\t\texpectedStat: created === true ? undefined : expectedStat,\n\t\t\t\t\t\texpectedHash: created === true ? undefined : expectedHash,\n\t\t\t\t\t\tmakeMismatchError: () => {\n\t\t\t\t\t\t\tconst current = statVersionSync(target);\n\t\t\t\t\t\t\treturn current === undefined\n\t\t\t\t\t\t\t\t? new FileCoordinationError(\n\t\t\t\t\t\t\t\t\t\t\"file_missing\",\n\t\t\t\t\t\t\t\t\t\t`${path} disappeared while the write was being committed.`,\n\t\t\t\t\t\t\t\t\t\t{ path },\n\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t: new FileCoordinationError(\n\t\t\t\t\t\t\t\t\t\t\"file_version_conflict\",\n\t\t\t\t\t\t\t\t\t\t`${path} changed while the write was being committed. Re-read the file and retry.`,\n\t\t\t\t\t\t\t\t\t\t{ path, currentVersion: current.mtimeNs, expectedVersion: expectedStat?.mtimeNs ?? \"\" },\n\t\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t},\n\t\t\t\t\t});\n\t\t\t\t\tthrowIfAborted();\n\n\t\t\t\t\tif (sessionId !== \"\") {\n\t\t\t\t\t\tconst after = await probeSnapshot(ops, target);\n\t\t\t\t\t\trecordVersion(sessionId, target, {\n\t\t\t\t\t\t\thash: await sha256Hex(Buffer.from(content, \"utf-8\")),\n\t\t\t\t\t\t\t...(after.stat === undefined ? {} : { stat: after.stat }),\n\t\t\t\t\t\t});\n\t\t\t\t\t}\n\n\t\t\t\t\tlet details: WriteToolDetails;\n\t\t\t\t\tif (oldContent !== undefined) {\n\t\t\t\t\t\tconst stats = countDiffLines(oldContent, content);\n\t\t\t\t\t\tdetails = created\n\t\t\t\t\t\t\t? { addedLines: stats.addedLines, deletedLines: 0, created }\n\t\t\t\t\t\t\t: { addedLines: stats.addedLines, deletedLines: stats.deletedLines };\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// Old content unknown: only the new content's line count is known.\n\t\t\t\t\t\tdetails = { addedLines: countDiffLines(\"\", content).addedLines };\n\t\t\t\t\t}\n\t\t\t\t\tif (!committed.atomic) details.coordination = { atomic: false };\n\n\t\t\t\t\treturn {\n\t\t\t\t\t\tcontent: [{ type: \"text\", text: `Successfully wrote to ${path}` }],\n\t\t\t\t\t\tdetails,\n\t\t\t\t\t};\n\t\t\t\t});\n\t\t\t});\n\t\t},\n\t\tquietSummary(\n\t\t\targs: WriteToolInput,\n\t\t\tcontext: { cwd: string; isError: boolean; isPartial: boolean; details: WriteToolDetails | undefined },\n\t\t): string | undefined {\n\t\t\tconst rawPath = str(args?.path);\n\t\t\tif (!rawPath) return undefined;\n\t\t\tconst pathDisplay = formatPathRelativeToCwdOrAbsolute(rawPath, context.cwd);\n\t\t\tlet text = `write ${pathDisplay}`;\n\t\t\tconst details = context.details;\n\t\t\tif (details && !context.isError) {\n\t\t\t\ttext += ` +${details.addedLines}`;\n\t\t\t\tif (details.deletedLines !== undefined) {\n\t\t\t\t\ttext += ` -${details.deletedLines}`;\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn text;\n\t\t},\n\t\trenderCall(args, theme, context) {\n\t\t\tconst renderArgs = args as { path?: string; file_path?: string; content?: string } | undefined;\n\t\t\tconst rawPath = str(renderArgs?.file_path ?? renderArgs?.path);\n\t\t\tconst fileContent = str(renderArgs?.content);\n\t\t\tconst component =\n\t\t\t\t(context.lastComponent as WriteCallRenderComponent | undefined) ?? new WriteCallRenderComponent();\n\t\t\tif (fileContent !== null) {\n\t\t\t\tcomponent.cache = context.argsComplete\n\t\t\t\t\t? rebuildWriteHighlightCacheFull(rawPath, fileContent)\n\t\t\t\t\t: updateWriteHighlightCacheIncremental(component.cache, rawPath, fileContent);\n\t\t\t} else {\n\t\t\t\tcomponent.cache = undefined;\n\t\t\t}\n\t\t\tcomponent.setText(\n\t\t\t\tformatWriteCall(\n\t\t\t\t\trenderArgs,\n\t\t\t\t\t{ expanded: context.expanded, isPartial: context.isPartial },\n\t\t\t\t\ttheme,\n\t\t\t\t\tcomponent.cache,\n\t\t\t\t\tcontext.cwd,\n\t\t\t\t),\n\t\t\t);\n\t\t\treturn component;\n\t\t},\n\t\trenderResult(result, _options, theme, context) {\n\t\t\tconst output = formatWriteResult({ ...result, isError: context.isError }, theme);\n\t\t\tif (!output) {\n\t\t\t\tconst component = (context.lastComponent as Container | undefined) ?? new Container();\n\t\t\t\tcomponent.clear();\n\t\t\t\treturn component;\n\t\t\t}\n\t\t\tconst text = (context.lastComponent as Text | undefined) ?? new Text(\"\", 0, 0);\n\t\t\ttext.setText(output);\n\t\t\treturn text;\n\t\t},\n\t};\n}\n\nexport function createWriteTool(cwd: string, options?: WriteToolOptions): AgentTool<typeof writeSchema> {\n\treturn wrapToolDefinition(createWriteToolDefinition(cwd, options));\n}\n"]}
package/dist/write.js CHANGED
@@ -1,28 +1,65 @@
1
1
  // Migrated from packages/agent-forge/src/core/tools/write (batch 1, copy-then-delete);
2
2
  // host modules are imported through supply keys (@agent-forge/host/*).
3
- import { mkdir as fsMkdir, writeFile as fsWriteFile } from "node:fs/promises";
3
+ import { randomUUID } from "node:crypto";
4
+ import { constants, renameSync } from "node:fs";
5
+ import { access as fsAccess, mkdir as fsMkdir, readFile as fsReadFile, writeFile as fsWriteFile, } from "node:fs/promises";
4
6
  import { dirname } from "node:path";
7
+ import { countDiffLines } from "@agent-forge/host/edit-diff";
8
+ import { FileCoordinationError, sha256Hex, statLooksChanged, statVersionOf, statVersionSync, withFileMutationLock, } from "@agent-forge/host/file-lock";
5
9
  import { withFileMutationQueue } from "@agent-forge/host/file-mutation-queue";
6
10
  import { t } from "@agent-forge/host/i18n";
7
11
  import { keyHint } from "@agent-forge/host/keybinding-hints";
8
12
  import { resolveToCwd } from "@agent-forge/host/path-utils";
13
+ import { formatPathRelativeToCwdOrAbsolute } from "@agent-forge/host/paths";
9
14
  import { normalizeDisplayText, renderToolPath, replaceTabs, str } from "@agent-forge/host/render-utils";
10
15
  import { getLanguageFromPath, highlightCode } from "@agent-forge/host/theme";
11
16
  import { Container, Text } from "@agent-forge/tui";
12
17
  import { Type } from "typebox";
18
+ import { commitPreparedContent } from "./atomic-ops.js";
19
+ import { checkIntentLease } from "./lease-check.js";
20
+ import { hasAtomicTier, probeSnapshot, readSnapshot } from "./snapshot-ops.js";
13
21
  import { wrapToolDefinition } from "./types.js";
22
+ import { forgetVersion, getRecordedVersion, recordVersion } from "./version-registry.js";
23
+ import { assertWithinWriteScope } from "./write-scope.js";
14
24
  const writeSchema = Type.Object({
15
25
  path: Type.String({ description: "Path to the file to write (relative or absolute)" }),
16
26
  content: Type.String({ description: "Content to write to the file" }),
27
+ expectedVersion: Type.Optional(Type.String({
28
+ description: "Optional sha256 of the current content. When set, the write fails if the file changed. Programmatic callers use this instead of an in-session read.",
29
+ })),
17
30
  });
18
31
  export const writeToolSystemPromptContribution = {
19
32
  snippet: "Create or overwrite files",
20
- guidelines: ["Use write only for new files or complete rewrites."],
33
+ guidelines: [
34
+ "Use write only for new files or complete rewrites.",
35
+ "Reading the file first is required before overwriting an existing file; without a prior read the write is rejected.",
36
+ ],
21
37
  };
22
38
  const defaultWriteOperations = {
39
+ readFile: (path) => fsReadFile(path),
23
40
  writeFile: (path, content) => fsWriteFile(path, content, "utf-8"),
24
41
  mkdir: (dir) => fsMkdir(dir, { recursive: true }).then(() => { }),
42
+ statVersion: (path) => statVersionOf(path),
43
+ writeTemp: async (path, content) => {
44
+ const tempPath = `${path}.${randomUUID()}.agent-forge-tmp`;
45
+ await fsWriteFile(tempPath, content, { encoding: "utf-8", mode: 0o600 });
46
+ return tempPath;
47
+ },
48
+ commitSync: (tempPath, targetPath) => renameSync(tempPath, targetPath),
25
49
  };
50
+ function versionConflict(path, currentVersion, expectedVersion) {
51
+ return new FileCoordinationError("file_version_conflict", `${path} changed on disk since it was read. Re-read the file and retry.`, { path, currentVersion, expectedVersion });
52
+ }
53
+ /** Reject a read-only target so the atomic replace cannot bypass the permission bit. */
54
+ async function ensureWritable(target, displayPath) {
55
+ try {
56
+ await fsAccess(target, constants.W_OK);
57
+ }
58
+ catch (error) {
59
+ const errorMessage = error instanceof Error && "code" in error ? `Error code: ${error.code}` : String(error);
60
+ throw new Error(t("Could not write file: {path}. {errorMessage}.", { path: displayPath, errorMessage }));
61
+ }
62
+ }
26
63
  class WriteCallRenderComponent extends Text {
27
64
  cache;
28
65
  constructor() {
@@ -137,17 +174,20 @@ function formatWriteResult(result, theme) {
137
174
  }
138
175
  export function createWriteToolDefinition(cwd, options) {
139
176
  const ops = options?.operations ?? defaultWriteOperations;
177
+ const writeScope = options?.writeScope;
178
+ const coordination = options?.coordination;
140
179
  return {
141
180
  name: "write",
142
181
  label: "write",
143
- description: "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.",
182
+ description: "Write content to a file. Creates the file if it doesn't exist. Overwriting an existing file requires that you have read it in this session first (or supplied expectedVersion); otherwise the write is rejected so a concurrent change is never silently lost. Automatically creates parent directories.",
144
183
  promptSnippet: writeToolSystemPromptContribution.snippet,
145
184
  promptGuidelines: [...writeToolSystemPromptContribution.guidelines],
146
185
  parameters: writeSchema,
147
186
  constrainedSampling: { type: "json_schema", strict: "prefer" },
148
- async execute(_toolCallId, { path, content }, signal, _onUpdate, ctx) {
187
+ async execute(_toolCallId, { path, content, expectedVersion }, signal, _onUpdate, ctx) {
149
188
  const absolutePath = resolveToCwd(path, ctx?.cwd || cwd);
150
189
  const dir = dirname(absolutePath);
190
+ const sessionId = ctx?.sessionManager?.getSessionId() ?? "";
151
191
  return withFileMutationQueue(absolutePath, async () => {
152
192
  // Do not reject from an abort event listener here: that would release the
153
193
  // mutation queue while an in-flight filesystem operation may still finish.
@@ -158,18 +198,158 @@ export function createWriteToolDefinition(cwd, options) {
158
198
  throw new Error(t("Operation aborted"));
159
199
  };
160
200
  throwIfAborted();
161
- // Create parent directories if needed.
162
- await ops.mkdir(dir);
163
- throwIfAborted();
164
- // Write the file contents.
165
- await ops.writeFile(absolutePath, content);
166
- throwIfAborted();
167
- return {
168
- content: [{ type: "text", text: `Successfully wrote to ${path}` }],
169
- details: undefined,
201
+ const lockOptions = {
202
+ ...(sessionId === "" ? {} : { sessionId }),
203
+ ...(signal === undefined ? {} : { signal }),
170
204
  };
205
+ return withFileMutationLock(absolutePath, lockOptions, async (lock) => {
206
+ assertWithinWriteScope(lock.canonicalPath, ctx?.cwd || cwd, writeScope);
207
+ const target = lock.canonicalPath;
208
+ // Advisory lease check (design §8.8): whole-file for write, inside
209
+ // the lock, before any state mutation. Own leases are clear.
210
+ await checkIntentLease({
211
+ coordination,
212
+ ownerSessionId: sessionId === "" ? undefined : sessionId,
213
+ canonicalPath: target,
214
+ lockToken: lock.token,
215
+ displayPath: path,
216
+ });
217
+ const atomicTier = hasAtomicTier(ops);
218
+ const live = await probeSnapshot(ops, target);
219
+ const recorded = sessionId === "" ? undefined : getRecordedVersion(sessionId, target);
220
+ let oldContent;
221
+ let created;
222
+ let expectedStat = live.stat;
223
+ // Tier B has no stat: remember the version we validated so the commit can
224
+ // compare content instead of mtime.
225
+ let expectedHash = live.snapshot?.hash;
226
+ if (live.exists === false) {
227
+ // The file is gone. If this session (or the caller) expected it to exist,
228
+ // say so instead of silently recreating it.
229
+ if (expectedVersion !== undefined) {
230
+ throw new FileCoordinationError("file_missing", `${path} no longer exists, but expectedVersion was supplied. Re-read before writing.`, { path, expectedVersion });
231
+ }
232
+ if (recorded !== undefined) {
233
+ if (sessionId !== "")
234
+ forgetVersion(sessionId, target);
235
+ throw new FileCoordinationError("file_missing", `${path} was deleted after this session read it. The recorded version was cleared; write again to recreate it.`, { path });
236
+ }
237
+ created = true;
238
+ oldContent = "";
239
+ }
240
+ else if (live.exists === "unknown") {
241
+ // No read primitive: existence and version are unknowable, so the
242
+ // read-before-write guarantee cannot be enforced. Fail rather than
243
+ // silently falling back to a blind overwrite.
244
+ throw new FileCoordinationError("coordination_unavailable", `${path}: write operations cannot read the target, so overwrite safety cannot be enforced.`, { path, reason: "operations_cannot_read" });
245
+ }
246
+ else {
247
+ // Existing file: a blind overwrite is exactly the lost-update case this
248
+ // design exists to prevent.
249
+ if (expectedVersion === undefined && recorded === undefined) {
250
+ throw new FileCoordinationError("write_read_required", `${path} already exists and this session has not read it. Call read first, or pass expectedVersion.`, { path });
251
+ }
252
+ // A read-only target must keep failing: atomic replace would otherwise
253
+ // bypass the permission bit. Only tier A can check this locally; tier B
254
+ // has no access primitive in its operations contract.
255
+ if (atomicTier)
256
+ await ensureWritable(target, path);
257
+ if (expectedVersion !== undefined) {
258
+ // Explicit expectation always wins and is never satisfied by the cache.
259
+ const snapshot = await readSnapshot(ops, target);
260
+ if (snapshot === undefined) {
261
+ throw new FileCoordinationError("file_missing", `${path} disappeared during the write.`, {
262
+ path,
263
+ });
264
+ }
265
+ if (snapshot.hash !== expectedVersion) {
266
+ throw versionConflict(path, snapshot.hash, expectedVersion);
267
+ }
268
+ oldContent = snapshot.content.toString("utf-8");
269
+ expectedStat = snapshot.stat;
270
+ expectedHash = snapshot.hash;
271
+ }
272
+ else if (recorded !== undefined) {
273
+ // Tier A can skip the re-read when stat is unchanged; tier B must hash.
274
+ const unchanged = atomicTier && live.stat !== undefined && !statLooksChanged(live.stat, recorded.stat);
275
+ if (!unchanged) {
276
+ const snapshot = await readSnapshot(ops, target);
277
+ if (snapshot === undefined) {
278
+ throw new FileCoordinationError("file_missing", `${path} disappeared during the write.`, {
279
+ path,
280
+ });
281
+ }
282
+ if (snapshot.hash !== recorded.hash) {
283
+ throw versionConflict(path, snapshot.hash, recorded.hash);
284
+ }
285
+ oldContent = snapshot.content.toString("utf-8");
286
+ expectedStat = snapshot.stat;
287
+ expectedHash = snapshot.hash;
288
+ }
289
+ // stat unchanged: skip the re-read and the hash (same-fd snapshot guarantees
290
+ // the recorded hash belongs to this stat). deletedLines stays omitted.
291
+ }
292
+ }
293
+ throwIfAborted();
294
+ await ops.mkdir(dir);
295
+ throwIfAborted();
296
+ const committed = await commitPreparedContent({
297
+ ops,
298
+ lock,
299
+ targetPath: target,
300
+ content,
301
+ expectedStat: created === true ? undefined : expectedStat,
302
+ expectedHash: created === true ? undefined : expectedHash,
303
+ makeMismatchError: () => {
304
+ const current = statVersionSync(target);
305
+ return current === undefined
306
+ ? new FileCoordinationError("file_missing", `${path} disappeared while the write was being committed.`, { path })
307
+ : new FileCoordinationError("file_version_conflict", `${path} changed while the write was being committed. Re-read the file and retry.`, { path, currentVersion: current.mtimeNs, expectedVersion: expectedStat?.mtimeNs ?? "" });
308
+ },
309
+ });
310
+ throwIfAborted();
311
+ if (sessionId !== "") {
312
+ const after = await probeSnapshot(ops, target);
313
+ recordVersion(sessionId, target, {
314
+ hash: await sha256Hex(Buffer.from(content, "utf-8")),
315
+ ...(after.stat === undefined ? {} : { stat: after.stat }),
316
+ });
317
+ }
318
+ let details;
319
+ if (oldContent !== undefined) {
320
+ const stats = countDiffLines(oldContent, content);
321
+ details = created
322
+ ? { addedLines: stats.addedLines, deletedLines: 0, created }
323
+ : { addedLines: stats.addedLines, deletedLines: stats.deletedLines };
324
+ }
325
+ else {
326
+ // Old content unknown: only the new content's line count is known.
327
+ details = { addedLines: countDiffLines("", content).addedLines };
328
+ }
329
+ if (!committed.atomic)
330
+ details.coordination = { atomic: false };
331
+ return {
332
+ content: [{ type: "text", text: `Successfully wrote to ${path}` }],
333
+ details,
334
+ };
335
+ });
171
336
  });
172
337
  },
338
+ quietSummary(args, context) {
339
+ const rawPath = str(args?.path);
340
+ if (!rawPath)
341
+ return undefined;
342
+ const pathDisplay = formatPathRelativeToCwdOrAbsolute(rawPath, context.cwd);
343
+ let text = `write ${pathDisplay}`;
344
+ const details = context.details;
345
+ if (details && !context.isError) {
346
+ text += ` +${details.addedLines}`;
347
+ if (details.deletedLines !== undefined) {
348
+ text += ` -${details.deletedLines}`;
349
+ }
350
+ }
351
+ return text;
352
+ },
173
353
  renderCall(args, theme, context) {
174
354
  const renderArgs = args;
175
355
  const rawPath = str(renderArgs?.file_path ?? renderArgs?.path);