@namzu/sdk 1.3.0 → 2.0.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 (171) hide show
  1. package/CHANGELOG.md +106 -0
  2. package/dist/compaction/__tests__/verifier-empty-reply.test.d.ts +12 -0
  3. package/dist/compaction/__tests__/verifier-empty-reply.test.d.ts.map +1 -0
  4. package/dist/compaction/__tests__/verifier-empty-reply.test.js +73 -0
  5. package/dist/compaction/__tests__/verifier-empty-reply.test.js.map +1 -0
  6. package/dist/compaction/verifier.d.ts.map +1 -1
  7. package/dist/compaction/verifier.js +10 -1
  8. package/dist/compaction/verifier.js.map +1 -1
  9. package/dist/config/__tests__/compaction-budget-schema.test.d.ts +18 -0
  10. package/dist/config/__tests__/compaction-budget-schema.test.d.ts.map +1 -0
  11. package/dist/config/__tests__/compaction-budget-schema.test.js +96 -0
  12. package/dist/config/__tests__/compaction-budget-schema.test.js.map +1 -0
  13. package/dist/config/runtime.d.ts.map +1 -1
  14. package/dist/config/runtime.js +20 -11
  15. package/dist/config/runtime.js.map +1 -1
  16. package/dist/manager/run/persistence.d.ts +2 -1
  17. package/dist/manager/run/persistence.d.ts.map +1 -1
  18. package/dist/manager/run/persistence.js +3 -1
  19. package/dist/manager/run/persistence.js.map +1 -1
  20. package/dist/provider/__tests__/errors.test.d.ts +2 -0
  21. package/dist/provider/__tests__/errors.test.d.ts.map +1 -0
  22. package/dist/provider/__tests__/errors.test.js +61 -0
  23. package/dist/provider/__tests__/errors.test.js.map +1 -0
  24. package/dist/provider/errors.d.ts +108 -0
  25. package/dist/provider/errors.d.ts.map +1 -0
  26. package/dist/provider/errors.js +311 -0
  27. package/dist/provider/errors.js.map +1 -0
  28. package/dist/provider/index.d.ts +2 -0
  29. package/dist/provider/index.d.ts.map +1 -1
  30. package/dist/provider/index.js +1 -0
  31. package/dist/provider/index.js.map +1 -1
  32. package/dist/public-runtime.d.ts +11 -11
  33. package/dist/public-runtime.d.ts.map +1 -1
  34. package/dist/public-runtime.js +11 -11
  35. package/dist/public-runtime.js.map +1 -1
  36. package/dist/registry/tool/execute.d.ts.map +1 -1
  37. package/dist/registry/tool/execute.js +45 -12
  38. package/dist/registry/tool/execute.js.map +1 -1
  39. package/dist/registry/tool/execute.test.js +104 -7
  40. package/dist/registry/tool/execute.test.js.map +1 -1
  41. package/dist/registry/toolset/catalog.d.ts.map +1 -1
  42. package/dist/registry/toolset/catalog.js +17 -4
  43. package/dist/registry/toolset/catalog.js.map +1 -1
  44. package/dist/registry/toolset/catalog.test.js +101 -0
  45. package/dist/registry/toolset/catalog.test.js.map +1 -1
  46. package/dist/runtime/query/__tests__/capability-negotiation.test.js +79 -0
  47. package/dist/runtime/query/__tests__/capability-negotiation.test.js.map +1 -1
  48. package/dist/runtime/query/__tests__/deferred-tools.test.js +100 -0
  49. package/dist/runtime/query/__tests__/deferred-tools.test.js.map +1 -1
  50. package/dist/runtime/query/__tests__/long-document-flow.test.js +12 -12
  51. package/dist/runtime/query/__tests__/long-document-flow.test.js.map +1 -1
  52. package/dist/runtime/query/__tests__/stream-recovery.test.js +55 -2
  53. package/dist/runtime/query/__tests__/stream-recovery.test.js.map +1 -1
  54. package/dist/runtime/query/executor.js +2 -2
  55. package/dist/runtime/query/executor.js.map +1 -1
  56. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  57. package/dist/runtime/query/iteration/index.js +10 -0
  58. package/dist/runtime/query/iteration/index.js.map +1 -1
  59. package/dist/runtime/query/iteration/phases/compaction-safe-cut.test.d.ts +32 -0
  60. package/dist/runtime/query/iteration/phases/compaction-safe-cut.test.d.ts.map +1 -0
  61. package/dist/runtime/query/iteration/phases/compaction-safe-cut.test.js +152 -0
  62. package/dist/runtime/query/iteration/phases/compaction-safe-cut.test.js.map +1 -0
  63. package/dist/runtime/query/iteration/phases/compaction.d.ts.map +1 -1
  64. package/dist/runtime/query/iteration/phases/compaction.js +52 -12
  65. package/dist/runtime/query/iteration/phases/compaction.js.map +1 -1
  66. package/dist/runtime/query/iteration/stream-turn.d.ts.map +1 -1
  67. package/dist/runtime/query/iteration/stream-turn.js +11 -5
  68. package/dist/runtime/query/iteration/stream-turn.js.map +1 -1
  69. package/dist/runtime/query/result.d.ts.map +1 -1
  70. package/dist/runtime/query/result.js +11 -1
  71. package/dist/runtime/query/result.js.map +1 -1
  72. package/dist/tools/builtins/__tests__/edit.test.js +216 -10
  73. package/dist/tools/builtins/__tests__/edit.test.js.map +1 -1
  74. package/dist/tools/builtins/__tests__/payload-budget.test.js +30 -16
  75. package/dist/tools/builtins/__tests__/payload-budget.test.js.map +1 -1
  76. package/dist/tools/builtins/__tests__/write-file.test.js +37 -13
  77. package/dist/tools/builtins/__tests__/write-file.test.js.map +1 -1
  78. package/dist/tools/builtins/atomic-write-file.d.ts +13 -0
  79. package/dist/tools/builtins/atomic-write-file.d.ts.map +1 -0
  80. package/dist/tools/builtins/atomic-write-file.js +46 -0
  81. package/dist/tools/builtins/atomic-write-file.js.map +1 -0
  82. package/dist/tools/builtins/bash.js +2 -2
  83. package/dist/tools/builtins/bash.js.map +1 -1
  84. package/dist/tools/builtins/edit.d.ts +2 -5
  85. package/dist/tools/builtins/edit.d.ts.map +1 -1
  86. package/dist/tools/builtins/edit.js +105 -125
  87. package/dist/tools/builtins/edit.js.map +1 -1
  88. package/dist/tools/builtins/file-mutation-lock.d.ts +7 -0
  89. package/dist/tools/builtins/file-mutation-lock.d.ts.map +1 -0
  90. package/dist/tools/builtins/file-mutation-lock.js +25 -0
  91. package/dist/tools/builtins/file-mutation-lock.js.map +1 -0
  92. package/dist/tools/builtins/index.js +2 -2
  93. package/dist/tools/builtins/index.js.map +1 -1
  94. package/dist/tools/builtins/write-file.d.ts +1 -2
  95. package/dist/tools/builtins/write-file.d.ts.map +1 -1
  96. package/dist/tools/builtins/write-file.js +66 -40
  97. package/dist/tools/builtins/write-file.js.map +1 -1
  98. package/dist/tools/coordinator/__tests__/task-list.test.js +14 -0
  99. package/dist/tools/coordinator/__tests__/task-list.test.js.map +1 -1
  100. package/dist/tools/coordinator/agent.d.ts +6 -8
  101. package/dist/tools/coordinator/agent.d.ts.map +1 -1
  102. package/dist/tools/coordinator/agent.js.map +1 -1
  103. package/dist/tools/coordinator/index.d.ts.map +1 -1
  104. package/dist/tools/coordinator/index.js +11 -32
  105. package/dist/tools/coordinator/index.js.map +1 -1
  106. package/dist/tools/defineTool.d.ts +3 -0
  107. package/dist/tools/defineTool.d.ts.map +1 -1
  108. package/dist/tools/defineTool.js +3 -0
  109. package/dist/tools/defineTool.js.map +1 -1
  110. package/dist/types/provider/chat.d.ts +9 -0
  111. package/dist/types/provider/chat.d.ts.map +1 -1
  112. package/dist/types/provider/error.d.ts +22 -0
  113. package/dist/types/provider/error.d.ts.map +1 -0
  114. package/dist/types/provider/error.js +2 -0
  115. package/dist/types/provider/error.js.map +1 -0
  116. package/dist/types/provider/index.d.ts +1 -0
  117. package/dist/types/provider/index.d.ts.map +1 -1
  118. package/dist/types/run/entity.d.ts +2 -0
  119. package/dist/types/run/entity.d.ts.map +1 -1
  120. package/dist/types/run/events.d.ts +2 -0
  121. package/dist/types/run/events.d.ts.map +1 -1
  122. package/dist/types/run/events.js.map +1 -1
  123. package/dist/types/sandbox/index.d.ts +4 -0
  124. package/dist/types/sandbox/index.d.ts.map +1 -1
  125. package/dist/types/sandbox/index.js.map +1 -1
  126. package/dist/types/tool/index.d.ts +20 -0
  127. package/dist/types/tool/index.d.ts.map +1 -1
  128. package/package.json +2 -1
  129. package/src/compaction/__tests__/verifier-empty-reply.test.ts +96 -0
  130. package/src/compaction/verifier.ts +10 -1
  131. package/src/config/__tests__/compaction-budget-schema.test.ts +114 -0
  132. package/src/config/runtime.ts +20 -11
  133. package/src/manager/run/persistence.ts +3 -1
  134. package/src/provider/__tests__/errors.test.ts +85 -0
  135. package/src/provider/errors.ts +344 -0
  136. package/src/provider/index.ts +15 -0
  137. package/src/public-runtime.ts +53 -10
  138. package/src/registry/tool/execute.test.ts +121 -7
  139. package/src/registry/tool/execute.ts +50 -12
  140. package/src/registry/toolset/catalog.test.ts +118 -0
  141. package/src/registry/toolset/catalog.ts +26 -4
  142. package/src/runtime/query/__tests__/capability-negotiation.test.ts +88 -0
  143. package/src/runtime/query/__tests__/deferred-tools.test.ts +111 -0
  144. package/src/runtime/query/__tests__/long-document-flow.test.ts +12 -12
  145. package/src/runtime/query/__tests__/stream-recovery.test.ts +65 -2
  146. package/src/runtime/query/executor.ts +2 -2
  147. package/src/runtime/query/iteration/index.ts +15 -0
  148. package/src/runtime/query/iteration/phases/compaction-safe-cut.test.ts +186 -0
  149. package/src/runtime/query/iteration/phases/compaction.ts +54 -12
  150. package/src/runtime/query/iteration/stream-turn.ts +11 -6
  151. package/src/runtime/query/result.ts +11 -1
  152. package/src/tools/builtins/__tests__/edit.test.ts +270 -10
  153. package/src/tools/builtins/__tests__/payload-budget.test.ts +40 -20
  154. package/src/tools/builtins/__tests__/write-file.test.ts +52 -13
  155. package/src/tools/builtins/atomic-write-file.ts +58 -0
  156. package/src/tools/builtins/bash.ts +2 -2
  157. package/src/tools/builtins/edit.ts +116 -162
  158. package/src/tools/builtins/file-mutation-lock.ts +27 -0
  159. package/src/tools/builtins/index.ts +2 -2
  160. package/src/tools/builtins/write-file.ts +68 -44
  161. package/src/tools/coordinator/__tests__/task-list.test.ts +16 -0
  162. package/src/tools/coordinator/agent.ts +6 -8
  163. package/src/tools/coordinator/index.ts +11 -34
  164. package/src/tools/defineTool.ts +6 -0
  165. package/src/types/provider/chat.ts +14 -1
  166. package/src/types/provider/error.ts +29 -0
  167. package/src/types/provider/index.ts +5 -0
  168. package/src/types/run/entity.ts +2 -0
  169. package/src/types/run/events.ts +7 -1
  170. package/src/types/sandbox/index.ts +4 -0
  171. package/src/types/tool/index.ts +20 -0
@@ -1,76 +1,77 @@
1
- import { readFile, writeFile } from 'node:fs/promises'
1
+ import { readFile } from 'node:fs/promises'
2
2
  import { resolve } from 'node:path'
3
3
  import { z } from 'zod'
4
4
  import { defineTool } from '../defineTool.js'
5
+ import { atomicWriteFile } from './atomic-write-file.js'
6
+ import { withFileMutationLock } from './file-mutation-lock.js'
5
7
 
6
8
  const inputSchema = z
7
9
  .object({
8
- path: z.string().describe('Path to the file to edit'),
9
- old_string: z
10
+ path: z
10
11
  .string()
11
- .optional()
12
- .describe('The exact string to find and replace. Must be unique in the file.'),
13
- oldStr: z
12
+ .refine((value) => value.trim().length > 0, 'Path must not be empty.')
13
+ .describe('Path to the file to edit. Must not be empty.'),
14
+ old_string: z
14
15
  .string()
15
- .optional()
16
+ .min(1)
16
17
  .describe(
17
- 'Alias for old_string. Used by hosts that expose text replacement as oldStr/newStr.',
18
+ 'The exact unique text to replace, without read-tool line-number prefixes. Must not be empty.',
18
19
  ),
19
20
  new_string: z
20
21
  .string()
21
- .optional()
22
- .describe(
23
- 'The replacement string. Self-budget this payload under 12000 characters before calling.',
24
- ),
25
- newStr: z
26
- .string()
27
- .optional()
28
- .describe(
29
- 'Alias for new_string. Also used as inserted content when insertLine is provided. Self-budget this payload under 12000 characters before calling.',
30
- ),
31
- insertLine: z
32
- .union([z.coerce.number().int().min(0), z.string().min(1)])
33
- .optional()
34
22
  .describe(
35
- 'Optional line insertion target. Inserts the replacement after this 1-indexed line; 0 inserts before the first line; "end" appends to the file.',
23
+ 'The exact replacement text. May be empty to delete old_string. Keep this payload under 12000 characters.',
36
24
  ),
37
25
  replace_all: z
38
26
  .boolean()
39
27
  .default(false)
40
- .describe('Replace all occurrences instead of just the first unique match'),
28
+ .describe('Replace every occurrence instead of requiring one unique match.'),
41
29
  })
42
- .refine((value) => typeof value.new_string === 'string' || typeof value.newStr === 'string', {
43
- message: 'Either new_string or newStr is required.',
44
- })
45
- .refine(
46
- (value) =>
47
- value.insertLine !== undefined ||
48
- typeof value.old_string === 'string' ||
49
- typeof value.oldStr === 'string',
50
- { message: 'Either old_string/oldStr or insertLine is required.' },
51
- )
30
+ .strict()
52
31
 
53
32
  type EditInput = z.infer<typeof inputSchema>
54
33
 
55
- type NormalizedEditInput =
56
- | {
57
- operation: 'replace'
58
- oldString: string
59
- newString: string
60
- replace_all: boolean
61
- }
62
- | {
63
- operation: 'insert'
64
- insertLine: number | 'end'
65
- newString: string
66
- replace_all: boolean
67
- }
34
+ const modelInputSchema: Record<string, unknown> = {
35
+ type: 'object',
36
+ properties: {
37
+ path: {
38
+ type: 'string',
39
+ description: 'Path to the file to edit. Must not be empty.',
40
+ },
41
+ old_string: {
42
+ type: 'string',
43
+ description:
44
+ 'Exact unique text from the file, without read-tool line-number prefixes. Must not be empty.',
45
+ },
46
+ new_string: {
47
+ type: 'string',
48
+ description:
49
+ 'Exact replacement text. May be empty to delete old_string. Keep under 12000 characters.',
50
+ },
51
+ replace_all: {
52
+ type: 'boolean',
53
+ description: 'Replace every occurrence instead of requiring one unique match.',
54
+ },
55
+ },
56
+ required: ['path', 'old_string', 'new_string'],
57
+ additionalProperties: false,
58
+ }
59
+
60
+ type ExactReplacement = Readonly<{
61
+ oldString: string
62
+ newString: string
63
+ replaceAll: boolean
64
+ }>
68
65
 
69
66
  export const EditTool = defineTool({
70
67
  name: 'edit',
71
68
  description:
72
- 'Makes targeted edits to a file using exact string find-and-replace or line insertion. THIS IS THE PREFERRED WAY TO MODIFY AN EXISTING FILE never reach for `write` to change a file that already exists, because `write` overwrites the whole body and discards earlier work on partial failure. `edit` keeps the rest of the file byte-for-byte intact and is recoverable: if a single edit fails (old_string/oldStr ambiguous, broader restructuring needed), follow up with another `edit` instead of re-emitting the entire file via `write`. The old_string/oldStr must be unique in the file unless replace_all is true. For insertions, pass insertLine plus new_string/newStr; use insertLine: "end" to extend a file at the end. Self-budget new_string/newStr under 12000 characters before emitting the tool call; use repeated bounded edits for long sections. Preserves file formatting and indentation.',
69
+ "Performs one targeted exact-string replacement in an existing file. Pass path + old_string + new_string; old_string must match exactly and be unique unless replace_all is true. Read the file immediately before editing, preserve whitespace and indentation, and never include the read tool's line-number prefix in old_string. To append or insert, replace a unique existing tail/marker with itself plus the new text; advance a deterministic marker such as {{CHUNK_001}} to {{CHUNK_002}} so replaying a completed edit cannot duplicate content. Prefer this tool over rewriting an existing file. Self-budget new_string under 12000 characters.",
73
70
  inputSchema,
71
+ modelInputSchema,
72
+ enforceModelInput: true,
73
+ validationErrorHint:
74
+ 'Required shape: {"path":"file.md","old_string":"exact unique text","new_string":"replacement text"}. Optional: "replace_all": true.',
74
75
  category: 'filesystem',
75
76
  permissions: ['file_write'],
76
77
  readOnly: false,
@@ -78,144 +79,120 @@ export const EditTool = defineTool({
78
79
  concurrencySafe: false,
79
80
 
80
81
  async execute(input: EditInput, context) {
81
- const normalized = normalizeEditInput(input)
82
- if (!normalized.success) {
83
- return { success: false, output: '', error: normalized.error }
84
- }
85
- if (
86
- normalized.operation.operation === 'replace' &&
87
- normalized.operation.oldString === normalized.operation.newString
88
- ) {
82
+ const parsed = inputSchema.safeParse(input)
83
+ if (!parsed.success) {
89
84
  return {
90
85
  success: false,
91
86
  output: '',
92
- error: 'old_string/oldStr and new_string/newStr are identical no change needed',
87
+ error: `Invalid edit input: ${parsed.error.issues.map((issue) => issue.message).join('; ')}`,
93
88
  }
94
89
  }
90
+ const canonicalInput = parsed.data
95
91
 
96
- // Sandbox-aware: route through sandbox when available
97
- if (context.sandbox) {
98
- const buffer = await context.sandbox.readFile(input.path)
99
- const content = buffer.toString('utf-8')
100
-
101
- const result = applyEdit(content, normalized.operation)
102
- if (!result.success) {
103
- return { success: false, output: '', error: result.error }
104
- }
105
-
106
- await context.sandbox.writeFile(input.path, result.content)
92
+ const replacement: ExactReplacement = {
93
+ oldString: canonicalInput.old_string,
94
+ newString: canonicalInput.new_string,
95
+ replaceAll: canonicalInput.replace_all,
96
+ }
97
+ if (replacement.oldString === replacement.newString) {
107
98
  return {
108
- success: true,
109
- output: `Edited ${input.path}: ${result.replacements} replacement(s) [sandboxed]`,
110
- data: { path: input.path, replacements: result.replacements, sandboxed: true },
99
+ success: false,
100
+ output: '',
101
+ error: 'old_string and new_string are identical no change needed.',
111
102
  }
112
103
  }
113
104
 
114
- const filePath = resolve(context.workingDirectory, input.path)
115
- const content = await readFile(filePath, 'utf-8')
105
+ const filePath = resolve(context.workingDirectory, canonicalInput.path)
106
+ const lockKey = `${context.sandbox ? 'sandbox' : 'local'}:${filePath}`
107
+
108
+ return withFileMutationLock(lockKey, async () => {
109
+ if (context.sandbox) {
110
+ const buffer = await context.sandbox.readFile(canonicalInput.path)
111
+ const result = applyEdit(buffer.toString('utf-8'), replacement)
112
+ if (!result.success) return { success: false as const, output: '', error: result.error }
113
+
114
+ await context.sandbox.writeFile(canonicalInput.path, result.content)
115
+ return {
116
+ success: true as const,
117
+ output: `Edited ${canonicalInput.path}: ${result.replacements} replacement(s) [sandboxed]`,
118
+ data: {
119
+ path: canonicalInput.path,
120
+ replacements: result.replacements,
121
+ sandboxed: true,
122
+ },
123
+ }
124
+ }
116
125
 
117
- const result = applyEdit(content, normalized.operation)
118
- if (!result.success) {
119
- return { success: false, output: '', error: result.error }
120
- }
126
+ const content = await readFile(filePath, 'utf-8')
127
+ const result = applyEdit(content, replacement)
128
+ if (!result.success) return { success: false as const, output: '', error: result.error }
121
129
 
122
- await writeFile(filePath, result.content, 'utf-8')
123
- return {
124
- success: true,
125
- output: `Edited ${filePath}: ${result.replacements} replacement(s)`,
126
- data: { path: filePath, replacements: result.replacements },
127
- }
130
+ await atomicWriteFile(filePath, result.content)
131
+ return {
132
+ success: true as const,
133
+ output: `Edited ${filePath}: ${result.replacements} replacement(s)`,
134
+ data: { path: filePath, replacements: result.replacements },
135
+ }
136
+ })
128
137
  },
129
138
  })
130
139
 
131
- function normalizeEditInput(
132
- input: EditInput,
133
- ): { success: true; operation: NormalizedEditInput } | { success: false; error: string } {
134
- const newString = input.new_string ?? input.newStr
135
- if (typeof newString !== 'string') {
136
- return { success: false, error: 'Either new_string or newStr is required.' }
137
- }
138
-
139
- if (input.insertLine !== undefined) {
140
- const insertLine = normalizeInsertLine(input.insertLine)
141
- if (!insertLine.success) return insertLine
140
+ function normalizeLineEndings(content: string, input: ExactReplacement): ExactReplacement {
141
+ const withoutCrlf = content.replaceAll('\r\n', '')
142
+ const usesOnlyCrlf = content.includes('\r\n') && !withoutCrlf.includes('\n')
143
+ if (usesOnlyCrlf) {
142
144
  return {
143
- success: true,
144
- operation: {
145
- operation: 'insert',
146
- insertLine: insertLine.value,
147
- newString,
148
- replace_all: input.replace_all,
149
- },
145
+ ...input,
146
+ oldString: content.includes(input.oldString)
147
+ ? input.oldString
148
+ : input.oldString.replaceAll('\r\n', '\n').replaceAll('\n', '\r\n'),
149
+ newString: input.newString.replaceAll('\r\n', '\n').replaceAll('\n', '\r\n'),
150
150
  }
151
151
  }
152
152
 
153
- const oldString = input.old_string ?? input.oldStr
154
- if (typeof oldString !== 'string') {
155
- return { success: false, error: 'Either old_string/oldStr or insertLine is required.' }
156
- }
157
- return {
158
- success: true,
159
- operation: {
160
- operation: 'replace',
161
- oldString,
162
- newString,
163
- replace_all: input.replace_all,
164
- },
165
- }
166
- }
167
-
168
- function normalizeInsertLine(
169
- value: string | number,
170
- ): { success: true; value: number | 'end' } | { success: false; error: string } {
171
- if (typeof value === 'string') {
172
- if (value.trim().toLowerCase() === 'end') return { success: true, value: 'end' }
173
- const parsed = Number(value)
174
- if (Number.isInteger(parsed) && parsed >= 0) return { success: true, value: parsed }
153
+ const usesOnlyLf = content.includes('\n') && !content.includes('\r\n')
154
+ if (usesOnlyLf) {
175
155
  return {
176
- success: false,
177
- error: 'insertLine must be a non-negative line number or "end".',
156
+ ...input,
157
+ oldString: content.includes(input.oldString)
158
+ ? input.oldString
159
+ : input.oldString.replaceAll('\r\n', '\n'),
160
+ newString: input.newString.replaceAll('\r\n', '\n'),
178
161
  }
179
162
  }
180
- return { success: true, value }
163
+ return input
181
164
  }
182
165
 
183
166
  function applyEdit(
184
167
  content: string,
185
- input: NormalizedEditInput,
168
+ rawInput: ExactReplacement,
186
169
  ): { success: true; content: string; replacements: number } | { success: false; error: string } {
187
- if (input.operation === 'insert') {
188
- return applyLineInsert(content, input)
189
- }
190
-
170
+ const input = normalizeLineEndings(content, rawInput)
191
171
  if (!content.includes(input.oldString)) {
192
172
  return {
193
173
  success: false,
194
174
  error:
195
- 'old_string/oldStr not found in file. Make sure the string matches exactly, including whitespace and indentation.',
175
+ 'old_string was not found. Read the file again and copy exact text without line-number prefixes.',
196
176
  }
197
177
  }
198
178
 
199
- if (input.replace_all) {
179
+ if (input.replaceAll) {
200
180
  const parts = content.split(input.oldString)
201
- const replacements = parts.length - 1
202
181
  return {
203
182
  success: true,
204
183
  content: parts.join(input.newString),
205
- replacements,
184
+ replacements: parts.length - 1,
206
185
  }
207
186
  }
208
187
 
209
- // Uniqueness check: old_string/oldStr must appear exactly once
210
188
  const firstIndex = content.indexOf(input.oldString)
211
- const secondIndex = content.indexOf(input.oldString, firstIndex + 1)
212
-
189
+ const secondIndex = content.indexOf(input.oldString, firstIndex + input.oldString.length)
213
190
  if (secondIndex !== -1) {
214
- const lineNumber = content.slice(0, firstIndex).split('\n').length
191
+ const firstLine = content.slice(0, firstIndex).split('\n').length
215
192
  const secondLine = content.slice(0, secondIndex).split('\n').length
216
193
  return {
217
194
  success: false,
218
- error: `old_string/oldStr is not unique — found at lines ${lineNumber} and ${secondLine}. Provide more surrounding context to make it unique, or use replace_all: true.`,
195
+ error: `old_string is not unique — found at lines ${firstLine} and ${secondLine}. Include more surrounding context, or use replace_all: true.`,
219
196
  }
220
197
  }
221
198
 
@@ -228,26 +205,3 @@ function applyEdit(
228
205
  replacements: 1,
229
206
  }
230
207
  }
231
-
232
- function applyLineInsert(
233
- content: string,
234
- input: Extract<NormalizedEditInput, { operation: 'insert' }>,
235
- ): { success: true; content: string; replacements: number } {
236
- const hasTrailingNewline = content.endsWith('\n')
237
- const lines = content.split('\n')
238
- if (hasTrailingNewline) lines.pop()
239
-
240
- const line =
241
- input.insertLine === 'end'
242
- ? lines.length
243
- : Math.min(Math.max(input.insertLine, 0), lines.length)
244
- const inserted = input.newString.endsWith('\n')
245
- ? input.newString.slice(0, -1).split('\n')
246
- : input.newString.split('\n')
247
- lines.splice(line, 0, ...inserted)
248
- return {
249
- success: true,
250
- content: `${lines.join('\n')}${hasTrailingNewline ? '\n' : ''}`,
251
- replacements: 1,
252
- }
253
- }
@@ -0,0 +1,27 @@
1
+ const mutationTails = new Map<string, Promise<void>>()
2
+
3
+ /**
4
+ * Serialize read-modify-write filesystem operations by resolved path within
5
+ * one SDK process. Distributed hosts must still assign one writer per file or
6
+ * provide a storage-level compare-and-swap primitive.
7
+ */
8
+ export async function withFileMutationLock<T>(
9
+ key: string,
10
+ operation: () => Promise<T>,
11
+ ): Promise<T> {
12
+ const previous = mutationTails.get(key) ?? Promise.resolve()
13
+ let release = () => {}
14
+ const current = new Promise<void>((resolve) => {
15
+ release = resolve
16
+ })
17
+ const tail = previous.then(() => current)
18
+ mutationTails.set(key, tail)
19
+
20
+ await previous
21
+ try {
22
+ return await operation()
23
+ } finally {
24
+ release()
25
+ if (mutationTails.get(key) === tail) mutationTails.delete(key)
26
+ }
27
+ }
@@ -26,8 +26,8 @@ import { WriteFileTool } from './write-file.js'
26
26
  // `code.claude.com/docs/en/tools-reference`) does NOT include `LS` —
27
27
  // directory listing is canonical `Bash` + `Glob`. `search_tools` has no
28
28
  // Claude analogue at all. Including these in the defaults gives the model
29
- // tools that look right but degrade alignment. File extension is canonical
30
- // `edit` with `insertLine: "end"` the legacy `Append` tool is gone.
29
+ // tools that look right but degrade alignment. File extension uses canonical
30
+ // exact replacement against a unique tail or deterministic marker.
31
31
  // Hosts that genuinely want LS/search can still register them explicitly.
32
32
 
33
33
  export function getBuiltinTools(): ToolDefinition[] {
@@ -1,41 +1,53 @@
1
- import { access, mkdir, writeFile } from 'node:fs/promises'
2
- import { dirname, resolve } from 'node:path'
1
+ import { access } from 'node:fs/promises'
2
+ import { resolve } from 'node:path'
3
3
  import { z } from 'zod'
4
4
  import type { ToolContext } from '../../types/tool/index.js'
5
5
  import { defineTool } from '../defineTool.js'
6
+ import { atomicWriteFile } from './atomic-write-file.js'
7
+ import { withFileMutationLock } from './file-mutation-lock.js'
6
8
 
7
9
  const inputSchema = z
8
10
  .object({
9
11
  path: z
10
12
  .string()
11
- .min(1)
13
+ .refine((value) => value.trim().length > 0, 'Path must not be empty.')
12
14
  .describe(
13
- 'Relative path to the file to write (e.g. "outputs/report.md"). Required. Must be a non-empty string.',
15
+ 'Relative path to the file to write (e.g. "outputs/report.md"). Must not be empty.',
14
16
  ),
15
17
  content: z
16
18
  .string()
17
- .optional()
18
19
  .describe(
19
- 'Full file body to write. Required (use "" only for an intentionally empty file). The file is fully overwritten — pass the COMPLETE intended content for this bounded chunk, not a diff. Self-budget content under 12000 characters before calling; if the intended body is longer, write a smaller opening section here, then use `edit` with insertLine: "end" to extend the file section by section. Do NOT try to chain multiple `write` calls, since each one overwrites the previous.',
20
- ),
21
- newStr: z
22
- .string()
23
- .optional()
24
- .describe(
25
- 'Alias for content. Useful for hosts that expose create/write operations as newStr. Self-budget this payload under 12000 characters before calling.',
20
+ 'Complete bounded file body. Use "" only for an intentionally empty file. Keep under 12000 characters. For longer documents, include a deterministic marker such as {{CHUNK_001}} and advance it with exact edit calls.',
26
21
  ),
27
22
  })
28
- .refine((value) => typeof value.content === 'string' || typeof value.newStr === 'string', {
29
- message: 'Either content or newStr is required.',
30
- })
23
+ .strict()
31
24
 
32
25
  type WriteInput = z.infer<typeof inputSchema>
33
26
 
27
+ const modelInputSchema: Record<string, unknown> = {
28
+ type: 'object',
29
+ properties: {
30
+ path: {
31
+ type: 'string',
32
+ description: 'Non-empty path to the file to write.',
33
+ },
34
+ content: {
35
+ type: 'string',
36
+ description: 'Complete bounded file body. May be empty only for an intentionally empty file.',
37
+ },
38
+ },
39
+ required: ['path', 'content'],
40
+ additionalProperties: false,
41
+ }
42
+
34
43
  export const WriteFileTool = defineTool({
35
44
  name: 'write',
36
45
  description:
37
- 'Writes a file to the local filesystem. Overwrites the existing file at the path if there is one.\n\n- If the file already exists, you must use the `read` tool on it first in this conversation, or this call will fail.\n- Prefer the `edit` tool for modifying existing files — it only sends the diff and preserves the rest of the file byte-for-byte.\n- Use `write` to create a new file or to perform a deliberate full rewrite of a file you have already read.\n- Self-budget content/newStr under 12000 characters before emitting the tool call. For long content, write a smaller opening section, then use `edit` with insertLine: "end" to extend the file section by section. Do not chain multiple `write` calls each one overwrites the previous.',
46
+ 'Writes a complete bounded file body. Pass exactly path + content. If the file exists, read it first; prefer edit for targeted changes. Self-budget content under 12000 characters. For longer documents, write an opening with a deterministic marker such as {{CHUNK_001}}, then use exact edit calls that advance the marker one chunk at a time. Do not chain write calls because each overwrites the file.',
38
47
  inputSchema,
48
+ modelInputSchema,
49
+ enforceModelInput: true,
50
+ validationErrorHint: 'Required shape: {"path":"file.md","content":"complete bounded file body"}.',
39
51
  category: 'filesystem',
40
52
  permissions: ['file_write'],
41
53
  readOnly: false,
@@ -43,40 +55,52 @@ export const WriteFileTool = defineTool({
43
55
  concurrencySafe: false,
44
56
 
45
57
  async execute(input: WriteInput, context) {
46
- const content = input.content ?? input.newStr ?? ''
47
- // Sandbox-aware: route through sandbox.writeFile() when available
48
- if (context.sandbox) {
49
- const sandboxExists = await sandboxFileExists(context, input.path)
50
- if (sandboxExists) {
51
- const guard = enforceReadBeforeOverwrite(context, input.path)
52
- if (guard) return guard
53
- }
54
- await context.sandbox.writeFile(input.path, content)
55
- context.fileReadTracker?.recordRead(input.path)
58
+ const parsed = inputSchema.safeParse(input)
59
+ if (!parsed.success) {
56
60
  return {
57
- success: true,
58
- output: `File written successfully: ${input.path} (${content.length} chars) [sandboxed]`,
59
- data: { path: input.path, size: content.length, sandboxed: true },
61
+ success: false,
62
+ output: '',
63
+ error: `Invalid write input: ${parsed.error.issues.map((issue) => issue.message).join('; ')}`,
60
64
  }
61
65
  }
66
+ const canonicalInput = parsed.data
62
67
 
63
- const filePath = resolve(context.workingDirectory, input.path)
64
-
65
- const localExists = await pathExists(filePath)
66
- if (localExists) {
67
- const guard = enforceReadBeforeOverwrite(context, filePath)
68
- if (guard) return guard
69
- }
68
+ const filePath = resolve(context.workingDirectory, canonicalInput.path)
69
+ const lockKey = `${context.sandbox ? 'sandbox' : 'local'}:${filePath}`
70
+ return withFileMutationLock(lockKey, async () => {
71
+ if (context.sandbox) {
72
+ const sandboxExists = await sandboxFileExists(context, canonicalInput.path)
73
+ if (sandboxExists) {
74
+ const guard = enforceReadBeforeOverwrite(context, canonicalInput.path)
75
+ if (guard) return guard
76
+ }
77
+ await context.sandbox.writeFile(canonicalInput.path, canonicalInput.content)
78
+ context.fileReadTracker?.recordRead(canonicalInput.path)
79
+ return {
80
+ success: true as const,
81
+ output: `File written successfully: ${canonicalInput.path} (${canonicalInput.content.length} chars) [sandboxed]`,
82
+ data: {
83
+ path: canonicalInput.path,
84
+ size: canonicalInput.content.length,
85
+ sandboxed: true,
86
+ },
87
+ }
88
+ }
70
89
 
71
- await mkdir(dirname(filePath), { recursive: true })
72
- await writeFile(filePath, content, 'utf-8')
73
- context.fileReadTracker?.recordRead(filePath)
90
+ const localExists = await pathExists(filePath)
91
+ if (localExists) {
92
+ const guard = enforceReadBeforeOverwrite(context, filePath)
93
+ if (guard) return guard
94
+ }
74
95
 
75
- return {
76
- success: true,
77
- output: `File written successfully: ${filePath} (${content.length} chars)`,
78
- data: { path: filePath, size: content.length },
79
- }
96
+ await atomicWriteFile(filePath, canonicalInput.content)
97
+ context.fileReadTracker?.recordRead(filePath)
98
+ return {
99
+ success: true as const,
100
+ output: `File written successfully: ${filePath} (${canonicalInput.content.length} chars)`,
101
+ data: { path: filePath, size: canonicalInput.content.length },
102
+ }
103
+ })
80
104
  },
81
105
  })
82
106
 
@@ -179,4 +179,20 @@ describe('coordinator agent_task_list tool', () => {
179
179
  expect(names).toContain('agent_task_list')
180
180
  expect(names).not.toContain('task_list')
181
181
  })
182
+
183
+ it('does not advertise per-task cancellation on the blocking coordinator surface', () => {
184
+ const coordinatorTools = buildCoordinatorTools({
185
+ gateway: gatewayWith([]),
186
+ workingDirectory: '/tmp/test',
187
+ allowedAgentIds: ['solution-architecture'],
188
+ })
189
+ const names = coordinatorTools.map((tool) => tool.name)
190
+
191
+ // create_task returns only after the worker is terminal, so the
192
+ // supervisor cannot know a live task id in a later model turn.
193
+ // Keeping cancel_task here only manufactured success for missing
194
+ // or terminal ids because every gateway cancellation is a void
195
+ // no-op in those states.
196
+ expect(names).not.toContain('cancel_task')
197
+ })
182
198
  })
@@ -19,14 +19,12 @@ import type { TaskLaunchedCallback } from './index.js'
19
19
  * subagent tool calls are isolated — only the summary surfaces to
20
20
  * the parent.
21
21
  *
22
- * This is **NOT** the same shape as the legacy `create_task` /
23
- * `continue_task` / `cancel_task` trio that this package ships
24
- * alongside it: those are non-blocking and use a `<task-notification>`
25
- * callback model. The async pattern is useful for hosts that want a
26
- * work-queue surface, but it is not what Claude Code trained against.
27
- * For free agentic alignment, prefer the canonical `Agent` tool; keep
28
- * the legacy coordinator tools only when you genuinely need
29
- * fire-and-forget multi-task fan-out.
22
+ * This is **NOT** the same shape as the coordinator `create_task`
23
+ * tool that this package ships alongside it. That tool exposes
24
+ * Namzu's task IDs and optional planning integration, while still
25
+ * returning results synchronously. For free agentic alignment,
26
+ * prefer the canonical `Agent` tool; use the coordinator tool when
27
+ * the host needs task tracking.
30
28
  */
31
29
  export interface AgentToolOptions {
32
30
  gateway: TaskGateway