@zq-silk/yui 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/README.md +4 -0
  2. package/dist/cli/commandCatalog.js +24 -3
  3. package/dist/cli/commandDiscovery.js +4 -1
  4. package/dist/cli.js +23 -3078
  5. package/dist/commands/taskCommands.js +79 -21
  6. package/dist/commands/taskIntegrationCommands.js +3 -1
  7. package/dist/commands/taskUpstreamCommands.js +3 -1
  8. package/dist/context/runContextPack.js +132 -5
  9. package/dist/context/sourceRunContext.js +4 -2
  10. package/dist/context/taskContext.js +6 -1
  11. package/dist/controlPlaneCli.js +3092 -0
  12. package/dist/controller/fileSchedulerStoreAdapter.js +19 -6
  13. package/dist/controller/jobControl.js +21 -0
  14. package/dist/executor/agentExecutor.js +1 -1
  15. package/dist/executor/effectiveLaunch.js +18 -5
  16. package/dist/executor/fileRoleLaunchPlanner.js +15 -19
  17. package/dist/integration/gitIntegrationService.js +50 -15
  18. package/dist/message/messageContinuation.js +7 -2
  19. package/dist/nativeAgent/agent.js +176 -74
  20. package/dist/nativeAgent/cliDemo.js +37 -0
  21. package/dist/nativeAgent/codingTools.js +11 -0
  22. package/dist/nativeAgent/commandTool.js +215 -0
  23. package/dist/nativeAgent/compactionDemo.js +158 -0
  24. package/dist/nativeAgent/composition.js +71 -0
  25. package/dist/nativeAgent/context/budget.js +44 -0
  26. package/dist/nativeAgent/context/index.js +339 -0
  27. package/dist/nativeAgent/context/providerCompressor.js +103 -0
  28. package/dist/nativeAgent/demo.js +12 -1
  29. package/dist/nativeAgent/evaluation/cases.js +38 -0
  30. package/dist/nativeAgent/evaluation/checks.js +91 -0
  31. package/dist/nativeAgent/evaluation/demo.js +19 -0
  32. package/dist/nativeAgent/evaluation/files.js +54 -0
  33. package/dist/nativeAgent/evaluation/fixture.js +36 -0
  34. package/dist/nativeAgent/evaluation/index.js +239 -0
  35. package/dist/nativeAgent/executionOwner.js +209 -0
  36. package/dist/nativeAgent/filePatterns.js +170 -0
  37. package/dist/nativeAgent/fileToolsSupport.js +202 -0
  38. package/dist/nativeAgent/index.js +13 -0
  39. package/dist/nativeAgent/interaction/cli.js +358 -0
  40. package/dist/nativeAgent/interaction/contracts.js +1 -0
  41. package/dist/nativeAgent/interaction/index.js +3 -0
  42. package/dist/nativeAgent/interaction/memoryDemo.js +115 -0
  43. package/dist/nativeAgent/interaction/renderer.js +34 -0
  44. package/dist/nativeAgent/localSafety.js +258 -0
  45. package/dist/nativeAgent/model/anthropicMessages.js +204 -0
  46. package/dist/nativeAgent/model/chatCompletions.js +210 -0
  47. package/dist/nativeAgent/model/errors.js +47 -0
  48. package/dist/nativeAgent/model/gateway.js +423 -0
  49. package/dist/nativeAgent/model/index.js +7 -0
  50. package/dist/nativeAgent/model/observationAdapter.js +21 -0
  51. package/dist/nativeAgent/model/protocols.js +19 -0
  52. package/dist/nativeAgent/model/responses.js +263 -0
  53. package/dist/nativeAgent/model/types.js +1 -0
  54. package/dist/nativeAgent/model/wire.js +73 -0
  55. package/dist/nativeAgent/observability/index.js +220 -0
  56. package/dist/nativeAgent/product/catalog.js +82 -0
  57. package/dist/nativeAgent/product/config.js +295 -0
  58. package/dist/nativeAgent/product/facts.js +30 -0
  59. package/dist/nativeAgent/product/index.js +62 -0
  60. package/dist/nativeAgent/product/location.js +44 -0
  61. package/dist/nativeAgent/product/runtime.js +276 -0
  62. package/dist/nativeAgent/product/storage.js +49 -0
  63. package/dist/nativeAgent/product/tools.js +47 -0
  64. package/dist/nativeAgent/product/transport.js +54 -0
  65. package/dist/nativeAgent/projectGuidance/index.js +425 -0
  66. package/dist/nativeAgent/searchTools.js +305 -0
  67. package/dist/nativeAgent/session/backends.js +293 -0
  68. package/dist/nativeAgent/session/catalog.js +97 -0
  69. package/dist/nativeAgent/session/catalogDemo.js +87 -0
  70. package/dist/nativeAgent/session/contracts.js +1 -0
  71. package/dist/nativeAgent/session/format.js +269 -0
  72. package/dist/nativeAgent/session/index.js +5 -0
  73. package/dist/nativeAgent/session/location.js +36 -0
  74. package/dist/nativeAgent/session/sqliteFormat.js +134 -0
  75. package/dist/nativeAgent/session/store.js +248 -0
  76. package/dist/nativeAgent/textTools.js +270 -133
  77. package/dist/nativeAgent/toolManager/executor.js +290 -0
  78. package/dist/nativeAgent/toolManager/index.js +4 -0
  79. package/dist/nativeAgent/validation.js +2 -2
  80. package/dist/task/taskAuthority.js +56 -0
  81. package/dist/web/assets/client/app.js +64 -3
  82. package/dist/web/assets/client/detail.js +6 -6
  83. package/dist/web/assets/client/i18n.js +4 -0
  84. package/dist/web/assets/client/overview.js +12 -9
  85. package/dist/web/assets/client/sidebar.js +31 -2
  86. package/dist/web/assets/shell.js +2 -1
  87. package/dist/web/assets/styles/components.js +2 -0
  88. package/dist/web/assets/styles/layout.js +11 -4
  89. package/dist/web/assets/styles/responsive.js +12 -4
  90. package/dist/web/assets/styles/views.js +16 -13
  91. package/docs/agent-result-consumption.md +16 -0
  92. package/docs/agent-result-consumption.zh-CN.md +13 -0
  93. package/docs/examples/agent-offline.mjs +194 -0
  94. package/docs/native-agent.md +283 -0
  95. package/docs/release-workflow.md +47 -9
  96. package/docs/release-workflow.zh-CN.md +36 -6
  97. package/docs/roles-and-configuration.md +32 -0
  98. package/docs/roles-and-configuration.zh-CN.md +26 -0
  99. package/package.json +1 -1
  100. package/skills/yui-leader/SKILL.md +9 -0
  101. package/skills/yui-reviewer/SKILL.md +5 -0
  102. package/skills/yui-runtime/SKILL.md +7 -0
@@ -1,156 +1,293 @@
1
- import { constants, realpathSync, statSync } from 'node:fs';
2
- import { lstat, open, rename, unlink } from 'node:fs/promises';
1
+ import { open, rename, unlink } from 'node:fs/promises';
3
2
  import path from 'node:path';
4
3
  import { randomUUID } from 'node:crypto';
5
- class FileFault extends Error {
6
- code;
7
- constructor(code, message) {
8
- super(message);
9
- this.code = code;
10
- }
11
- }
4
+ import { FileFault, bounded, workspaceRoot, validPath, checkPath, readSnapshot, digest, fits, cursor, readCursor, replace, diff, assertText } from './fileToolsSupport.js';
12
5
  const fail = (code, message) => ({ code, message, effect: 'none' });
13
- /** Controlled local directories only. Path checks are NOT an adversarial sandbox. */
14
- export function createTextTools({ root, maxBytes = 64 * 1024 }) {
15
- if (!path.isAbsolute(root))
16
- throw new Error('An explicit absolute workspace root is required');
17
- const workspace = realpathSync(root);
18
- if (!statSync(workspace).isDirectory())
19
- throw new Error('Workspace must be a directory');
20
- if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > 64 * 1024) {
21
- throw new Error('maxBytes must be an integer in [1, 65536]');
22
- }
23
- const validate = (args, write) => {
24
- if (!args || typeof args !== 'object' || Array.isArray(args)
25
- || typeof args.path !== 'string' || !args.path.trim()
26
- || Object.keys(args).some(k => k !== 'path' && !(write && k === 'content'))
27
- || (write && typeof args.content !== 'string'))
28
- return fail('invalid_arguments', 'Expected path and, for write, content');
29
- if (args.path.includes('\0') || path.isAbsolute(args.path) || args.path.split(/[\\/]/).includes('..')) {
30
- return fail('path_out_of_scope', 'Expected a relative path without parent traversal');
31
- }
32
- if (write && Buffer.byteLength(args.content) > maxBytes)
33
- return fail('too_large', 'Text exceeds byte limit');
34
- return null;
35
- };
36
- const check = async (relative, allowMissing) => {
37
- const target = path.resolve(workspace, relative);
38
- const rel = path.relative(workspace, target);
39
- if (!rel || rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
40
- throw new FileFault('path_out_of_scope', 'Path is outside the file workspace');
6
+ const object = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
7
+ /** Controlled directories only. Queues are same-pack, not cross-process CAS or a multi-file transaction. */
8
+ export function createTextTools(options) {
9
+ const root = workspaceRoot(options.root);
10
+ const maxBytes = bounded(options.maxBytes, 8 * 1024 * 1024, 'maxBytes');
11
+ const maxOutputBytes = bounded(options.maxOutputBytes, 64 * 1024, 'maxOutputBytes');
12
+ const writers = new Map();
13
+ const targetsFor = (args, name) => name === 'patch'
14
+ ? args.files : name === 'edit' ? [{
15
+ path: args.path, expectedSha256: args.expectedSha256,
16
+ edits: [{ oldText: args.oldText, newText: args.newText }],
17
+ }] : [args];
18
+ const validate = (args, name) => {
19
+ if (!object(args))
20
+ return fail('invalid_arguments', 'Expected an argument object');
21
+ const keys = name === 'patch' ? ['files'] : name === 'read' ? ['path', 'startLine', 'limit', 'cursor']
22
+ : name === 'edit' ? ['path', 'expectedSha256', 'oldText', 'newText'] : ['path', 'content', 'expectedSha256'];
23
+ if (Object.keys(args).some(key => !keys.includes(key)))
24
+ return fail('invalid_arguments', 'Unexpected tool fields');
25
+ if (Buffer.byteLength(JSON.stringify(args)) > 64 * 1024)
26
+ return fail('too_large', 'Arguments exceed 64 KiB');
27
+ if (name === 'read') {
28
+ if (!validPath(args.path))
29
+ return fail(typeof args.path === 'string' && (path.isAbsolute(args.path)
30
+ || args.path.split(/[\\/]/).includes('..')) ? 'path_out_of_scope' : 'invalid_arguments', 'Expected a bounded relative file path');
31
+ if (['startLine', 'limit'].some(key => args[key] !== undefined && (typeof args[key] !== 'number'
32
+ || !Number.isSafeInteger(args[key]) || args[key] < 1 || args[key] > 1_000_000))
33
+ || (args.cursor !== undefined && (typeof args.cursor !== 'string' || !args.cursor || args.cursor.length > 2048))) {
34
+ return fail('invalid_arguments', 'Expected positive startLine/limit and an optional cursor');
35
+ }
36
+ return null;
41
37
  }
42
- const parts = rel.split(path.sep);
43
- let current = workspace;
44
- for (let i = 0; i < parts.length; i++) {
45
- current = path.join(current, parts[i]);
46
- const final = i === parts.length - 1;
47
- let stat;
48
- try {
49
- stat = await lstat(current);
38
+ if (name === 'patch' && (!Array.isArray(args.files) || !args.files.length || args.files.length > 16
39
+ || !args.files.every(object)))
40
+ return fail('invalid_arguments', 'patch expects 1..16 existing files');
41
+ const targets = targetsFor(args, name);
42
+ for (const target of targets) {
43
+ if (!validPath(target.path))
44
+ return fail(typeof target.path === 'string' && (path.isAbsolute(target.path)
45
+ || target.path.split(/[\\/]/).includes('..')) ? 'path_out_of_scope' : 'invalid_arguments', 'Expected bounded relative file paths');
46
+ if (name === 'patch' && Object.keys(target).some(key => !['path', 'expectedSha256', 'edits'].includes(key))) {
47
+ return fail('invalid_arguments', 'Unexpected patch file fields');
50
48
  }
51
- catch (error) {
52
- if (error.code === 'ENOENT' && final && allowMissing)
53
- return target;
54
- throw error;
49
+ if (target.expectedSha256 !== undefined && (typeof target.expectedSha256 !== 'string'
50
+ || !/^[a-f0-9]{64}$/.test(target.expectedSha256)))
51
+ return fail('invalid_arguments', 'Expected lowercase SHA-256');
52
+ if (name === 'write') {
53
+ if (typeof target.content !== 'string')
54
+ return fail('invalid_arguments', 'Expected content');
55
+ if (Buffer.byteLength(target.content) > maxBytes)
56
+ return fail('too_large', 'Text exceeds byte limit');
55
57
  }
56
- if (stat.isSymbolicLink())
57
- throw new FileFault('symlink_denied', 'Symlinks are not allowed');
58
- if (final ? !stat.isFile() || stat.nlink > 1 : !stat.isDirectory()) {
59
- throw new FileFault('not_regular_file', 'Expected ordinary directories and a single-link regular file');
58
+ else {
59
+ if (typeof target.expectedSha256 !== 'string' || !Array.isArray(target.edits) || !target.edits.length
60
+ || target.edits.length > 32 || target.edits.some(edit => !object(edit)
61
+ || Object.keys(edit).some(key => !['oldText', 'newText'].includes(key))
62
+ || typeof edit.oldText !== 'string' || !edit.oldText || typeof edit.newText !== 'string')) {
63
+ return fail('invalid_arguments', 'Expected 1..32 exact edits and a SHA-256 precondition');
64
+ }
65
+ if (target.edits.some(edit => [edit.oldText, edit.newText].some(text => Buffer.byteLength(text) > maxBytes))) {
66
+ return fail('too_large', 'Text exceeds byte limit');
67
+ }
60
68
  }
61
69
  }
62
- return target;
70
+ return null;
63
71
  };
64
- return ['read', 'write'].map(name => {
65
- const writing = name === 'write';
66
- return {
67
- definition: {
68
- name,
69
- description: writing ? 'Replace a workspace UTF-8 text file' : 'Read a bounded workspace UTF-8 text file',
70
- inputSchema: { type: 'object', properties: { path: { type: 'string' },
71
- ...(writing ? { content: { type: 'string' } } : {}) },
72
- required: writing ? ['path', 'content'] : ['path'], additionalProperties: false },
73
- },
74
- validate: (args) => validate(args, writing),
75
- async execute(args, _scope, signal) {
76
- const invalid = validate(args, writing);
77
- if (invalid)
78
- return { ok: false, error: invalid };
79
- const { path: relative, content } = args;
80
- let temporary;
81
- let committed = false;
82
- let result;
83
- try {
84
- signal.throwIfAborted();
85
- const target = await check(relative, writing);
86
- if (!writing) {
87
- const file = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
88
- try {
89
- const stat = await file.stat();
90
- if (!stat.isFile() || stat.nlink > 1)
91
- throw new FileFault('not_regular_file', 'Expected single-link regular file');
92
- if (stat.size > maxBytes)
93
- throw new FileFault('too_large', 'File exceeds byte limit');
94
- const buffer = Buffer.alloc(maxBytes + 1);
95
- let bytes = 0;
96
- while (bytes < buffer.length) {
97
- signal.throwIfAborted();
98
- const read = await file.read(buffer, bytes, buffer.length - bytes, bytes);
99
- if (!read.bytesRead)
100
- break;
101
- bytes += read.bytesRead;
102
- }
103
- if (bytes > maxBytes)
104
- throw new FileFault('too_large', 'File exceeds byte limit');
105
- let text;
106
- try {
107
- text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(buffer.subarray(0, bytes));
108
- }
109
- catch {
110
- throw new FileFault('invalid_utf8', 'File is not valid UTF-8');
111
- }
112
- result = { ok: true, content: JSON.stringify({ path: relative, text, bytes }) };
113
- }
114
- finally {
115
- await file.close();
72
+ const editSchema = { type: 'object', properties: { oldText: { type: 'string' }, newText: { type: 'string' } },
73
+ required: ['oldText', 'newText'], additionalProperties: false };
74
+ return ['read', 'write', 'edit', 'patch'].map(name => ({
75
+ definition: {
76
+ name,
77
+ description: name === 'read' ? 'Read UTF-8 pages with full-file SHA-256, exact byte offsets and request-bound cursor'
78
+ : name === 'patch' ? 'Unique nonoverlapping original-text edits to existing files; preflight all, sequential commits, bounded actual diffs; partial failures stop'
79
+ : name === 'edit' ? 'Replace one unique literal match with expected SHA-256 and actual diff'
80
+ : 'Create or replace UTF-8 text with SHA-256 precondition and actual diff',
81
+ inputSchema: { type: 'object', properties: name === 'patch' ? { files: { type: 'array', minItems: 1, maxItems: 16,
82
+ items: { type: 'object', properties: { path: { type: 'string' }, expectedSha256: { type: 'string' },
83
+ edits: { type: 'array', minItems: 1, maxItems: 32, items: editSchema } },
84
+ required: ['path', 'expectedSha256', 'edits'], additionalProperties: false } } }
85
+ : { path: { type: 'string' }, ...(name === 'read' ? { startLine: { type: 'integer' },
86
+ limit: { type: 'integer' }, cursor: { type: 'string' } } : { expectedSha256: { type: 'string' },
87
+ ...(name === 'edit' ? editSchema.properties : { content: { type: 'string' } }) }) },
88
+ required: name === 'patch' ? ['files'] : name === 'read' ? ['path'] : name === 'edit'
89
+ ? ['path', 'expectedSha256', 'oldText', 'newText'] : ['path', 'content'], additionalProperties: false },
90
+ },
91
+ validate: args => validate(args, name),
92
+ async execute(args, scope, signal) {
93
+ const invalid = validate(args, name);
94
+ if (invalid)
95
+ return { ok: false, error: invalid };
96
+ const outcome = (value) => ({ ok: true, content: JSON.stringify(value) });
97
+ const fit = (result) => fits(result, maxOutputBytes, name, scope.toolCallId);
98
+ const readText = async (target) => {
99
+ const snapshot = await readSnapshot(target, maxBytes, signal);
100
+ assertText(snapshot.text);
101
+ return snapshot;
102
+ };
103
+ const held = [];
104
+ const receipts = [];
105
+ let temporary;
106
+ let committing = false;
107
+ let index = 0;
108
+ try {
109
+ signal.throwIfAborted();
110
+ if (name === 'read') {
111
+ const request = args;
112
+ const snapshot = await readText(await checkPath(root, request.path));
113
+ const binding = digest(JSON.stringify([request.path, request.startLine ?? 1, request.limit ?? 200, maxBytes, maxOutputBytes]));
114
+ const fingerprint = snapshot.sha256 + ':' + snapshot.identity;
115
+ let start = readCursor(request.cursor, binding, fingerprint, snapshot.text.length);
116
+ if (!request.cursor) {
117
+ for (let line = 1; line < (request.startLine ?? 1); line++) {
118
+ const offset = snapshot.text.indexOf('\n', start);
119
+ if (offset < 0)
120
+ throw new FileFault('line_out_of_range', 'startLine is beyond EOF');
121
+ start = offset + 1;
116
122
  }
117
123
  }
118
- else {
119
- const candidate = path.join(path.dirname(target), `.agent-${randomUUID()}.tmp`);
120
- const file = await open(candidate, 'wx', 0o600);
121
- temporary = candidate;
122
- try {
123
- await file.writeFile(content, { encoding: 'utf8', signal });
124
- }
125
- finally {
126
- await file.close();
124
+ if (start > 0 && /[\uDC00-\uDFFF]/.test(snapshot.text[start] ?? ''))
125
+ throw new FileFault('invalid_cursor', 'Cursor splits a code point');
126
+ let end = start;
127
+ for (let count = 0; count < (request.limit ?? 200) && end < snapshot.text.length; count++) {
128
+ const offset = snapshot.text.indexOf('\n', end);
129
+ end = offset < 0 ? snapshot.text.length : offset + 1;
130
+ }
131
+ const requestedEnd = end;
132
+ const lineAt = (offset) => {
133
+ let line = 1;
134
+ for (let i = 0; i < offset; i++)
135
+ if (snapshot.text[i] === '\n')
136
+ line++;
137
+ return line;
138
+ };
139
+ const make = (stop) => outcome({ path: request.path, text: snapshot.text.slice(start, stop),
140
+ bytes: Buffer.byteLength(snapshot.text.slice(start, stop)), fileBytes: snapshot.bytes, sha256: snapshot.sha256,
141
+ byteStart: Buffer.byteLength(snapshot.text.slice(0, start)), byteEnd: Buffer.byteLength(snapshot.text.slice(0, stop)),
142
+ startLine: lineAt(start), endLine: lineAt(Math.max(start, stop - 1)),
143
+ complete: stop === snapshot.text.length, nextCursor: stop === snapshot.text.length ? null : cursor(binding, fingerprint, stop),
144
+ truncationReason: stop < requestedEnd ? 'output_bytes' : stop < snapshot.text.length ? 'line_limit' : null });
145
+ if (!fit(make(end))) {
146
+ let low = start;
147
+ let high = end;
148
+ while (low < high) {
149
+ let middle = Math.ceil((low + high) / 2);
150
+ if (/[\uDC00-\uDFFF]/.test(snapshot.text[middle] ?? ''))
151
+ middle--;
152
+ if (middle <= low)
153
+ break;
154
+ if (fit(make(middle)))
155
+ low = middle;
156
+ else
157
+ high = middle - 1;
127
158
  }
128
- await check(relative, true);
129
- signal.throwIfAborted();
130
- await rename(temporary, target);
131
- committed = true;
132
- temporary = undefined;
133
- result = { ok: true, content: JSON.stringify({ path: relative, bytes: Buffer.byteLength(content) }) };
159
+ end = low;
160
+ }
161
+ if ((end === start && start < snapshot.text.length) || !fit(make(end))) {
162
+ throw new FileFault('output_limit', 'Budget cannot hold one code point and metadata');
134
163
  }
164
+ return make(end);
135
165
  }
136
- catch (error) {
137
- const code = error instanceof FileFault ? error.code
138
- : signal.aborted ? 'cancelled_before_commit'
139
- : error.code === 'ENOENT' ? 'not_found' : 'io_error';
140
- result = { ok: false, error: fail(code, error instanceof FileFault ? error.message : `File operation failed (${code})`) };
166
+ const targets = targetsFor(args, name);
167
+ const keys = targets.map(target => path.resolve(root, target.path));
168
+ if (new Set(keys).size !== keys.length)
169
+ throw new FileFault('edit_conflict', 'Duplicate target paths');
170
+ for (const key of [...keys].sort()) {
171
+ const previous = writers.get(key);
172
+ let release;
173
+ const promise = new Promise(resolve => { release = resolve; });
174
+ writers.set(key, promise);
175
+ held.push({ key, promise, release });
176
+ await previous;
177
+ signal.throwIfAborted();
141
178
  }
179
+ const snapshots = [];
180
+ const candidates = [];
181
+ const snapshot = async (target) => {
182
+ const absolute = await checkPath(root, target.path, name === 'write');
183
+ if (absolute === root)
184
+ throw new FileFault('not_regular_file', 'Expected a file');
185
+ try {
186
+ return await readText(absolute);
187
+ }
188
+ catch (error) {
189
+ if (name === 'write' && error.code === 'ENOENT')
190
+ return undefined;
191
+ throw error;
192
+ }
193
+ };
194
+ let totalBytes = 0;
195
+ for (const target of targets) {
196
+ signal.throwIfAborted();
197
+ const before = await snapshot(target);
198
+ if (target.expectedSha256 === undefined ? before !== undefined : before?.sha256 !== target.expectedSha256) {
199
+ throw new FileFault('edit_conflict', 'File changed or exists without an expected SHA-256');
200
+ }
201
+ const edits = target.edits ? target.edits.map(edit => {
202
+ const start = before.text.indexOf(edit.oldText);
203
+ if (start < 0 || before.text.indexOf(edit.oldText, start + 1) >= 0) {
204
+ throw new FileFault('edit_conflict', 'oldText must match exactly once, including overlapping matches');
205
+ }
206
+ return { start, end: start + edit.oldText.length, text: edit.newText };
207
+ }).sort((a, b) => a.start - b.start) : [{ start: 0, end: before?.text.length ?? 0, text: target.content }];
208
+ if (edits.some((edit, i) => i > 0 && edit.start < edits[i - 1].end))
209
+ throw new FileFault('edit_conflict', 'Edits overlap in original text');
210
+ const next = replace(before?.text ?? '', edits);
211
+ assertText(next);
212
+ const bytes = Buffer.byteLength(next);
213
+ totalBytes += bytes + (before?.bytes ?? 0);
214
+ if (bytes > maxBytes || totalBytes > 32 * 1024 * 1024)
215
+ throw new FileFault('too_large', 'Mutation snapshot byte budget exceeded');
216
+ snapshots.push(before);
217
+ candidates.push(next);
218
+ receipts.push({ path: target.path, status: 'not_attempted', beforeSha256: before?.sha256 ?? null,
219
+ sha256: null, candidateSha256: digest(next), bytes,
220
+ diff: diff(target.path, before?.text ?? '', edits, next, before === undefined) });
221
+ }
222
+ const success = () => outcome(name === 'patch' ? { files: receipts, complete: true } : receipts[0]);
223
+ const cleanupPath = targets.reduce((longest, target) => {
224
+ const candidate = path.join(path.dirname(target.path), `.agent-${'0'.repeat(36)}.tmp`);
225
+ return Buffer.byteLength(JSON.stringify(JSON.stringify(candidate))) > Buffer.byteLength(JSON.stringify(JSON.stringify(longest)))
226
+ ? candidate : longest;
227
+ }, '');
228
+ const reserved = receipts.map(receipt => ({ ...receipt, status: 'not_attempted', sha256: receipt.candidateSha256 }));
229
+ const partial = { ok: false, error: { code: 'cancelled_before_commit', effect: 'unknown',
230
+ message: JSON.stringify({ complete: false, files: reserved, failedIndex: targets.length, cause: 'cancelled_before_commit',
231
+ temporary: cleanupPath }) + ' '.repeat(256) } };
232
+ // Include the most expensive doubly encoded cleanup path BEFORE touching any file.
233
+ if (!fit(outcome(name === 'patch' ? { files: reserved, complete: true } : reserved[0])) || !fit(partial)) {
234
+ throw new FileFault('output_limit', 'Encoded diff/receipt exceeds budget; no writes attempted');
235
+ }
236
+ committing = true;
237
+ for (index = 0; index < targets.length; index++) {
238
+ const target = targets[index];
239
+ signal.throwIfAborted();
240
+ const absolute = await checkPath(root, target.path, name === 'write');
241
+ const candidate = path.join(path.dirname(absolute), `.agent-${randomUUID()}.tmp`);
242
+ const file = await open(candidate, 'wx', 0o600);
243
+ temporary = candidate;
244
+ try {
245
+ await file.writeFile(candidates[index], { encoding: 'utf8', signal });
246
+ }
247
+ finally {
248
+ await file.close();
249
+ }
250
+ const after = await snapshot(target);
251
+ if (snapshots[index]?.identity !== after?.identity || snapshots[index]?.sha256 !== after?.sha256) {
252
+ throw new FileFault('edit_conflict', 'File changed before commit; no replacement was made');
253
+ }
254
+ signal.throwIfAborted();
255
+ await rename(temporary, absolute);
256
+ temporary = undefined;
257
+ receipts[index].status = 'committed';
258
+ receipts[index].sha256 = receipts[index].candidateSha256;
259
+ }
260
+ return success();
261
+ }
262
+ catch (error) {
263
+ const code = error instanceof FileFault ? error.code : signal.aborted ? 'cancelled_before_commit'
264
+ : error.code === 'ENOENT' ? 'not_found' : 'io_error';
265
+ let cleanupFailed = false;
142
266
  if (temporary) {
143
267
  try {
144
268
  await unlink(temporary);
269
+ temporary = undefined;
145
270
  }
146
271
  catch {
147
- return { ok: false, error: { code: 'cleanup_failed',
148
- message: `Owned temporary file remains: ${path.relative(workspace, temporary)}; targetCommitted=${committed}`,
149
- effect: 'unknown' } };
272
+ cleanupFailed = true;
150
273
  }
151
274
  }
152
- return result;
153
- },
154
- };
155
- });
275
+ const committed = receipts.some(receipt => receipt.status === 'committed');
276
+ if (committing && index < receipts.length)
277
+ receipts[index].status = 'rejected';
278
+ if (committed || cleanupFailed)
279
+ return { ok: false, error: { code: cleanupFailed ? 'cleanup_failed' : code, effect: 'unknown',
280
+ message: JSON.stringify({ complete: false, files: receipts, failedIndex: index, cause: code,
281
+ temporary: temporary ? path.relative(root, temporary) : null }) } };
282
+ return { ok: false, error: fail(code, error instanceof FileFault ? error.message : `File operation failed (${code})`) };
283
+ }
284
+ finally {
285
+ for (const lock of held.reverse()) {
286
+ lock.release();
287
+ if (writers.get(lock.key) === lock.promise)
288
+ writers.delete(lock.key);
289
+ }
290
+ }
291
+ },
292
+ }));
156
293
  }