@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.
- package/CHANGELOG.md +85 -0
- package/README.md +40 -6
- package/dist/agent/failover.js +1 -1
- package/dist/agent/loop.js +158 -21
- package/dist/agent/reminders.js +22 -7
- package/dist/agent/repeat-guard.js +26 -7
- package/dist/agent/structured.js +231 -0
- package/dist/agent/touched.js +20 -6
- package/dist/config.js +62 -26
- package/dist/cron/deliver.js +37 -3
- package/dist/feedback.js +5 -9
- package/dist/fs-read.js +106 -12
- package/dist/fs-write.js +242 -16
- package/dist/gateway/dingtalk.js +4 -1
- package/dist/gateway/discord.js +53 -18
- package/dist/gateway/feishu.js +158 -57
- package/dist/gateway/flows-pending.js +720 -0
- package/dist/gateway/flows.js +391 -16
- package/dist/gateway/matrix.js +80 -15
- package/dist/gateway/mattermost.js +44 -32
- package/dist/gateway/media.js +659 -0
- package/dist/gateway/serve.js +657 -162
- package/dist/gateway/sessions.js +475 -78
- package/dist/gateway/signal.js +27 -22
- package/dist/gateway/slack.js +26 -17
- package/dist/gateway/telegram.js +32 -18
- package/dist/gateway/wecom.js +32 -24
- package/dist/gateway/weixin.js +127 -49
- package/dist/hooks.js +32 -20
- package/dist/index.js +640 -219
- package/dist/org/projects.js +347 -0
- package/dist/org/roles.js +38 -11
- package/dist/security/secrets.js +150 -0
- package/dist/serve/server.js +772 -317
- package/dist/serve/sessions.js +112 -28
- package/dist/session/store.js +337 -44
- package/dist/tools/all.js +1 -0
- package/dist/tools/builtin.js +61 -23
- package/dist/tools/codebase.js +3 -1
- package/dist/tools/computer.js +98 -92
- package/dist/tools/edit.js +11 -8
- package/dist/tools/patch.js +230 -31
- package/dist/tools/search.js +482 -72
- package/dist/tools/task.js +453 -0
- package/dist/tools/todo.js +67 -16
- package/dist/tools/web.js +364 -64
- package/dist/tui/run.js +26 -23
- package/dist/undo.js +83 -7
- package/package.json +1 -1
package/dist/tools/computer.js
CHANGED
|
@@ -284,98 +284,104 @@ export function computerBackends() {
|
|
|
284
284
|
return "PowerShell (built-in)";
|
|
285
285
|
return `unsupported (${process.platform})`;
|
|
286
286
|
}
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
"
|
|
294
|
-
"
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
return
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
+
}
|
package/dist/tools/edit.js
CHANGED
|
@@ -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
|
|
47
|
+
let snapshot;
|
|
48
48
|
try {
|
|
49
|
-
|
|
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
|
});
|
package/dist/tools/patch.js
CHANGED
|
@@ -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 {
|
|
4
|
-
import {
|
|
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
|
-
|
|
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({
|
|
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
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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, {
|
|
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
|
-
|
|
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 === "
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
await
|
|
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) => ({
|
|
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
|
});
|