@cjhyy/code-shell-capability-coding 0.9.44 → 0.9.46

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 (49) hide show
  1. package/dist/capability-runtime.js +4 -1
  2. package/dist/cc-orchestrator/codex-session-history.d.ts +3 -1
  3. package/dist/cc-orchestrator/codex-session-history.js +11 -4
  4. package/dist/cc-orchestrator/external-agent-changes.js +7 -65
  5. package/dist/cc-orchestrator/external-agent-driver.js +61 -3
  6. package/dist/cc-orchestrator/session-discovery.d.ts +4 -2
  7. package/dist/cc-orchestrator/session-discovery.js +63 -6
  8. package/dist/cc-orchestrator/session-history.js +5 -0
  9. package/dist/external-runtimes/claude-code/event-translator.d.ts +7 -0
  10. package/dist/external-runtimes/claude-code/event-translator.js +58 -9
  11. package/dist/external-runtimes/claude-code/mcp-config.d.ts +8 -0
  12. package/dist/external-runtimes/claude-code/mcp-config.js +16 -3
  13. package/dist/external-runtimes/claude-code/permissions.d.ts +69 -0
  14. package/dist/external-runtimes/claude-code/permissions.js +120 -0
  15. package/dist/external-runtimes/claude-code/runtime.d.ts +40 -4
  16. package/dist/external-runtimes/claude-code/runtime.js +130 -39
  17. package/dist/external-runtimes/codex/app-server-client.d.ts +12 -0
  18. package/dist/external-runtimes/codex/app-server-client.js +16 -0
  19. package/dist/external-runtimes/codex/runtime.d.ts +11 -0
  20. package/dist/external-runtimes/codex/runtime.js +143 -16
  21. package/dist/external-runtimes/index.d.ts +2 -0
  22. package/dist/external-runtimes/index.js +1 -0
  23. package/dist/external-runtimes/session-factory.d.ts +14 -0
  24. package/dist/external-runtimes/session-factory.js +40 -1
  25. package/dist/external-runtimes/shared/mcp-bridge.d.ts +36 -1
  26. package/dist/external-runtimes/shared/mcp-bridge.js +93 -13
  27. package/dist/git/parse-log.d.ts +6 -2
  28. package/dist/git/parse-log.js +7 -3
  29. package/dist/git/utils.d.ts +4 -0
  30. package/dist/git/utils.js +29 -10
  31. package/dist/git/worktree/crud.js +28 -0
  32. package/dist/git/worktree/diff.js +57 -29
  33. package/dist/index.capability.js +14 -1
  34. package/dist/index.git.d.ts +1 -0
  35. package/dist/index.git.js +1 -0
  36. package/dist/quota/index.d.ts +2 -1
  37. package/dist/quota/index.js +27 -7
  38. package/dist/quota/types.d.ts +5 -2
  39. package/dist/review/review-prompt.d.ts +2 -0
  40. package/dist/review/review-prompt.js +70 -29
  41. package/dist/review/review-target.d.ts +29 -0
  42. package/dist/review/review-target.js +353 -0
  43. package/dist/tools/apply-patch/applier.js +64 -39
  44. package/dist/tools/apply-patch/backup-targets.d.ts +8 -0
  45. package/dist/tools/apply-patch/backup-targets.js +24 -0
  46. package/dist/tools/apply-patch/parser.js +4 -2
  47. package/dist/tools/check-quota.js +3 -1
  48. package/dist/tools/lsp.js +36 -5
  49. package/package.json +2 -2
@@ -0,0 +1,353 @@
1
+ import { execFile } from "node:child_process";
2
+ import { constants } from "node:fs";
3
+ import { lstat, open } from "node:fs/promises";
4
+ import { isAbsolute, relative, resolve, sep } from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { resolveGit } from "@cjhyy/code-shell-core/extension";
7
+ import { ALL_DIMENSIONS } from "./review-prompt.js";
8
+ const execFileAsync = promisify(execFile);
9
+ const MAX_DIFF_BYTES = 256 * 1024;
10
+ const MAX_ADDED_FILE_BYTES = 64 * 1024;
11
+ const MAX_FILES = 100;
12
+ const DIFF_OPTIONS = [
13
+ "--no-ext-diff",
14
+ "--no-textconv",
15
+ "--no-color",
16
+ "--no-relative",
17
+ "--no-renames",
18
+ "--ignore-submodules=none",
19
+ "--submodule=short",
20
+ ];
21
+ /** Parse slash-command arguments without treating quotes or flags as path fragments. */
22
+ export function parseReviewArguments(input) {
23
+ const tokens = tokenizeReviewArguments(input);
24
+ let target;
25
+ let file;
26
+ let json = false;
27
+ let dimensions = [...ALL_DIMENSIONS];
28
+ let dimensionsSeen = false;
29
+ let positional = false;
30
+ const setTarget = (next) => {
31
+ if (target)
32
+ throw new Error("Choose only one review scope: --all, --staged, --unstaged, --base, or --commit.");
33
+ target = next;
34
+ };
35
+ for (let index = 0; index < tokens.length; index++) {
36
+ const token = tokens[index];
37
+ if (!positional && token === "--") {
38
+ positional = true;
39
+ continue;
40
+ }
41
+ if (!positional && token.startsWith("--")) {
42
+ if (token === "--json") {
43
+ if (json)
44
+ throw new Error("--json may only be specified once.");
45
+ json = true;
46
+ }
47
+ else if (token === "--all" || token === "--staged" || token === "--unstaged") {
48
+ setTarget({ kind: "working", mode: token.slice(2) });
49
+ }
50
+ else if (/^--(?:base|commit|dimensions)(?:=|$)/.test(token)) {
51
+ const equals = token.indexOf("=");
52
+ const flag = equals < 0 ? token : token.slice(0, equals);
53
+ const value = equals < 0 ? tokens[++index] : token.slice(equals + 1);
54
+ if (!value || value.startsWith("--"))
55
+ throw new Error(`${flag} requires a value.`);
56
+ if (flag === "--base") {
57
+ validateRef(value);
58
+ setTarget({ kind: "base", ref: value });
59
+ }
60
+ else if (flag === "--commit") {
61
+ if (!/^[a-fA-F0-9]{4,64}$/.test(value)) {
62
+ throw new Error("--commit requires a hexadecimal commit hash (4–64 characters).");
63
+ }
64
+ setTarget({ kind: "commit", commit: value });
65
+ }
66
+ else {
67
+ if (dimensionsSeen)
68
+ throw new Error("--dimensions may only be specified once.");
69
+ dimensionsSeen = true;
70
+ const names = value.split(",").map((name) => name.trim().toLowerCase());
71
+ const normalized = names.map((name) => (name === "perf" ? "performance" : name));
72
+ const invalid = normalized.filter((name) => !ALL_DIMENSIONS.includes(name));
73
+ if (invalid.length)
74
+ throw new Error(`Unknown review dimension: ${invalid.join(", ")}. Use correctness, security, performance, or readability.`);
75
+ dimensions = ALL_DIMENSIONS.filter((name) => normalized.includes(name));
76
+ }
77
+ }
78
+ else {
79
+ throw new Error(`Unknown review option: ${token}.`);
80
+ }
81
+ }
82
+ else {
83
+ if (!positional && token.startsWith("-")) {
84
+ throw new Error(`Unknown review option: ${token}. Use -- before a dash-prefixed path.`);
85
+ }
86
+ if (file !== undefined)
87
+ throw new Error("Review accepts one file or directory path. Quote paths containing spaces.");
88
+ if (!token || token.includes("\0"))
89
+ throw new Error("Invalid review path.");
90
+ file = token;
91
+ }
92
+ }
93
+ return {
94
+ target: {
95
+ ...(target ?? { kind: "working", mode: "all" }),
96
+ ...(file ? { file } : {}),
97
+ },
98
+ json,
99
+ dimensions,
100
+ };
101
+ }
102
+ function validateRef(ref) {
103
+ if (ref.startsWith("-") || /[\x00-\x20\x7f]/.test(ref) || ref.length > 512) {
104
+ throw new Error("--base requires a valid Git reference.");
105
+ }
106
+ }
107
+ function tokenizeReviewArguments(input) {
108
+ const tokens = [];
109
+ let token = "";
110
+ let quote;
111
+ let started = false;
112
+ for (let index = 0; index < input.length; index++) {
113
+ const char = input[index];
114
+ if (quote) {
115
+ if (char === quote)
116
+ quote = undefined;
117
+ else if (char === "\\" && quote === '"' && /["\\]/.test(input[index + 1] ?? ""))
118
+ token += input[++index];
119
+ else
120
+ token += char;
121
+ continue;
122
+ }
123
+ if (char === "'" || char === '"') {
124
+ quote = char;
125
+ started = true;
126
+ }
127
+ else if (/\s/.test(char)) {
128
+ if (started)
129
+ tokens.push(token);
130
+ token = "";
131
+ started = false;
132
+ }
133
+ else if (char === "\\" && /[\s'"\\]/.test(input[index + 1] ?? "")) {
134
+ token += input[++index];
135
+ started = true;
136
+ }
137
+ else {
138
+ token += char;
139
+ started = true;
140
+ }
141
+ }
142
+ if (quote)
143
+ throw new Error("Unclosed quote in review arguments.");
144
+ if (started)
145
+ tokens.push(token);
146
+ return tokens;
147
+ }
148
+ /** Collect a complete bounded patch, or fail explicitly before asking the model to review it. */
149
+ export async function collectReviewTarget(cwd, target) {
150
+ const repoRoot = (await git(cwd, ["rev-parse", "--show-toplevel"])).trim();
151
+ const file = target.file === undefined ? undefined : repositoryPath(repoRoot, cwd, target.file);
152
+ const pathspec = file === undefined ? [] : [`:(literal)${file}`];
153
+ const paths = ["--", ...pathspec];
154
+ let range;
155
+ let label;
156
+ let unbornAll = false;
157
+ if (target.kind === "base") {
158
+ validateRef(target.ref);
159
+ const base = await resolveCommit(repoRoot, target.ref);
160
+ const head = await resolveCommit(repoRoot, "HEAD");
161
+ const ancestor = (await git(repoRoot, ["merge-base", base, head])).trim();
162
+ range = [ancestor, head];
163
+ label = `branch changes since merge-base with ${target.ref}`;
164
+ }
165
+ else if (target.kind === "commit") {
166
+ if (!/^[a-fA-F0-9]{4,64}$/.test(target.commit))
167
+ throw new Error("Invalid review commit hash.");
168
+ const commit = await resolveCommit(repoRoot, target.commit);
169
+ range = [commit];
170
+ label = `commit ${commit}`;
171
+ }
172
+ else {
173
+ if (!["all", "staged", "unstaged"].includes(target.mode))
174
+ throw new Error("Invalid review scope.");
175
+ const head = await git(repoRoot, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], true);
176
+ unbornAll = target.mode === "all" && head === null;
177
+ range =
178
+ target.mode === "staged" ? ["--cached"] : target.mode === "all" && head ? [head.trim()] : [];
179
+ label = `${target.mode === "all" ? "all uncommitted" : target.mode} changes`;
180
+ const conflicts = await git(repoRoot, ["ls-files", "--unmerged", "-z", ...paths]);
181
+ if (conflicts)
182
+ throw new Error("Review incomplete: resolve unmerged files before reviewing this scope.");
183
+ }
184
+ const command = target.kind === "commit" ? "show" : "diff";
185
+ const prefix = command === "show" ? ["show", "--format=", "--root", "--first-parent"] : ["diff"];
186
+ const options = [...prefix, ...DIFF_OPTIONS];
187
+ let content = "";
188
+ let files = [];
189
+ if (!unbornAll) {
190
+ const numstat = (await git(repoRoot, [...options, "--numstat", "-z", ...range, ...paths]));
191
+ for (const entry of numstat.split("\0").filter(Boolean)) {
192
+ const [added, removed, ...name] = entry.split("\t");
193
+ if (added === "-" || removed === "-") {
194
+ throw new Error(`Review incomplete: binary file ${name.join("\t")} cannot be reviewed as text.`);
195
+ }
196
+ }
197
+ files = nulPaths((await git(repoRoot, [...options, "--name-only", "-z", ...range, ...paths])));
198
+ enforceFileCount(files.length);
199
+ content = (await git(repoRoot, [...options, "--patch", "--unified=3", ...range, ...paths]));
200
+ if (content.includes("\0")) {
201
+ throw new Error("Review incomplete: the patch contains binary data that cannot be reviewed as text.");
202
+ }
203
+ }
204
+ if (target.kind === "working" && target.mode !== "staged") {
205
+ const added = nulPaths((await git(repoRoot, ["ls-files", "--others", "--exclude-standard", "-z", ...paths])));
206
+ if (unbornAll)
207
+ added.unshift(...nulPaths((await git(repoRoot, ["ls-files", "--cached", "-z", ...paths]))));
208
+ const uniqueAdded = [...new Set(added)];
209
+ enforceFileCount(files.length + uniqueAdded.length);
210
+ for (const path of uniqueAdded) {
211
+ const patch = await addedFilePatch(repoRoot, path, unbornAll);
212
+ if (patch === null)
213
+ continue; // A staged addition deleted before the first commit has no net change.
214
+ files.push(path);
215
+ content += `${content && !content.endsWith("\n") ? "\n" : ""}${patch}`;
216
+ enforceDiffSize(content);
217
+ }
218
+ }
219
+ enforceDiffSize(content);
220
+ if (file !== undefined && files.length === 0) {
221
+ throw new Error(`No changes in the selected review scope match ${target.file}.`);
222
+ }
223
+ return { content, label: `${label}${target.file ? ` in ${target.file}` : ""}`, files };
224
+ }
225
+ function repositoryPath(root, cwd, file) {
226
+ if (!file || file.includes("\0"))
227
+ throw new Error("Invalid review path.");
228
+ const path = relative(root, resolve(cwd, file));
229
+ if (path === ".." || path.startsWith(`..${sep}`) || isAbsolute(path))
230
+ throw new Error("Review path must be inside the Git repository.");
231
+ return path.split(sep).join("/") || ".";
232
+ }
233
+ async function resolveCommit(cwd, ref) {
234
+ return (await git(cwd, ["rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`])).trim();
235
+ }
236
+ function nulPaths(raw) {
237
+ return raw.split("\0").filter(Boolean);
238
+ }
239
+ function enforceFileCount(count) {
240
+ if (count > MAX_FILES)
241
+ throw new Error(`Review incomplete: scope exceeds ${MAX_FILES} files. Select a narrower scope or path.`);
242
+ }
243
+ function enforceDiffSize(content) {
244
+ if (Buffer.byteLength(content) > MAX_DIFF_BYTES)
245
+ throw new Error(`Review incomplete: patch exceeds ${MAX_DIFF_BYTES} bytes. Select a narrower scope or path.`);
246
+ }
247
+ async function git(cwd, args, allowMissing = false) {
248
+ try {
249
+ const result = await execFileAsync(resolveGit(), ["--no-pager", "-c", "core.fsmonitor=false", ...args], {
250
+ cwd,
251
+ encoding: "buffer",
252
+ timeout: 30_000,
253
+ maxBuffer: MAX_DIFF_BYTES,
254
+ env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" },
255
+ });
256
+ return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(result.stdout);
257
+ }
258
+ catch (error) {
259
+ const failure = error;
260
+ if (allowMissing && failure.code === 1)
261
+ return null;
262
+ if (failure.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER")
263
+ throw new Error(`Review incomplete: Git output exceeds ${MAX_DIFF_BYTES} bytes. Select a narrower scope or path.`, { cause: error });
264
+ if (failure.code === "ERR_ENCODING_INVALID_ENCODED_DATA") {
265
+ throw new Error("Review incomplete: Git output contains non-UTF-8 data.", { cause: error });
266
+ }
267
+ if (failure.killed)
268
+ throw new Error("Review incomplete: Git collection timed out.", { cause: error });
269
+ throw new Error(`Review collection failed: ${String(failure.stderr || failure.message)
270
+ .trim()
271
+ .slice(0, 500)}`, { cause: error });
272
+ }
273
+ }
274
+ async function addedFilePatch(repoRoot, file, allowDeleted) {
275
+ const absolute = resolve(repoRoot, file);
276
+ const bounded = repositoryPath(repoRoot, repoRoot, file);
277
+ let stat;
278
+ try {
279
+ // Refuse symlinks in every component, including a directory replaced after ls-files.
280
+ let component = repoRoot;
281
+ for (const part of bounded.split("/")) {
282
+ component = resolve(component, part);
283
+ const info = await lstat(component);
284
+ if (info.isSymbolicLink())
285
+ throw new Error(`Review incomplete: ${file} is a symbolic link.`);
286
+ }
287
+ stat = await lstat(absolute);
288
+ }
289
+ catch (error) {
290
+ if (allowDeleted && error.code === "ENOENT")
291
+ return null;
292
+ throw error;
293
+ }
294
+ if (!stat.isFile())
295
+ throw new Error(`Review incomplete: ${file} is not a regular file.`);
296
+ if (stat.size > MAX_ADDED_FILE_BYTES)
297
+ throw new Error(`Review incomplete: ${file} exceeds the ${MAX_ADDED_FILE_BYTES}-byte added-file limit.`);
298
+ const handle = await open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
299
+ try {
300
+ const before = await handle.stat();
301
+ if (!before.isFile() ||
302
+ before.size > MAX_ADDED_FILE_BYTES ||
303
+ before.dev !== stat.dev ||
304
+ before.ino !== stat.ino)
305
+ throw new Error(`Review incomplete: ${file} changed during collection or exceeds the added-file limit.`);
306
+ const bytes = Buffer.alloc(MAX_ADDED_FILE_BYTES + 1);
307
+ const { bytesRead } = await handle.read(bytes, 0, bytes.length, 0);
308
+ const after = await handle.stat();
309
+ if (bytesRead > MAX_ADDED_FILE_BYTES ||
310
+ before.size !== bytesRead ||
311
+ before.size !== after.size ||
312
+ before.mtimeMs !== after.mtimeMs ||
313
+ before.ctimeMs !== after.ctimeMs) {
314
+ throw new Error(`Review incomplete: ${file} changed during collection or exceeds the added-file limit.`);
315
+ }
316
+ const body = bytes.subarray(0, bytesRead);
317
+ if (body.includes(0))
318
+ throw new Error(`Review incomplete: binary file ${file} cannot be reviewed as text.`);
319
+ let text;
320
+ try {
321
+ text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(body);
322
+ }
323
+ catch {
324
+ throw new Error(`Review incomplete: ${file} is not valid UTF-8 text.`);
325
+ }
326
+ const lines = text ? text.split("\n") : [];
327
+ const newline = text.endsWith("\n");
328
+ if (newline)
329
+ lines.pop();
330
+ const a = quoteGitPath(`a/${file}`);
331
+ const b = quoteGitPath(`b/${file}`);
332
+ return [
333
+ `diff --git ${a} ${b}`,
334
+ `new file mode ${stat.mode & 0o111 ? "100755" : "100644"}`,
335
+ ...(lines.length
336
+ ? [
337
+ "--- /dev/null",
338
+ `+++ ${b}`,
339
+ `@@ -0,0 +1,${lines.length} @@`,
340
+ ...lines.map((line) => `+${line}`),
341
+ ...(!newline ? ["\"] : []),
342
+ ]
343
+ : []),
344
+ "",
345
+ ].join("\n");
346
+ }
347
+ finally {
348
+ await handle.close();
349
+ }
350
+ }
351
+ function quoteGitPath(path) {
352
+ return `"${path.replace(/["\\\x00-\x1f\x7f]/g, (char) => (char === '"' || char === "\\" ? `\\${char}` : `\\${char.charCodeAt(0).toString(8).padStart(3, "0")}`))}"`;
353
+ }
@@ -9,7 +9,7 @@
9
9
  //
10
10
  // Algorithm uses code adapted from codex-rs/apply-patch/src/lib.rs (Apache-2.0)
11
11
  // for the chunk-replacement phase (compute_replacements, apply_replacements).
12
- import { mkdir, readFile, stat, unlink, writeFile } from "node:fs/promises";
12
+ import { chmod, mkdir, open, unlink, writeFile } from "node:fs/promises";
13
13
  import { existsSync, realpathSync } from "node:fs";
14
14
  import { dirname, isAbsolute, resolve, sep } from "node:path";
15
15
  import { seekSequence } from "./seek-sequence.js";
@@ -47,42 +47,38 @@ async function planHunks(hunks, cwd, workspaceRoots) {
47
47
  const original = hunk.path;
48
48
  const sourcePath = resolveAgainst(original, cwd, workspaceRoots);
49
49
  if (hunk.kind === "add") {
50
- const existed = await readIfExists(sourcePath);
50
+ const existed = await readSnapshotIfExists(sourcePath);
51
51
  schedule(sourcePath, {
52
52
  path: sourcePath,
53
53
  newContent: hunk.contents,
54
- originalContent: existed,
54
+ original: existed,
55
55
  });
56
56
  set.added.push(original);
57
57
  continue;
58
58
  }
59
59
  if (hunk.kind === "delete") {
60
- const stats = await safeStat(sourcePath);
61
- if (!stats)
60
+ const snapshot = await readSnapshotIfExists(sourcePath);
61
+ if (!snapshot)
62
62
  throw new Error(`Failed to delete file: ${sourcePath} not found`);
63
- if (stats.isDirectory()) {
64
- throw new Error(`Failed to delete file: ${sourcePath} is a directory`);
65
- }
66
- const content = await readFile(sourcePath, "utf-8");
67
63
  schedule(sourcePath, {
68
64
  path: sourcePath,
69
65
  newContent: null,
70
- originalContent: content,
66
+ original: snapshot,
71
67
  });
72
68
  set.deleted.push(original);
73
69
  continue;
74
70
  }
75
71
  // Update (with optional rename).
76
- const stats = await safeStat(sourcePath);
77
- if (!stats) {
72
+ const snapshot = await readSnapshotIfExists(sourcePath);
73
+ if (!snapshot) {
78
74
  throw new Error(`Failed to update file: ${sourcePath} does not exist`);
79
75
  }
80
- const originalRaw = await readFile(sourcePath, "utf-8");
76
+ const originalRaw = snapshot.content.toString("utf-8");
81
77
  // CRLF handling: patch context lines are LF (the model emits LF). Match in
82
78
  // LF space, then write back in the file's original EOL so a CRLF file isn't
83
79
  // silently converted to LF (which would diff the whole file). originalText
84
- // (LF) is what we compare; originalRaw is preserved for rollback so a
85
- // restore writes back the exact bytes. See eol.ts.
80
+ // (LF) is what we compare; the undecoded snapshot is preserved for rollback
81
+ // so a restore writes back the exact bytes. See eol.ts.
86
82
  const eol = detectEol(originalRaw);
87
83
  const originalText = toLf(originalRaw);
88
84
  const newText = applyEol(applyChunksToText(originalText, hunk.chunks, sourcePath), eol);
@@ -93,20 +89,21 @@ async function planHunks(hunks, cwd, workspaceRoots) {
93
89
  schedule(sourcePath, {
94
90
  path: sourcePath,
95
91
  newContent: newText,
96
- originalContent: originalRaw,
92
+ original: snapshot,
97
93
  });
98
94
  }
99
95
  else {
100
- const destOriginal = await readIfExists(destPath);
96
+ const destOriginal = await readSnapshotIfExists(destPath);
101
97
  schedule(sourcePath, {
102
98
  path: sourcePath,
103
99
  newContent: null,
104
- originalContent: originalRaw,
100
+ original: snapshot,
105
101
  });
106
102
  schedule(destPath, {
107
103
  path: destPath,
108
104
  newContent: newText,
109
- originalContent: destOriginal,
105
+ original: destOriginal,
106
+ newMode: snapshot.mode,
110
107
  });
111
108
  }
112
109
  set.modified.push(original);
@@ -115,7 +112,7 @@ async function planHunks(hunks, cwd, workspaceRoots) {
115
112
  schedule(sourcePath, {
116
113
  path: sourcePath,
117
114
  newContent: newText,
118
- originalContent: originalRaw,
115
+ original: snapshot,
119
116
  });
120
117
  set.modified.push(original);
121
118
  }
@@ -191,27 +188,27 @@ async function commitPlanned(set, allowPartial) {
191
188
  }
192
189
  return { added: set.added, modified: set.modified, deleted: set.deleted };
193
190
  }
194
- // Track which writes have completed so rollback only touches them. We use
195
- // the originalContent captured during plan() rather than re-reading from
196
- // disk — this avoids a TOCTOU window between planning and committing.
197
- const committed = [];
191
+ // Include the current attempt: writeFile may write partially before failing,
192
+ // and chmod can fail after the new content has already been written. Restore
193
+ // the bytes and permissions captured during planning, without re-reading.
194
+ const attempted = [];
198
195
  try {
199
196
  for (const change of ordered) {
197
+ attempted.push(change);
200
198
  await writeChange(change);
201
- committed.push(change);
202
199
  }
203
200
  }
204
201
  catch (err) {
205
- for (let i = committed.length - 1; i >= 0; i--) {
206
- const c = committed[i];
202
+ for (let i = attempted.length - 1; i >= 0; i--) {
203
+ const c = attempted[i];
207
204
  try {
208
- if (c.originalContent === null) {
205
+ if (c.original === null) {
209
206
  // File did not exist before we wrote it — remove our write.
210
207
  if (existsSync(c.path))
211
208
  await unlink(c.path);
212
209
  }
213
210
  else {
214
- await writeFile(c.path, c.originalContent, "utf-8");
211
+ await restoreSnapshot(c.path, c.original);
215
212
  }
216
213
  }
217
214
  catch {
@@ -222,6 +219,25 @@ async function commitPlanned(set, allowPartial) {
222
219
  }
223
220
  return { added: set.added, modified: set.modified, deleted: set.deleted };
224
221
  }
222
+ async function restoreSnapshot(path, snapshot) {
223
+ const writableMode = snapshot.mode | 0o200;
224
+ try {
225
+ // A successful move may have made an existing target read-only. writeFile's
226
+ // mode only applies to new files, so temporarily allow restoring its bytes.
227
+ try {
228
+ await chmod(path, writableMode);
229
+ }
230
+ catch (error) {
231
+ if (error.code !== "ENOENT")
232
+ throw error;
233
+ }
234
+ await writeFile(path, snapshot.content, { mode: writableMode });
235
+ }
236
+ finally {
237
+ // Also restore a read-only snapshot's mode if writing its bytes fails.
238
+ await chmod(path, snapshot.mode);
239
+ }
240
+ }
225
241
  async function writeChange(change) {
226
242
  if (change.newContent === null) {
227
243
  if (existsSync(change.path))
@@ -232,7 +248,11 @@ async function writeChange(change) {
232
248
  if (!existsSync(parent)) {
233
249
  await mkdir(parent, { recursive: true });
234
250
  }
235
- await writeFile(change.path, change.newContent, "utf-8");
251
+ await writeFile(change.path, change.newContent, { encoding: "utf-8", mode: change.newMode });
252
+ if (change.newMode !== undefined) {
253
+ // The creation mode is masked by umask and ignored for existing targets.
254
+ await chmod(change.path, change.newMode);
255
+ }
236
256
  }
237
257
  // ─── helpers ──────────────────────────────────────────────────────
238
258
  /**
@@ -288,19 +308,24 @@ function isInside(child, parent) {
288
308
  const p = parent.endsWith(sep) ? parent : parent + sep;
289
309
  return child.startsWith(p);
290
310
  }
291
- async function safeStat(path) {
311
+ async function readSnapshotIfExists(path) {
312
+ let file;
292
313
  try {
293
- return await stat(path);
314
+ file = await open(path, "r");
294
315
  }
295
- catch {
296
- return null;
316
+ catch (error) {
317
+ const code = error.code;
318
+ if (code === "ENOENT" || code === "ENOTDIR")
319
+ return null;
320
+ throw error;
297
321
  }
298
- }
299
- async function readIfExists(path) {
300
322
  try {
301
- return await readFile(path, "utf-8");
323
+ const stats = await file.stat();
324
+ if (stats.isDirectory())
325
+ throw new Error(`Cannot apply patch to directory: ${path}`);
326
+ return { content: await file.readFile(), mode: stats.mode & 0o7777 };
302
327
  }
303
- catch {
304
- return null;
328
+ finally {
329
+ await file.close();
305
330
  }
306
331
  }
@@ -8,3 +8,11 @@
8
8
  * up (undo of an add = delete, handled separately if/when /undo grows that).
9
9
  */
10
10
  export declare function patchBackupTargets(patchText: string, cwd: string): string[];
11
+ /**
12
+ * Every filesystem destination an ApplyPatch can change. Unlike undo backup
13
+ * targets, turn diffs need absent paths too: additions and move destinations
14
+ * must be captured before execution to distinguish creation from overwrite.
15
+ * Moves are represented by their source and destination; the tracker compares
16
+ * each path's real before/after state rather than inferring a rename from text.
17
+ */
18
+ export declare function patchDiffTargets(patchText: string, cwd: string): string[];
@@ -28,3 +28,27 @@ export function patchBackupTargets(patchText, cwd) {
28
28
  // but be defensive — saveSnapshot is idempotent on identical content anyway).
29
29
  return [...new Set(out)];
30
30
  }
31
+ /**
32
+ * Every filesystem destination an ApplyPatch can change. Unlike undo backup
33
+ * targets, turn diffs need absent paths too: additions and move destinations
34
+ * must be captured before execution to distinguish creation from overwrite.
35
+ * Moves are represented by their source and destination; the tracker compares
36
+ * each path's real before/after state rather than inferring a rename from text.
37
+ */
38
+ export function patchDiffTargets(patchText, cwd) {
39
+ let parsed;
40
+ try {
41
+ parsed = parsePatch(patchText, "lenient");
42
+ }
43
+ catch {
44
+ return [];
45
+ }
46
+ const out = [];
47
+ for (const hunk of parsed.hunks) {
48
+ out.push(resolvePath(cwd, hunk.path));
49
+ if (hunk.kind === "update" && hunk.movePath) {
50
+ out.push(resolvePath(cwd, hunk.movePath));
51
+ }
52
+ }
53
+ return [...new Set(out)];
54
+ }
@@ -108,8 +108,10 @@ function parseOneHunk(lines, offset, lineNumber) {
108
108
  }
109
109
  const chunks = [];
110
110
  while (cursor < lines.length) {
111
- // Skip blank lines between chunks.
112
- if (lines[cursor].trim() === "") {
111
+ // Skip blank lines between chunks. The first chunk may omit `@@`, so a
112
+ // space-marked line there is whitespace context, not a separator.
113
+ const leadingContext = chunks.length === 0 && lines[cursor].startsWith(" ");
114
+ if (lines[cursor].trim() === "" && !leadingContext) {
113
115
  cursor++;
114
116
  consumed++;
115
117
  continue;
@@ -5,7 +5,9 @@ export const checkQuotaToolDef = {
5
5
  description: "Check remaining usage/rate-limit quota for the external coding-agent CLIs (Claude Code and/or " +
6
6
  "Codex) — the same subscription windows their status lines show. Use before or during " +
7
7
  "orchestration (DriveAgent) to plan how much work to hand off, whether to wait for a reset, or " +
8
- "which provider to use. Returns each window's used-% and reset time. Codex reports 5h/7d; " +
8
+ "which provider to use. Returns each window's used-% and reset time. " +
9
+ "Codex labels each window by its reported duration (e.g. 5h, 7d; 'primary'/'secondary' if " +
10
+ "the duration is missing); " +
9
11
  "Claude reports whichever windows apply to the account (5h/7d normally, 'overage' when the " +
10
12
  "account is running on overage), and marks the currently binding window with '*'. " +
11
13
  "COST: 'codex' is free (reads a usage endpoint). 'claude' costs ~1 token (Anthropic exposes " +