@nanhara/hara 0.121.0 → 0.122.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 (49) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +40 -6
  3. package/dist/agent/failover.js +1 -1
  4. package/dist/agent/loop.js +158 -21
  5. package/dist/agent/reminders.js +22 -7
  6. package/dist/agent/repeat-guard.js +26 -7
  7. package/dist/agent/structured.js +231 -0
  8. package/dist/agent/touched.js +20 -6
  9. package/dist/config.js +62 -26
  10. package/dist/cron/deliver.js +37 -3
  11. package/dist/feedback.js +5 -9
  12. package/dist/fs-read.js +106 -12
  13. package/dist/fs-write.js +242 -16
  14. package/dist/gateway/dingtalk.js +4 -1
  15. package/dist/gateway/discord.js +53 -18
  16. package/dist/gateway/feishu.js +158 -57
  17. package/dist/gateway/flows-pending.js +720 -0
  18. package/dist/gateway/flows.js +391 -16
  19. package/dist/gateway/matrix.js +80 -15
  20. package/dist/gateway/mattermost.js +44 -32
  21. package/dist/gateway/media.js +659 -0
  22. package/dist/gateway/serve.js +657 -162
  23. package/dist/gateway/sessions.js +475 -78
  24. package/dist/gateway/signal.js +27 -22
  25. package/dist/gateway/slack.js +26 -17
  26. package/dist/gateway/telegram.js +32 -18
  27. package/dist/gateway/wecom.js +32 -24
  28. package/dist/gateway/weixin.js +127 -49
  29. package/dist/hooks.js +32 -20
  30. package/dist/index.js +640 -219
  31. package/dist/org/projects.js +347 -0
  32. package/dist/org/roles.js +38 -11
  33. package/dist/security/secrets.js +150 -0
  34. package/dist/serve/server.js +772 -317
  35. package/dist/serve/sessions.js +112 -28
  36. package/dist/session/store.js +337 -44
  37. package/dist/tools/all.js +1 -0
  38. package/dist/tools/builtin.js +61 -23
  39. package/dist/tools/codebase.js +3 -1
  40. package/dist/tools/computer.js +98 -92
  41. package/dist/tools/edit.js +11 -8
  42. package/dist/tools/patch.js +230 -31
  43. package/dist/tools/search.js +482 -72
  44. package/dist/tools/task.js +453 -0
  45. package/dist/tools/todo.js +67 -16
  46. package/dist/tools/web.js +364 -64
  47. package/dist/tui/run.js +26 -23
  48. package/dist/undo.js +83 -7
  49. package/package.json +1 -1
@@ -284,98 +284,104 @@ export function computerBackends() {
284
284
  return "PowerShell (built-in)";
285
285
  return `unsupported (${process.platform})`;
286
286
  }
287
- registerTool({
288
- name: "computer",
289
- description: "Control the screen to operate desktop software (not just the browser). ALWAYS `activate` the target app " +
290
- "FIRST (e.g. activate WeChat) otherwise screenshots/clicks hit the terminal hara runs in, not the app. " +
291
- "Then prefer grounding over guessing pixels: pass `target` (e.g. 'the Send button') to click/move and it's " +
292
- "located by a vision model; or `find` to just get coordinates. Workflow: activate → screenshot → click a " +
293
- "target → re-screenshot to verify. When typing, type the ACTUAL text — never placeholders. Opt-in and " +
294
- "permission-gated (tier + per-app allowlist).",
295
- input_schema: {
296
- type: "object",
297
- properties: {
298
- action: { type: "string", enum: ["screenshot", "activate", "find", "click", "move", "type", "key"] },
299
- app: { type: "string", description: "app to bring to the foreground (activate) e.g. 'WeChat'. Do this BEFORE screenshot/click so they hit the app, not the terminal." },
300
- target: { type: "string", description: "describe a UI element to locate (find) or click/move to — e.g. 'the Send button'. Preferred over x,y." },
301
- x: { type: "number", description: "x pixel (click/move; or use `target`)" },
302
- y: { type: "number", description: "y pixel (click/move; or use `target`)" },
303
- text: { type: "string", description: "text to type (type)" },
304
- keys: { type: "string", description: "key or combo, e.g. 'return', 'cmd+c' (key)" },
305
- focus: { type: "string", description: "screenshot only: what to look forfocuses the read" },
287
+ // Gateway runs get NO computer tool by default (HARA_GATEWAY_COMPUTER=1 opts back in): a chat-driven, full-auto
288
+ // agent reaching for desktop automation is how "check Feishu" turns into blindly clicking another app's windows.
289
+ // In a gateway context the right lever is an API/skill, and send_file already covers delivery so the safe
290
+ // default is to not offer screen control at all rather than trust the model to decline it.
291
+ if (!process.env.HARA_GATEWAY || process.env.HARA_GATEWAY_COMPUTER === "1") {
292
+ registerTool({
293
+ name: "computer",
294
+ description: "Control the screen to operate desktop software (not just the browser). ALWAYS `activate` the target app " +
295
+ "FIRST (e.g. activate WeChat) — otherwise screenshots/clicks hit the terminal hara runs in, not the app. " +
296
+ "Then prefer grounding over guessing pixels: pass `target` (e.g. 'the Send button') to click/move and it's " +
297
+ "located by a vision model; or `find` to just get coordinates. Workflow: activate → screenshot → click a " +
298
+ "target re-screenshot to verify. When typing, type the ACTUAL text — never placeholders. Opt-in and " +
299
+ "permission-gated (tier + per-app allowlist).",
300
+ input_schema: {
301
+ type: "object",
302
+ properties: {
303
+ action: { type: "string", enum: ["screenshot", "activate", "find", "click", "move", "type", "key"] },
304
+ app: { type: "string", description: "app to bring to the foreground (activate) — e.g. 'WeChat'. Do this BEFORE screenshot/click so they hit the app, not the terminal." },
305
+ target: { type: "string", description: "describe a UI element to locate (find) or click/move to e.g. 'the Send button'. Preferred over x,y." },
306
+ x: { type: "number", description: "x pixel (click/move; or use `target`)" },
307
+ y: { type: "number", description: "y pixel (click/move; or use `target`)" },
308
+ text: { type: "string", description: "text to type (type)" },
309
+ keys: { type: "string", description: "key or combo, e.g. 'return', 'cmd+c' (key)" },
310
+ focus: { type: "string", description: "screenshot only: what to look for — focuses the read" },
311
+ },
312
+ required: ["action"],
306
313
  },
307
- required: ["action"],
308
- },
309
- kind: "computer",
310
- async run(input, ctx) {
311
- const cfg = loadConfig();
312
- const tier = cfg.computerUse;
313
- if (tier === "off")
314
- return "Screen control is off. Enable it: `hara config set computerUse read|click|full` (and `hara config set computerApps \"App Name, …\"` for the click/type allowlist).";
315
- const action = String(input.action ?? "");
316
- if (!actionAllowed(tier, action))
317
- return `'${action}' needs a higher tier (current computerUse=${tier}). Raise it with \`hara config set computerUse …\`.`;
318
- // Bring the target app to the foreground first — without this, clicks land on the terminal hara runs in.
319
- if (action === "activate") {
320
- const app = String(input.app ?? input.target ?? "");
321
- if (!app)
322
- return "activate needs an `app` name (e.g. 'WeChat').";
323
- if (!cfg.computerApps.some((a) => a.toLowerCase() === app.toLowerCase()))
324
- return `Refused: "${app}" isn't in your allowlist (${cfg.computerApps.join(", ") || "empty"}). Add it: \`hara config set computerApps "${app}"\`.`;
325
- const r = activateApp(app);
326
- return r.ok ? ok(`✓ ${r.msg} — now screenshot/find/click to act on it`) : fail(r.msg);
327
- }
328
- if (action !== "screenshot" && action !== "find") {
329
- // per-app allowlist: only act when an allowlisted app is frontmost (the key guard against wrong-window clicks)
330
- if (!cfg.computerApps.length)
331
- return "No apps allowlisted — set `hara config set computerApps \"App Name, …\"` before clicking/typing.";
332
- const app = frontmostApp();
333
- const allowed = cfg.computerApps.some((a) => a.toLowerCase() === app.toLowerCase());
334
- if (!allowed)
335
- return `Refused: frontmost app "${app || "unknown"}" isn't in your allowlist (${cfg.computerApps.join(", ")}). Switch to an allowed app or update computerApps.`;
336
- }
337
- if (action === "screenshot") {
338
- const s = screenshot();
339
- if (s.error)
340
- return fail(`screenshot — ${s.error}`);
341
- if (ctx.describeImage) {
342
- try {
343
- const desc = await ctx.describeImage(s.path, input.focus ? String(input.focus) : undefined);
344
- if (desc)
345
- return ok(`Screenshot (read via vision):\n${desc}`);
346
- }
347
- catch {
348
- /* fall through to path */
314
+ kind: "computer",
315
+ async run(input, ctx) {
316
+ const cfg = loadConfig();
317
+ const tier = cfg.computerUse;
318
+ if (tier === "off")
319
+ return "Screen control is off. Enable it: `hara config set computerUse read|click|full` (and `hara config set computerApps \"App Name, …\"` for the click/type allowlist).";
320
+ const action = String(input.action ?? "");
321
+ if (!actionAllowed(tier, action))
322
+ return `'${action}' needs a higher tier (current computerUse=${tier}). Raise it with \`hara config set computerUse …\`.`;
323
+ // Bring the target app to the foreground first — without this, clicks land on the terminal hara runs in.
324
+ if (action === "activate") {
325
+ const app = String(input.app ?? input.target ?? "");
326
+ if (!app)
327
+ return "activate needs an `app` name (e.g. 'WeChat').";
328
+ if (!cfg.computerApps.some((a) => a.toLowerCase() === app.toLowerCase()))
329
+ return `Refused: "${app}" isn't in your allowlist (${cfg.computerApps.join(", ") || "empty"}). Add it: \`hara config set computerApps "${app}"\`.`;
330
+ const r = activateApp(app);
331
+ return r.ok ? ok(`✓ ${r.msg} now screenshot/find/click to act on it`) : fail(r.msg);
332
+ }
333
+ if (action !== "screenshot" && action !== "find") {
334
+ // per-app allowlist: only act when an allowlisted app is frontmost (the key guard against wrong-window clicks)
335
+ if (!cfg.computerApps.length)
336
+ return "No apps allowlisted set `hara config set computerApps \"App Name, …\"` before clicking/typing.";
337
+ const app = frontmostApp();
338
+ const allowed = cfg.computerApps.some((a) => a.toLowerCase() === app.toLowerCase());
339
+ if (!allowed)
340
+ return `Refused: frontmost app "${app || "unknown"}" isn't in your allowlist (${cfg.computerApps.join(", ")}). Switch to an allowed app or update computerApps.`;
341
+ }
342
+ if (action === "screenshot") {
343
+ const s = screenshot();
344
+ if (s.error)
345
+ return fail(`screenshot ${s.error}`);
346
+ if (ctx.describeImage) {
347
+ try {
348
+ const desc = await ctx.describeImage(s.path, input.focus ? String(input.focus) : undefined);
349
+ if (desc)
350
+ return ok(`Screenshot (read via vision):\n${desc}`);
351
+ }
352
+ catch {
353
+ /* fall through to path */
354
+ }
349
355
  }
356
+ return ok(`Screenshot saved to ${s.path}. Configure a vision model so I can read it: \`hara config set visionModel <model>\`.`);
350
357
  }
351
- return ok(`Screenshot saved to ${s.path}. Configure a vision model so I can read it: \`hara config set visionModel <model>\`.`);
352
- }
353
- // Grounding: locate a described element and turn it into screen coordinates (more reliable than guessing
354
- // pixels from a text description). Used for `find`, and for click/move when given a `target` and no x,y.
355
- const needsLocate = action === "find" || ((action === "click" || action === "move") && input.target != null && (input.x == null || input.y == null));
356
- if (needsLocate) {
357
- const target = String(input.target ?? "");
358
- if (!target)
359
- return action === "find" ? "find needs a `target` (what to locate)." : "click/move needs `x,y` or a `target`.";
360
- if (!ctx.locate)
361
- return "Grounding needs a vision model that can see images — set one: `hara config set visionModel <model>`.";
362
- const s = screenshot();
363
- if (s.error)
364
- return fail(`screenshot — ${s.error}`);
365
- const loc = await ctx.locate(s.path, target);
366
- if (!loc)
367
- return fail(`couldn't locate "${target}" on screen — try a screenshot first, or rephrase the target`);
368
- const size = screenSize();
369
- if (!size)
370
- return fail(`located "${target}" but couldn't read the screen size to convert coordinates`);
371
- const gx = Math.round(loc.x * size.w);
372
- const gy = Math.round(loc.y * size.h);
373
- if (action === "find")
374
- return ok(`"${target}" is at ~${gx},${gy} (${Math.round(loc.x * 100)}% across, ${Math.round(loc.y * 100)}% down).`);
375
- input.x = gx;
376
- input.y = gy;
377
- }
378
- const r = pointerOrKeyboard(action, input);
379
- return r.ok ? ok(`✓ ${r.msg}${needsLocate ? ` (located "${input.target}")` : ""}`) : fail(r.msg);
380
- },
381
- });
358
+ // Grounding: locate a described element and turn it into screen coordinates (more reliable than guessing
359
+ // pixels from a text description). Used for `find`, and for click/move when given a `target` and no x,y.
360
+ const needsLocate = action === "find" || ((action === "click" || action === "move") && input.target != null && (input.x == null || input.y == null));
361
+ if (needsLocate) {
362
+ const target = String(input.target ?? "");
363
+ if (!target)
364
+ return action === "find" ? "find needs a `target` (what to locate)." : "click/move needs `x,y` or a `target`.";
365
+ if (!ctx.locate)
366
+ return "Grounding needs a vision model that can see images set one: `hara config set visionModel <model>`.";
367
+ const s = screenshot();
368
+ if (s.error)
369
+ return fail(`screenshot ${s.error}`);
370
+ const loc = await ctx.locate(s.path, target);
371
+ if (!loc)
372
+ return fail(`couldn't locate "${target}" on screen — try a screenshot first, or rephrase the target`);
373
+ const size = screenSize();
374
+ if (!size)
375
+ return fail(`located "${target}" but couldn't read the screen size to convert coordinates`);
376
+ const gx = Math.round(loc.x * size.w);
377
+ const gy = Math.round(loc.y * size.h);
378
+ if (action === "find")
379
+ return ok(`"${target}" is at ~${gx},${gy} (${Math.round(loc.x * 100)}% across, ${Math.round(loc.y * 100)}% down).`);
380
+ input.x = gx;
381
+ input.y = gy;
382
+ }
383
+ const r = pointerOrKeyboard(action, input);
384
+ return r.ok ? ok(`✓ ${r.msg}${needsLocate ? ` (located "${input.target}")` : ""}`) : fail(r.msg);
385
+ },
386
+ });
387
+ }
@@ -1,4 +1,3 @@
1
- import { readFile } from "node:fs/promises";
2
1
  import { isAbsolute, resolve } from "node:path";
3
2
  import { registerTool } from "./registry.js";
4
3
  import { nearestPaths } from "../fs-walk.js";
@@ -7,6 +6,7 @@ import { applyEdits } from "./apply-core.js";
7
6
  import { recordEdit } from "../undo.js";
8
7
  import { atomicWriteText } from "../fs-write.js";
9
8
  import { invalidateFileCandidates } from "../context/mentions.js";
9
+ import { readRegularFileSnapshot } from "../fs-read.js";
10
10
  registerTool({
11
11
  name: "edit_file",
12
12
  description: "Edit an existing file by replacing exact strings. Provide a single `old_string`/`new_string`, " +
@@ -44,28 +44,31 @@ registerTool({
44
44
  const edits = Array.isArray(input.edits) && input.edits.length
45
45
  ? input.edits
46
46
  : [{ old_string: input.old_string, new_string: input.new_string, replace_all: input.replace_all }];
47
- let text;
47
+ let snapshot;
48
48
  try {
49
- text = await readFile(p, "utf8");
49
+ snapshot = await readRegularFileSnapshot(p);
50
50
  }
51
- catch {
51
+ catch (error) {
52
52
  const near = nearestPaths(ctx.cwd, input.path);
53
- return `Error: cannot read ${input.path} (use write_file to create a new file).` + (near.length ? ` Did you mean: ${near.join(", ")}?` : "");
53
+ return `Error: cannot read ${input.path}: ${error?.message ?? "unknown error"} (use write_file to create a new file).` + (near.length ? ` Did you mean: ${near.join(", ")}?` : "");
54
54
  }
55
+ const text = snapshot.text;
55
56
  const res = applyEdits(text, edits);
56
57
  if ("error" in res)
57
58
  return `Error: ${res.error} in ${input.path}. No changes written.`;
59
+ let committed;
58
60
  try {
59
- await atomicWriteText(p, res.text, { expected: text });
61
+ committed = await atomicWriteText(p, res.text, { expected: text, expectedIdentity: snapshot });
60
62
  }
61
63
  catch (error) {
62
64
  return `Error: cannot edit ${input.path}: ${error?.message ?? String(error)} No changes written.`;
63
65
  }
64
66
  emitDiff(input.path, text, res.text, ctx.ui);
65
- recordEdit([{ path: input.path, absPath: p, before: text }]);
67
+ recordEdit([{ path: input.path, absPath: p, before: text, beforeMode: snapshot.mode, committed, after: res.text }]);
66
68
  invalidateFileCandidates(ctx.cwd);
67
69
  const note = res.fuzzy ? " (quote-normalized)" : "";
68
70
  const plural = (n, w) => `${n} ${w}${n === 1 ? "" : "s"}`;
69
- return `Edited ${input.path}: ${plural(edits.length, "edit")}, ${plural(res.total, "replacement")}${note}.`;
71
+ return `Edited ${input.path}: ${plural(edits.length, "edit")}, ${plural(res.total, "replacement")}${note}.` +
72
+ (committed.warnings?.length ? ` Warning: ${committed.warnings.join("; ")}` : "");
70
73
  },
71
74
  });
@@ -1,13 +1,158 @@
1
1
  // apply_patch — change MULTIPLE files atomically (all-or-nothing). Everything is validated and
2
2
  // computed in memory first; nothing is written unless every change applies cleanly.
3
- import { lstat, readFile, unlink } from "node:fs/promises";
4
- import { isAbsolute, resolve } from "node:path";
3
+ import { linkSync, lstatSync, readlinkSync, symlinkSync } from "node:fs";
4
+ import { lstat, readlink, rename } from "node:fs/promises";
5
+ import { randomUUID } from "node:crypto";
6
+ import { dirname, isAbsolute, join, resolve } from "node:path";
5
7
  import { registerTool } from "./registry.js";
6
8
  import { applyEdits } from "./apply-core.js";
7
9
  import { emitDiff } from "../diff.js";
8
10
  import { recordEdit } from "../undo.js";
9
- import { atomicWriteText, FileChangedError } from "../fs-write.js";
11
+ import { atomicWriteText, discardClaimedPath, FileChangedError, removeCreatedDirectories } from "../fs-write.js";
10
12
  import { invalidateFileCandidates } from "../context/mentions.js";
13
+ import { readRegularFileSnapshot, readRegularFileSnapshotNoFollow } from "../fs-read.js";
14
+ async function restoreMovedFile(quarantine, target) {
15
+ let identity;
16
+ try {
17
+ // Keep source inspection + create-if-absent in one JS turn. The subsequent discard performs another
18
+ // synchronous identity claim, so a watcher cannot turn cleanup into an unlink of an unrelated entry.
19
+ const info = lstatSync(quarantine);
20
+ const linkTarget = info.isSymbolicLink() ? readlinkSync(quarantine) : undefined;
21
+ if (linkTarget !== undefined)
22
+ symlinkSync(linkTarget, target);
23
+ else
24
+ linkSync(quarantine, target);
25
+ identity = {
26
+ dev: info.dev,
27
+ ino: info.ino,
28
+ mode: info.mode & 0o777,
29
+ nlink: info.nlink + (linkTarget === undefined ? 1 : 0),
30
+ linkTarget,
31
+ };
32
+ }
33
+ catch (error) {
34
+ if (error?.code === "EEXIST") {
35
+ throw new Error(`another file appeared at ${target}; the concurrently moved file is preserved at ${quarantine}`);
36
+ }
37
+ throw error;
38
+ }
39
+ discardClaimedPath(quarantine, identity);
40
+ }
41
+ /** Roll back a file we wrote without read→unlink/rename TOCTOU. First atomically move whichever inode is
42
+ * currently at the path aside, then inspect that moved inode. A concurrent replacement is restored with
43
+ * create-if-absent and never deleted/overwritten. */
44
+ async function rollbackWrittenPlan(plan) {
45
+ if (!plan.committed || plan.after === null)
46
+ throw new Error(`missing commit identity for ${plan.path}`);
47
+ const target = plan.committed.target;
48
+ const quarantine = join(dirname(target), `.hara-rollback-${process.pid}-${randomUUID()}.tmp`);
49
+ // atomicWriteText follows a destination symlink and replaces its real target. Roll back that exact returned
50
+ // target path; moving plan.abs would destroy the symlink while leaving the changed target untouched.
51
+ await rename(target, quarantine);
52
+ let current;
53
+ try {
54
+ current = await readRegularFileSnapshotNoFollow(quarantine);
55
+ }
56
+ catch (error) {
57
+ await restoreMovedFile(quarantine, target);
58
+ throw error;
59
+ }
60
+ const owned = current.dev === plan.committed.dev && current.ino === plan.committed.ino && current.mode === plan.committed.mode && current.nlink === plan.committed.nlink && current.text === plan.after;
61
+ if (!owned) {
62
+ await restoreMovedFile(quarantine, target);
63
+ throw new FileChangedError(plan.path);
64
+ }
65
+ if (plan.type === "create") {
66
+ discardClaimedPath(quarantine, plan.committed);
67
+ await removeCreatedDirectories(plan.committed.createdDirs);
68
+ return;
69
+ }
70
+ if (plan.beforeMode === undefined) {
71
+ await restoreMovedFile(quarantine, target);
72
+ throw new Error(`missing preflight mode for ${plan.path}`);
73
+ }
74
+ try {
75
+ await atomicWriteText(target, plan.before, { expected: null, mode: plan.beforeMode });
76
+ }
77
+ catch (error) {
78
+ try {
79
+ await restoreMovedFile(quarantine, target);
80
+ }
81
+ catch (restoreError) {
82
+ throw new Error(`${error instanceof Error ? error.message : String(error)}; could not restore quarantine: ${restoreError?.message ?? String(restoreError)}`);
83
+ }
84
+ throw error;
85
+ }
86
+ discardClaimedPath(quarantine, plan.committed);
87
+ }
88
+ async function quarantineExpectedDelete(plan) {
89
+ if (!plan.beforeIdentity || !plan.beforePathIdentity || plan.beforeMode === undefined)
90
+ throw new Error(`missing preflight identity for ${plan.path}`);
91
+ // Keep the initially moved path private until it has been verified. A directory watcher must not be able
92
+ // to mistake an unverified staging entry for the durable delete quarantine used by the commit/cleanup
93
+ // phases. Recording it immediately also lets the outer rollback recover (or accurately report) a failure
94
+ // that happens after rename but before verification completes.
95
+ const staging = join(dirname(plan.abs), `.hara-stage-delete-${process.pid}-${randomUUID()}.tmp`);
96
+ await rename(plan.abs, staging);
97
+ plan.quarantine = staging;
98
+ const movedPath = await lstat(staging);
99
+ const samePath = movedPath.dev === plan.beforePathIdentity.dev && movedPath.ino === plan.beforePathIdentity.ino && (movedPath.mode & 0o777) === plan.beforePathIdentity.mode && movedPath.nlink === plan.beforePathIdentity.nlink;
100
+ const isExpectedLink = plan.beforeLinkTarget !== undefined;
101
+ const sameLink = movedPath.isSymbolicLink() === isExpectedLink && (!isExpectedLink || await readlink(staging) === plan.beforeLinkTarget);
102
+ if (!samePath || !sameLink)
103
+ throw new FileChangedError(plan.path);
104
+ const moved = isExpectedLink
105
+ ? await readRegularFileSnapshot(staging)
106
+ : await readRegularFileSnapshotNoFollow(staging);
107
+ if (moved.dev !== plan.beforeIdentity.dev || moved.ino !== plan.beforeIdentity.ino || moved.mode !== plan.beforeMode || moved.nlink !== plan.beforeIdentity.nlink || moved.text !== plan.before) {
108
+ throw new FileChangedError(plan.path);
109
+ }
110
+ const quarantine = join(dirname(plan.abs), `.hara-delete-${process.pid}-${randomUUID()}.tmp`);
111
+ await rename(staging, quarantine);
112
+ plan.quarantine = quarantine;
113
+ }
114
+ /** Move a delete quarantine to a fresh name and verify that exact moved path before restore/cleanup. If a
115
+ * concurrent process replaced the known quarantine name, preserve the unexpected inode and fail closed. */
116
+ async function claimDeleteQuarantine(plan, purpose) {
117
+ if (!plan.quarantine || !plan.beforePathIdentity || !plan.beforeIdentity || plan.beforeMode === undefined) {
118
+ throw new Error(`missing delete quarantine identity for ${plan.path}`);
119
+ }
120
+ const claimed = join(dirname(plan.quarantine), `.hara-${purpose}-${process.pid}-${randomUUID()}.tmp`);
121
+ await rename(plan.quarantine, claimed);
122
+ try {
123
+ const pathInfo = await lstat(claimed);
124
+ const expectedPath = plan.beforePathIdentity;
125
+ const expectedLink = plan.beforeLinkTarget !== undefined;
126
+ if (pathInfo.dev !== expectedPath.dev ||
127
+ pathInfo.ino !== expectedPath.ino ||
128
+ (pathInfo.mode & 0o777) !== expectedPath.mode ||
129
+ pathInfo.nlink !== expectedPath.nlink ||
130
+ pathInfo.isSymbolicLink() !== expectedLink ||
131
+ (expectedLink && await readlink(claimed) !== plan.beforeLinkTarget)) {
132
+ throw new FileChangedError(plan.path);
133
+ }
134
+ if (!expectedLink) {
135
+ const file = await readRegularFileSnapshotNoFollow(claimed);
136
+ if (file.dev !== plan.beforeIdentity.dev ||
137
+ file.ino !== plan.beforeIdentity.ino ||
138
+ file.mode !== plan.beforeMode ||
139
+ file.nlink !== plan.beforeIdentity.nlink ||
140
+ file.text !== plan.before) {
141
+ throw new FileChangedError(plan.path);
142
+ }
143
+ }
144
+ return claimed;
145
+ }
146
+ catch (error) {
147
+ try {
148
+ await restoreMovedFile(claimed, plan.quarantine);
149
+ }
150
+ catch (restoreError) {
151
+ throw new Error(`${error instanceof Error ? error.message : String(error)}; unexpected quarantine is preserved at ${claimed}: ${restoreError?.message ?? String(restoreError)}`);
152
+ }
153
+ throw error;
154
+ }
155
+ }
11
156
  registerTool({
12
157
  name: "apply_patch",
13
158
  description: "Change SEVERAL files in one atomic step (all-or-nothing). `changes` is an array of " +
@@ -78,13 +223,29 @@ registerTool({
78
223
  }
79
224
  if (type === "delete") {
80
225
  let before;
226
+ let pathInfo;
227
+ let beforeLinkTarget;
81
228
  try {
82
- before = await readFile(p, "utf8");
229
+ pathInfo = await lstat(p);
230
+ if (pathInfo.isSymbolicLink())
231
+ beforeLinkTarget = await readlink(p);
232
+ before = await readRegularFileSnapshot(p);
83
233
  }
84
- catch {
85
- return `Error: ${tag} delete ${ch.path}: file not found. Nothing written.`;
234
+ catch (error) {
235
+ return `Error: ${tag} delete ${ch.path}: ${error?.message ?? "file not found"}. Nothing written.`;
86
236
  }
87
- plans.push({ path: ch.path, abs: p, type, before, after: null, existed: true });
237
+ plans.push({
238
+ path: ch.path,
239
+ abs: p,
240
+ type,
241
+ before: before.text,
242
+ beforeMode: before.mode,
243
+ beforeIdentity: { dev: before.dev, ino: before.ino, mode: before.mode, nlink: before.nlink },
244
+ beforePathIdentity: { dev: pathInfo.dev, ino: pathInfo.ino, mode: pathInfo.mode & 0o777, nlink: pathInfo.nlink },
245
+ beforeLinkTarget,
246
+ after: null,
247
+ existed: true,
248
+ });
88
249
  }
89
250
  else if (type === "create") {
90
251
  if (typeof ch.content !== "string")
@@ -103,19 +264,19 @@ registerTool({
103
264
  // update
104
265
  let before;
105
266
  try {
106
- before = await readFile(p, "utf8");
267
+ before = await readRegularFileSnapshot(p);
107
268
  }
108
- catch {
109
- return `Error: ${tag} update ${ch.path}: cannot read (use type:create for a new file). Nothing written.`;
269
+ catch (error) {
270
+ return `Error: ${tag} update ${ch.path}: cannot read (${error?.message ?? "unknown error"}; use type:create for a new file). Nothing written.`;
110
271
  }
111
272
  if (typeof ch.content === "string" && !ch.edits) {
112
- plans.push({ path: ch.path, abs: p, type, before, after: ch.content, existed: true });
273
+ plans.push({ path: ch.path, abs: p, type, before: before.text, beforeMode: before.mode, beforeIdentity: { dev: before.dev, ino: before.ino, mode: before.mode, nlink: before.nlink }, after: ch.content, existed: true });
113
274
  }
114
275
  else {
115
- const res = applyEdits(before, ch.edits ?? []);
276
+ const res = applyEdits(before.text, ch.edits ?? []);
116
277
  if ("error" in res)
117
278
  return `Error: ${tag} ${ch.path} — ${res.error}. Nothing written.`;
118
- plans.push({ path: ch.path, abs: p, type, before, after: res.text, existed: true });
279
+ plans.push({ path: ch.path, abs: p, type, before: before.text, beforeMode: before.mode, beforeIdentity: { dev: before.dev, ino: before.ino, mode: before.mode, nlink: before.nlink }, after: res.text, existed: true });
119
280
  }
120
281
  }
121
282
  }
@@ -125,31 +286,40 @@ registerTool({
125
286
  try {
126
287
  for (const pl of plans) {
127
288
  if (pl.type === "delete") {
128
- let current;
129
- try {
130
- current = await readFile(pl.abs, "utf8");
131
- }
132
- catch {
133
- throw new FileChangedError(pl.path);
134
- }
135
- if (current !== pl.before)
136
- throw new FileChangedError(pl.path);
137
- await unlink(pl.abs);
289
+ // Atomic move-then-inspect: a replacement inode is moved aside and restored, never unlinked.
290
+ // The expected inode remains quarantined until every other patch step has committed.
291
+ await quarantineExpectedDelete(pl);
138
292
  }
139
293
  else {
140
- await atomicWriteText(pl.abs, pl.after, { expected: pl.existed ? pl.before : null });
294
+ pl.committed = await atomicWriteText(pl.abs, pl.after, {
295
+ expected: pl.existed ? pl.before : null,
296
+ expectedIdentity: pl.existed ? pl.beforeIdentity : undefined,
297
+ });
141
298
  }
142
299
  applied.push(pl);
143
300
  }
144
301
  }
145
302
  catch (e) {
146
303
  const rollbackFailures = [];
147
- for (const pl of applied.reverse()) {
304
+ // A delete becomes a visible mutation at its first rename, before its verification can finish. Include
305
+ // that partially staged plan in rollback instead of claiming that an empty `applied` list means the
306
+ // tree was untouched.
307
+ const rollbackPlans = [...applied];
308
+ for (const pl of plans) {
309
+ if (pl.quarantine && !rollbackPlans.includes(pl))
310
+ rollbackPlans.push(pl);
311
+ }
312
+ for (const pl of rollbackPlans.reverse()) {
148
313
  try {
149
- if (pl.type === "create" && !pl.existed)
150
- await unlink(pl.abs); // remove a file we created
151
- else
152
- await atomicWriteText(pl.abs, pl.before); // restore an updated/deleted file's prior content
314
+ if (pl.type === "delete") {
315
+ if (!pl.quarantine)
316
+ throw new Error(`missing delete quarantine for ${pl.path}`);
317
+ const claimed = await claimDeleteQuarantine(pl, "restore");
318
+ await restoreMovedFile(claimed, pl.abs);
319
+ }
320
+ else {
321
+ await rollbackWrittenPlan(pl);
322
+ }
153
323
  }
154
324
  catch (rollbackError) {
155
325
  rollbackFailures.push(`${pl.path}: ${rollbackError?.message ?? String(rollbackError)}`);
@@ -160,13 +330,42 @@ registerTool({
160
330
  }
161
331
  return `Error: apply_patch failed writing a file (${e instanceof Error ? e.message : String(e)}) — rolled back, nothing left changed.`;
162
332
  }
333
+ // Deletes become final only after every commit step succeeds. Cleanup cannot affect visible paths;
334
+ // report a rare failure rather than rolling back after another quarantine may already be removed.
335
+ const cleanupFailures = [];
336
+ for (const pl of plans) {
337
+ for (const warning of pl.committed?.warnings ?? [])
338
+ cleanupFailures.push(`${pl.path}: ${warning}`);
339
+ }
340
+ for (const pl of plans) {
341
+ if (!pl.quarantine)
342
+ continue;
343
+ try {
344
+ const claimed = await claimDeleteQuarantine(pl, "cleanup");
345
+ if (!pl.beforePathIdentity)
346
+ throw new Error(`missing delete path identity for ${pl.path}`);
347
+ discardClaimedPath(claimed, { ...pl.beforePathIdentity, linkTarget: pl.beforeLinkTarget });
348
+ }
349
+ catch (error) {
350
+ cleanupFailures.push(`${pl.path}: old entry cleanup was refused (${error?.message ?? String(error)})`);
351
+ }
352
+ }
163
353
  // All writes succeeded → now show diffs + record the undo snapshot.
164
354
  const summary = plans.map((pl) => {
165
355
  emitDiff(pl.path, pl.before, pl.type === "delete" ? "" : pl.after, ctx.ui);
166
356
  return pl.type === "delete" ? `deleted ${pl.path}` : `${pl.type === "create" ? "created" : "updated"} ${pl.path}`;
167
357
  });
168
- recordEdit(plans.map((pl) => ({ path: pl.path, absPath: pl.abs, before: pl.existed ? pl.before : null })));
358
+ recordEdit(plans.map((pl) => ({
359
+ path: pl.path,
360
+ absPath: pl.abs,
361
+ before: pl.existed ? pl.before : null,
362
+ beforeMode: pl.beforeMode,
363
+ linkTarget: pl.type === "delete" ? pl.beforeLinkTarget : undefined,
364
+ removed: pl.type === "delete",
365
+ committed: pl.committed,
366
+ after: pl.after ?? undefined,
367
+ })));
169
368
  invalidateFileCandidates(ctx.cwd);
170
- return `apply_patch: ${plans.length} file(s) — ${summary.join("; ")}.`;
369
+ return `apply_patch: ${plans.length} file(s) — ${summary.join("; ")}.` + (cleanupFailures.length ? ` Warning: ${cleanupFailures.join("; ")}` : "");
171
370
  },
172
371
  });