@nanhara/hara 0.122.3 → 0.122.4
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 +98 -0
- package/README.md +15 -2
- package/dist/agent/limits.js +51 -0
- package/dist/agent/loop.js +367 -112
- package/dist/agent/repeat-guard.js +49 -12
- package/dist/checkpoints.js +9 -0
- package/dist/cli.js +2 -1
- package/dist/config.js +10 -2
- package/dist/context/agents-md.js +21 -2
- package/dist/context/mentions.js +84 -1
- package/dist/context/workspace-scope.js +49 -0
- package/dist/cron/deliver.js +61 -38
- package/dist/cron/runner.js +423 -65
- package/dist/cron/schedule.js +23 -7
- package/dist/cron/store.js +709 -43
- package/dist/fs-walk.js +279 -7
- package/dist/fs-write.js +9 -0
- package/dist/gateway/feishu.js +351 -64
- package/dist/gateway/flows-pending.js +28 -14
- package/dist/gateway/flows.js +306 -31
- package/dist/gateway/outbound-files.js +20 -6
- package/dist/gateway/runtime-state.js +1485 -0
- package/dist/gateway/serve.js +550 -242
- package/dist/gateway/telegram.js +279 -29
- package/dist/gateway/tts.js +182 -38
- package/dist/hooks.js +22 -22
- package/dist/index.js +466 -158
- package/dist/memory/store.js +8 -5
- package/dist/org/planner.js +11 -6
- package/dist/process-identity.js +52 -0
- package/dist/providers/bounded-turn.js +42 -0
- package/dist/recall.js +69 -1
- package/dist/runtime.js +24 -0
- package/dist/sandbox.js +54 -4
- package/dist/search/embed.js +13 -2
- package/dist/search/hybrid.js +6 -3
- package/dist/search/semindex.js +87 -5
- package/dist/security/guardian.js +3 -15
- package/dist/security/permissions.js +11 -0
- package/dist/serve/server.js +70 -42
- package/dist/serve/sessions.js +4 -3
- package/dist/tools/agent.js +1 -1
- package/dist/tools/ask_user.js +5 -1
- package/dist/tools/builtin.js +5 -2
- package/dist/tools/codebase.js +67 -18
- package/dist/tools/computer.js +149 -68
- package/dist/tools/cron.js +72 -18
- package/dist/tools/edit.js +3 -2
- package/dist/tools/external_agent.js +66 -12
- package/dist/tools/memory.js +25 -7
- package/dist/tools/patch.js +11 -2
- package/dist/tools/registry.js +16 -1
- package/dist/tools/search.js +68 -9
- package/dist/tools/send.js +1 -1
- package/dist/tools/skill.js +1 -1
- package/dist/tools/web.js +43 -13
- package/dist/tui/App.js +93 -25
- package/dist/vision.js +5 -6
- package/package.json +2 -2
- package/runtime-bootstrap.cjs +22 -0
package/dist/tools/cron.js
CHANGED
|
@@ -7,11 +7,35 @@
|
|
|
7
7
|
import { registerTool } from "./registry.js";
|
|
8
8
|
import { addJob, loadJobs, resolveJob, removeJob, setEnabled } from "../cron/store.js";
|
|
9
9
|
import { parseSchedule, describeSchedule, nextRun, validTz } from "../cron/schedule.js";
|
|
10
|
-
import {
|
|
10
|
+
import { runJobTracked } from "../cron/runner.js";
|
|
11
11
|
import { parseDeliver } from "../cron/deliver.js";
|
|
12
12
|
import { isInstalled } from "../cron/install.js";
|
|
13
13
|
import { sensitiveShellCommandReason } from "../security/sensitive-files.js";
|
|
14
|
+
import { homeWorkspaceActionError, isHomeWorkspace } from "../context/workspace-scope.js";
|
|
14
15
|
const fmt = (ms) => (ms ? new Date(ms).toLocaleString() : "—");
|
|
16
|
+
const fmtNext = (ms, now) => (ms !== null && ms <= now ? "due now" : fmt(ms));
|
|
17
|
+
const duration = (ms) => {
|
|
18
|
+
if (ms === undefined)
|
|
19
|
+
return "";
|
|
20
|
+
if (ms >= 3_600_000)
|
|
21
|
+
return ` after ${Math.round(ms / 360_000) / 10}h`;
|
|
22
|
+
if (ms >= 60_000)
|
|
23
|
+
return ` after ${Math.round(ms / 6_000) / 10}m`;
|
|
24
|
+
if (ms >= 1_000)
|
|
25
|
+
return ` after ${Math.round(ms / 1_000)}s`;
|
|
26
|
+
return ` after ${ms}ms`;
|
|
27
|
+
};
|
|
28
|
+
const status = (j) => {
|
|
29
|
+
if (j.lastStatus === "running")
|
|
30
|
+
return `running since ${fmt(j.runningSince ?? j.lastRunAt ?? null)}`;
|
|
31
|
+
if (j.lastStatus === "timed_out")
|
|
32
|
+
return `timed out${duration(j.lastDurationMs)}`;
|
|
33
|
+
if (j.lastStatus === "error")
|
|
34
|
+
return `error${duration(j.lastDurationMs)}`;
|
|
35
|
+
if (j.lastStatus === "ok")
|
|
36
|
+
return `ok${duration(j.lastDurationMs)}`;
|
|
37
|
+
return "—";
|
|
38
|
+
};
|
|
15
39
|
registerTool({
|
|
16
40
|
name: "cronjob",
|
|
17
41
|
description: "Manage the user's scheduled jobs (hara cron). Use when they ask to schedule, automate, or be reminded of " +
|
|
@@ -19,7 +43,8 @@ registerTool({
|
|
|
19
43
|
"and `task`. Optional: `name`; `mode` — \"print\" (default: run task as a hara prompt), \"org\" (role-routed), or " +
|
|
20
44
|
"\"command\" (run task as a plain SHELL COMMAND — deterministic, no agent, no tokens; prefer it for fixed scripts); " +
|
|
21
45
|
"`tz` (IANA, e.g. Asia/Shanghai, for cron exprs); `deliver` (push each run's result: telegram:<chatId> | " +
|
|
22
|
-
"feishu:<chatId> | webhook:<url>)
|
|
46
|
+
"feishu:<chatId> | webhook:<url>); `alertAfter` (1..1000 consecutive failures, default 3). Other actions: " +
|
|
47
|
+
"list · remove · enable · disable · run (fire now), with `id`. " +
|
|
23
48
|
"Jobs fire via the OS scheduler even when hara isn't running.",
|
|
24
49
|
input_schema: {
|
|
25
50
|
type: "object",
|
|
@@ -31,6 +56,7 @@ registerTool({
|
|
|
31
56
|
mode: { type: "string", enum: ["print", "org", "command"] },
|
|
32
57
|
tz: { type: "string", description: "IANA timezone for cron exprs" },
|
|
33
58
|
deliver: { type: "string", description: "telegram:<chatId> | feishu:<chatId> | webhook:<url>" },
|
|
59
|
+
alertAfter: { type: "integer", minimum: 1, maximum: 1000, description: "consecutive failures before 🚨 (default 3)" },
|
|
34
60
|
id: { type: "string", description: "job id (or unique prefix) for remove/enable/disable/run" },
|
|
35
61
|
},
|
|
36
62
|
required: ["action"],
|
|
@@ -41,17 +67,28 @@ registerTool({
|
|
|
41
67
|
return "Error: cron-run sessions cannot manage cron jobs (recursion guard — a scheduled task must not schedule more tasks).";
|
|
42
68
|
const action = String(input.action ?? "");
|
|
43
69
|
if (action === "list") {
|
|
44
|
-
|
|
70
|
+
let jobs;
|
|
71
|
+
try {
|
|
72
|
+
jobs = loadJobs();
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
return `Error: ${error instanceof Error ? error.message : String(error)}`;
|
|
76
|
+
}
|
|
45
77
|
if (!jobs.length)
|
|
46
78
|
return "No scheduled jobs.";
|
|
47
79
|
return jobs
|
|
48
80
|
.map((j) => {
|
|
49
|
-
const
|
|
50
|
-
|
|
81
|
+
const now = Date.now();
|
|
82
|
+
const next = nextRun(j, now);
|
|
83
|
+
return `${j.id} · ${j.enabled ? "on " : "OFF"} · ${j.name} · ${describeSchedule(j.schedule)}${j.tz ? ` @ ${j.tz}` : ""} · mode ${j.mode}${j.deliver ? ` · → ${j.deliver}` : ""} · next ${fmtNext(next, now)} · last ${status(j)}${j.consecutiveErrors ? ` (${j.consecutiveErrors}✗)` : ""}`;
|
|
51
84
|
})
|
|
52
85
|
.join("\n");
|
|
53
86
|
}
|
|
54
87
|
if (action === "add") {
|
|
88
|
+
// Every new job persists ctx.cwd and later treats it as its implicit agent/shell workspace. Management
|
|
89
|
+
// of existing jobs remains available at Home, but creating a Home-root job would bypass project scope.
|
|
90
|
+
if (isHomeWorkspace(ctx.cwd))
|
|
91
|
+
return `Error: ${homeWorkspaceActionError("schedule a job")}`;
|
|
55
92
|
const scheduleStr = String(input.schedule ?? "").trim();
|
|
56
93
|
const task = String(input.task ?? "").trim();
|
|
57
94
|
if (!scheduleStr || !task)
|
|
@@ -70,30 +107,47 @@ registerTool({
|
|
|
70
107
|
if ("error" in d)
|
|
71
108
|
return `Error: ${d.error}`;
|
|
72
109
|
}
|
|
110
|
+
const alertAfter = input.alertAfter === undefined ? undefined : Number(input.alertAfter);
|
|
111
|
+
if (alertAfter !== undefined && (!Number.isInteger(alertAfter) || alertAfter < 1 || alertAfter > 1_000)) {
|
|
112
|
+
return "Error: `alertAfter` must be an integer from 1 to 1000.";
|
|
113
|
+
}
|
|
73
114
|
const mode = input.mode === "org" || input.mode === "command" ? input.mode : "print";
|
|
74
115
|
if (mode === "command") {
|
|
75
116
|
const denied = sensitiveShellCommandReason(task, ctx.cwd);
|
|
76
117
|
if (denied)
|
|
77
118
|
return `Error: scheduled shell command crosses Hara's protected secret boundary (${denied}).`;
|
|
78
119
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
120
|
+
let job;
|
|
121
|
+
try {
|
|
122
|
+
job = addJob({
|
|
123
|
+
name: input.name ? String(input.name) : task.slice(0, 48),
|
|
124
|
+
schedule: sched,
|
|
125
|
+
task,
|
|
126
|
+
mode,
|
|
127
|
+
cwd: ctx.cwd,
|
|
128
|
+
...(tz ? { tz } : {}),
|
|
129
|
+
...(deliver ? { deliver } : {}),
|
|
130
|
+
...(alertAfter !== undefined ? { alertAfter } : {}),
|
|
131
|
+
createdAt: Date.now(),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
return `Error: ${error instanceof Error ? error.message : String(error)}`;
|
|
136
|
+
}
|
|
89
137
|
const warn = isInstalled() ? "" : "\n⚠ The OS scheduler isn't installed yet — tell the user to run `hara cron install` once, or jobs won't fire.";
|
|
90
|
-
return `✓ scheduled ${job.id} · ${describeSchedule(sched)}${tz ? ` @ ${tz}` : ""} · mode ${mode}${deliver ? ` · → ${deliver}` : ""} · next ${fmt(nextRun(job, Date.now()))}${warn}`;
|
|
138
|
+
return `✓ scheduled ${job.id} · ${describeSchedule(sched)}${tz ? ` @ ${tz}` : ""} · mode ${mode}${deliver ? ` · → ${deliver}` : ""}${alertAfter !== undefined ? ` · alert ≥${alertAfter}` : ""} · next ${fmt(nextRun(job, Date.now()))}${warn}`;
|
|
91
139
|
}
|
|
92
140
|
// id-based actions
|
|
93
141
|
const idArg = String(input.id ?? "").trim();
|
|
94
142
|
if (!idArg)
|
|
95
143
|
return `Error: ${action} needs \`id\`.`;
|
|
96
|
-
|
|
144
|
+
let j;
|
|
145
|
+
try {
|
|
146
|
+
j = resolveJob(idArg);
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
return `Error: ${error instanceof Error ? error.message : String(error)}`;
|
|
150
|
+
}
|
|
97
151
|
if (j === "ambiguous")
|
|
98
152
|
return `Error: id "${idArg}" matches multiple jobs — use more characters.`;
|
|
99
153
|
if (!j)
|
|
@@ -105,7 +159,7 @@ registerTool({
|
|
|
105
159
|
return `✓ ${j.id} ${action}d`;
|
|
106
160
|
}
|
|
107
161
|
if (action === "run") {
|
|
108
|
-
const r = await
|
|
162
|
+
const r = await runJobTracked(j, { signal: ctx.signal });
|
|
109
163
|
return r.ok ? `✓ ran ${j.id} — ok\n${(r.output ?? "").trim().slice(-800)}` : `✗ ${j.id} failed: ${r.error}\n${(r.output ?? "").trim().slice(-800)}`;
|
|
110
164
|
}
|
|
111
165
|
return `Error: unknown action "${action}".`;
|
package/dist/tools/edit.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { isAbsolute, resolve } from "node:path";
|
|
2
2
|
import { registerTool } from "./registry.js";
|
|
3
|
-
import {
|
|
3
|
+
import { nearestPathsAsync } from "../fs-walk.js";
|
|
4
4
|
import { emitDiff } from "../diff.js";
|
|
5
5
|
import { applyEdits } from "./apply-core.js";
|
|
6
6
|
import { recordEdit } from "../undo.js";
|
|
@@ -55,7 +55,7 @@ registerTool({
|
|
|
55
55
|
snapshot = await readVerifiedRegularFileSnapshot(boundary.target, undefined, "edit");
|
|
56
56
|
}
|
|
57
57
|
catch (error) {
|
|
58
|
-
const near =
|
|
58
|
+
const near = await nearestPathsAsync(ctx.cwd, input.path, 3, { timeoutMs: 1_000, signal: ctx.signal });
|
|
59
59
|
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(", ")}?` : "");
|
|
60
60
|
}
|
|
61
61
|
const text = snapshot.text;
|
|
@@ -68,6 +68,7 @@ registerTool({
|
|
|
68
68
|
expected: text,
|
|
69
69
|
expectedIdentity: snapshot,
|
|
70
70
|
boundary,
|
|
71
|
+
signal: ctx.signal,
|
|
71
72
|
});
|
|
72
73
|
}
|
|
73
74
|
catch (error) {
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// Safety: kind:"exec" → inherits the loop's approval gate. It can read/write/run on the host, so it is the most
|
|
7
7
|
// privileged tool — and because fan-out sub-agents only get the read-only allow-list (READONLY_TOOLS), they
|
|
8
8
|
// never see this tool. Trust tiers (off|gated|full) gate the dangerous bypass/full-access sub-modes.
|
|
9
|
-
import { spawn
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
10
|
import { platform } from "node:os";
|
|
11
11
|
import { registerTool } from "./registry.js";
|
|
12
12
|
import { capHeadTail } from "./builtin.js";
|
|
@@ -42,17 +42,58 @@ export function appendBoundedExternalOutput(current, chunk, limit = EXTERNAL_CAP
|
|
|
42
42
|
}
|
|
43
43
|
/** Probe a CLI's availability via `<bin> --version` (cached per process). */
|
|
44
44
|
const availCache = new Map();
|
|
45
|
-
function available(bin) {
|
|
45
|
+
async function available(bin, signal) {
|
|
46
46
|
if (availCache.has(bin))
|
|
47
47
|
return availCache.get(bin);
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
48
|
+
if (signal?.aborted)
|
|
49
|
+
throw new Error("external agent interrupted before availability probe");
|
|
50
|
+
const ok = await new Promise((resolve, reject) => {
|
|
51
|
+
const processGroup = platform() !== "win32";
|
|
52
|
+
let child;
|
|
53
|
+
try {
|
|
54
|
+
child = spawn(bin, ["--version"], {
|
|
55
|
+
stdio: "ignore",
|
|
56
|
+
env: toolSubprocessEnv(),
|
|
57
|
+
detached: processGroup,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
resolve(false);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
let done = false;
|
|
65
|
+
let aborted = false;
|
|
66
|
+
let stopping = false;
|
|
67
|
+
let fallback;
|
|
68
|
+
const settle = (value) => {
|
|
69
|
+
if (done)
|
|
70
|
+
return;
|
|
71
|
+
done = true;
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
if (fallback)
|
|
74
|
+
clearTimeout(fallback);
|
|
75
|
+
signal?.removeEventListener("abort", abortProbe);
|
|
76
|
+
if (aborted)
|
|
77
|
+
reject(new Error("external agent interrupted during availability probe"));
|
|
78
|
+
else
|
|
79
|
+
resolve(value);
|
|
80
|
+
};
|
|
81
|
+
const stop = (fromAbort) => {
|
|
82
|
+
if (done || stopping)
|
|
83
|
+
return;
|
|
84
|
+
stopping = true;
|
|
85
|
+
aborted = fromAbort;
|
|
86
|
+
terminateSubprocessTree(child, { force: true, processGroup });
|
|
87
|
+
fallback = setTimeout(() => settle(false), 750);
|
|
88
|
+
};
|
|
89
|
+
const abortProbe = () => stop(true);
|
|
90
|
+
const timer = setTimeout(() => stop(false), 5_000);
|
|
91
|
+
signal?.addEventListener("abort", abortProbe, { once: true });
|
|
92
|
+
if (signal?.aborted)
|
|
93
|
+
abortProbe();
|
|
94
|
+
child.once("error", () => settle(false));
|
|
95
|
+
child.once("close", (code) => settle(code === 0));
|
|
96
|
+
});
|
|
56
97
|
availCache.set(bin, ok);
|
|
57
98
|
return ok;
|
|
58
99
|
}
|
|
@@ -68,6 +109,7 @@ registerTool({
|
|
|
68
109
|
"another agent to own end-to-end. It can read/write/run on the host, so it's gated by approval. " +
|
|
69
110
|
"Args: task (required), backend (claude|codex; default = first installed), model (optional).",
|
|
70
111
|
kind: "exec", // → approval gate; never exposed to read-only fan-out sub-agents
|
|
112
|
+
requiresProjectWorkspace: true,
|
|
71
113
|
trustBoundary: "external",
|
|
72
114
|
input_schema: {
|
|
73
115
|
type: "object",
|
|
@@ -80,6 +122,8 @@ registerTool({
|
|
|
80
122
|
required: ["task"],
|
|
81
123
|
},
|
|
82
124
|
async run(input, ctx) {
|
|
125
|
+
if (ctx.signal?.aborted)
|
|
126
|
+
return "[external agent] interrupted before start by agent run deadline or cancellation";
|
|
83
127
|
if (!ctx.ask && process.env.HARA_ALLOW_TRUSTED_EXTENSIONS !== "1") {
|
|
84
128
|
return ("Blocked: external_agent runs another host coding agent outside Hara's protected-file boundary. " +
|
|
85
129
|
"Use it interactively after approval, or restart with HARA_ALLOW_TRUSTED_EXTENSIONS=1 only after reviewing that agent.");
|
|
@@ -90,12 +134,17 @@ registerTool({
|
|
|
90
134
|
const task = typeof input.task === "string" ? input.task.trim() : "";
|
|
91
135
|
if (!task)
|
|
92
136
|
return "external_agent needs a non-empty `task`.";
|
|
93
|
-
const
|
|
137
|
+
const availability = await Promise.all(BUILTIN_BACKENDS.map((candidate) => available(candidate, ctx.signal)));
|
|
138
|
+
if (ctx.signal?.aborted)
|
|
139
|
+
return "[external agent] interrupted before start by agent run deadline or cancellation";
|
|
140
|
+
const installed = BUILTIN_BACKENDS.filter((_candidate, index) => availability[index]);
|
|
94
141
|
const backend = String(input.backend ?? "").trim() || installed[0] || "";
|
|
95
142
|
if (!BUILTIN_BACKENDS.includes(backend))
|
|
96
143
|
return `Unknown backend '${backend || "(none)"}'. Supported: ${BUILTIN_BACKENDS.join(", ")}.`;
|
|
97
|
-
if (!available(backend))
|
|
144
|
+
if (!(await available(backend, ctx.signal)))
|
|
98
145
|
return `'${backend}' CLI not found on PATH. Installed external agents: ${installed.join(", ") || "none"}.`;
|
|
146
|
+
if (ctx.signal?.aborted)
|
|
147
|
+
return "[external agent] interrupted before start by agent run deadline or cancellation";
|
|
99
148
|
const built = buildExternalArgv(backend, task, { cwd: ctx.cwd, model: input.model ? String(input.model) : undefined, sandbox: ctx.sandbox ?? "off", trust });
|
|
100
149
|
if (!built)
|
|
101
150
|
return `Unknown backend '${backend}'.`;
|
|
@@ -129,6 +178,7 @@ registerTool({
|
|
|
129
178
|
return;
|
|
130
179
|
done = true;
|
|
131
180
|
clearTimeout(timer);
|
|
181
|
+
ctx.signal?.removeEventListener("abort", abortRun);
|
|
132
182
|
// A stopped tree must still receive its scheduled group KILL even if the direct shell closed first.
|
|
133
183
|
// The default cancellation removes only the wall-clock fallback, not the force timer.
|
|
134
184
|
cancelTermination?.();
|
|
@@ -164,6 +214,10 @@ registerTool({
|
|
|
164
214
|
const timer = setTimeout(() => {
|
|
165
215
|
stopTree(`[${backend}] timed out after ${timeout}ms`);
|
|
166
216
|
}, timeout);
|
|
217
|
+
const abortRun = () => stopTree(`[${backend}] interrupted by agent run deadline or cancellation`);
|
|
218
|
+
ctx.signal?.addEventListener("abort", abortRun, { once: true });
|
|
219
|
+
if (ctx.signal?.aborted)
|
|
220
|
+
abortRun();
|
|
167
221
|
child.stdin?.on("error", () => { });
|
|
168
222
|
child.stdin?.end(); // task is passed via argv
|
|
169
223
|
child.stdout?.on("data", (d) => {
|
package/dist/tools/memory.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
4
|
import { isAbsolute, resolve, join, relative, sep } from "node:path";
|
|
5
5
|
import { registerTool } from "./registry.js";
|
|
6
|
-
import {
|
|
6
|
+
import { searchAssetsAsync, assetSearchRoots } from "../recall.js";
|
|
7
7
|
import { searchHybrid } from "../search/hybrid.js";
|
|
8
8
|
import { memoryRoots, appendMemory, replaceMemory, forgetMemory } from "../memory/store.js";
|
|
9
9
|
import { scanMemory, redactSecrets, scrubLocal } from "../memory/guard.js";
|
|
@@ -24,7 +24,7 @@ registerTool({
|
|
|
24
24
|
},
|
|
25
25
|
kind: "read",
|
|
26
26
|
async run(input, ctx) {
|
|
27
|
-
const hits = await searchHybrid(String(input.query ?? ""), ctx.cwd, { indexName: "memory", roots: memoryRoots(ctx.cwd), limit: Math.min(Number(input.limit) || 5, 10) });
|
|
27
|
+
const hits = await searchHybrid(String(input.query ?? ""), ctx.cwd, { indexName: "memory", roots: memoryRoots(ctx.cwd), limit: Math.min(Number(input.limit) || 5, 10), signal: ctx.signal });
|
|
28
28
|
if (!hits.length)
|
|
29
29
|
return "(no memory matches)";
|
|
30
30
|
return hits.map((h) => `${h.path} — ${h.title}\n${h.snippet}`).join("\n\n");
|
|
@@ -80,8 +80,8 @@ registerTool({
|
|
|
80
80
|
const scope = asScope(input.scope);
|
|
81
81
|
const target = asTarget(input.target);
|
|
82
82
|
const f = input.mode === "replace"
|
|
83
|
-
? await replaceMemory(scope, target, content, ctx.cwd)
|
|
84
|
-
: await appendMemory(scope, target, content, ctx.cwd);
|
|
83
|
+
? await replaceMemory(scope, target, content, ctx.cwd, ctx.signal)
|
|
84
|
+
: await appendMemory(scope, target, content, ctx.cwd, ctx.signal);
|
|
85
85
|
return `Saved to ${f}`;
|
|
86
86
|
},
|
|
87
87
|
});
|
|
@@ -126,13 +126,30 @@ registerTool({
|
|
|
126
126
|
const scope = input.scope === "project" ? "project" : "personal";
|
|
127
127
|
const dir = join(scope === "project" ? skillsDir(ctx.cwd) : globalSkillsDir(), slug);
|
|
128
128
|
const f = join(dir, "SKILL.md");
|
|
129
|
-
// dedup: surface a near-duplicate so the agent updates instead of piling up (lexical signal, not a block)
|
|
130
|
-
const dups = searchAssets(`${slug} ${description}`, 3, assetSearchRoots(ctx.cwd)).filter((h) => h.path !== f && h.score >= 2);
|
|
131
129
|
const skillText = `---\nname: ${slug}\ndescription: ${description}\n---\n\n${body.trim()}\n`;
|
|
132
130
|
let boundary;
|
|
133
131
|
let snapshot = null;
|
|
132
|
+
// Bind the destination before the first await. A project skills symlink can otherwise be retargeted
|
|
133
|
+
// while the asynchronous duplicate scan yields, changing where this write lands.
|
|
134
134
|
try {
|
|
135
135
|
boundary = bindAtomicWritePath(f, "create skill");
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
return `Error: cannot save skill ${slug}: ${error?.message ?? String(error)} No changes written.`;
|
|
139
|
+
}
|
|
140
|
+
let dups = [];
|
|
141
|
+
try {
|
|
142
|
+
// dedup: surface a near-duplicate so the agent updates instead of piling up (lexical signal, not a block)
|
|
143
|
+
dups = (await searchAssetsAsync(`${slug} ${description}`, 3, assetSearchRoots(ctx.cwd), { signal: ctx.signal }))
|
|
144
|
+
.filter((h) => h.path !== f && h.score >= 2);
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
if (ctx.signal?.aborted) {
|
|
148
|
+
return `Error: cannot save skill ${slug}: cancelled before commit. No changes written.`;
|
|
149
|
+
}
|
|
150
|
+
// Duplicate detection is advisory; an unreadable optional asset root must not prevent a safe write.
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
136
153
|
try {
|
|
137
154
|
snapshot = await readVerifiedRegularFileSnapshot(boundary.target, undefined, "create skill");
|
|
138
155
|
}
|
|
@@ -144,6 +161,7 @@ registerTool({
|
|
|
144
161
|
expected: snapshot?.text ?? null,
|
|
145
162
|
expectedIdentity: snapshot ?? undefined,
|
|
146
163
|
boundary,
|
|
164
|
+
signal: ctx.signal,
|
|
147
165
|
});
|
|
148
166
|
}
|
|
149
167
|
catch (error) {
|
|
@@ -171,7 +189,7 @@ registerTool({
|
|
|
171
189
|
},
|
|
172
190
|
kind: "edit",
|
|
173
191
|
async run(input, ctx) {
|
|
174
|
-
const n = await forgetMemory(asScope(input.scope), asTarget(input.target), String(input.match ?? ""), ctx.cwd);
|
|
192
|
+
const n = await forgetMemory(asScope(input.scope), asTarget(input.target), String(input.match ?? ""), ctx.cwd, ctx.signal);
|
|
175
193
|
return n ? `Removed ${n} line(s).` : "(no matching lines)";
|
|
176
194
|
},
|
|
177
195
|
});
|
package/dist/tools/patch.js
CHANGED
|
@@ -86,7 +86,7 @@ async function rollbackWrittenPlan(plan) {
|
|
|
86
86
|
}
|
|
87
87
|
discardClaimedPath(quarantine, plan.committed);
|
|
88
88
|
}
|
|
89
|
-
async function quarantineExpectedDelete(plan) {
|
|
89
|
+
async function quarantineExpectedDelete(plan, signal) {
|
|
90
90
|
if (!plan.beforeIdentity || !plan.beforePathIdentity || plan.beforeMode === undefined)
|
|
91
91
|
throw new Error(`missing preflight identity for ${plan.path}`);
|
|
92
92
|
if (!plan.writeBoundary)
|
|
@@ -96,6 +96,8 @@ async function quarantineExpectedDelete(plan) {
|
|
|
96
96
|
// phases. Recording it immediately also lets the outer rollback recover (or accurately report) a failure
|
|
97
97
|
// that happens after rename but before verification completes.
|
|
98
98
|
const staging = join(dirname(plan.abs), `.hara-stage-delete-${process.pid}-${randomUUID()}.tmp`);
|
|
99
|
+
if (signal?.aborted)
|
|
100
|
+
throw new Error("apply_patch delete cancelled before commit");
|
|
99
101
|
verifyAtomicWriteBoundary(plan.writeBoundary);
|
|
100
102
|
renameSync(plan.abs, staging);
|
|
101
103
|
plan.quarantine = staging;
|
|
@@ -112,6 +114,8 @@ async function quarantineExpectedDelete(plan) {
|
|
|
112
114
|
throw new FileChangedError(plan.path);
|
|
113
115
|
}
|
|
114
116
|
const quarantine = join(dirname(plan.abs), `.hara-delete-${process.pid}-${randomUUID()}.tmp`);
|
|
117
|
+
if (signal?.aborted)
|
|
118
|
+
throw new Error("apply_patch delete cancelled during verification");
|
|
115
119
|
verifyAtomicWriteBoundary(plan.writeBoundary);
|
|
116
120
|
renameSync(staging, quarantine);
|
|
117
121
|
plan.quarantine = quarantine;
|
|
@@ -304,19 +308,24 @@ registerTool({
|
|
|
304
308
|
const applied = [];
|
|
305
309
|
try {
|
|
306
310
|
for (const pl of plans) {
|
|
311
|
+
if (ctx.signal?.aborted)
|
|
312
|
+
throw new Error("apply_patch cancelled before commit");
|
|
307
313
|
if (pl.type === "delete") {
|
|
308
314
|
// Atomic move-then-inspect: a replacement inode is moved aside and restored, never unlinked.
|
|
309
315
|
// The expected inode remains quarantined until every other patch step has committed.
|
|
310
|
-
await quarantineExpectedDelete(pl);
|
|
316
|
+
await quarantineExpectedDelete(pl, ctx.signal);
|
|
311
317
|
}
|
|
312
318
|
else {
|
|
313
319
|
pl.committed = await atomicWriteText(pl.abs, pl.after, {
|
|
314
320
|
expected: pl.existed ? pl.before : null,
|
|
315
321
|
expectedIdentity: pl.existed ? pl.beforeIdentity : undefined,
|
|
316
322
|
boundary: pl.writeBoundary,
|
|
323
|
+
signal: ctx.signal,
|
|
317
324
|
});
|
|
318
325
|
}
|
|
319
326
|
applied.push(pl);
|
|
327
|
+
if (ctx.signal?.aborted)
|
|
328
|
+
throw new Error("apply_patch cancelled during commit; rolling back");
|
|
320
329
|
}
|
|
321
330
|
}
|
|
322
331
|
catch (e) {
|
package/dist/tools/registry.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { limitToolResult } from "./result-limit.js";
|
|
2
|
+
import { homeWorkspaceActionError, isHomeWorkspace } from "../context/workspace-scope.js";
|
|
2
3
|
/** Names of required parameters that are ABSENT (undefined/null) in a tool call's input. Defends
|
|
3
4
|
* against models that drop parameters outright (observed: qwen3.7-plus losing write_file's
|
|
4
5
|
* path/content mid-stream) — the loop rejects the call with a precise error instead of executing
|
|
@@ -17,7 +18,21 @@ export function registerTool(t) {
|
|
|
17
18
|
...t,
|
|
18
19
|
// Apply the context boundary at registration so every caller (main loop, tests, embedders) gets
|
|
19
20
|
// identical behavior instead of relying on one orchestration path to remember the cap.
|
|
20
|
-
run: async (input, ctx) =>
|
|
21
|
+
run: async (input, ctx) => {
|
|
22
|
+
// A tool reference can be retained by a non-cooperative wrapper and invoked after runAgent has already
|
|
23
|
+
// returned its deadline outcome. Gate at the registered call boundary (not just in the agent loop) so
|
|
24
|
+
// delayed getTool(...).run(...) calls never start fresh work after cancellation.
|
|
25
|
+
if (ctx.signal?.aborted) {
|
|
26
|
+
return `Error: ${t.name} cancelled before execution because the agent run has ended.`;
|
|
27
|
+
}
|
|
28
|
+
// Only explicitly project-scoped executable tools are blocked. `kind:"exec"` is also used for safe
|
|
29
|
+
// management/delivery side effects, so it must not itself imply a project workspace requirement.
|
|
30
|
+
// Canonical comparison closes Home symlink aliases.
|
|
31
|
+
if (t.requiresProjectWorkspace && isHomeWorkspace(ctx.cwd)) {
|
|
32
|
+
return `Error: ${homeWorkspaceActionError(`run ${t.name}`)}`;
|
|
33
|
+
}
|
|
34
|
+
return limitToolResult(await run(input, ctx));
|
|
35
|
+
},
|
|
21
36
|
});
|
|
22
37
|
specsCache = null;
|
|
23
38
|
}
|
package/dist/tools/search.js
CHANGED
|
@@ -4,15 +4,17 @@ import { lstatSync, readdirSync, statSync } from "node:fs";
|
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
5
|
import { basename, dirname, isAbsolute, resolve, join, relative, sep } from "node:path";
|
|
6
6
|
import { registerTool } from "./registry.js";
|
|
7
|
-
import { IGNORE_DIRS,
|
|
7
|
+
import { IGNORE_DIRS, walkFilesAsync } from "../fs-walk.js";
|
|
8
8
|
import { isSensitiveFilePath, sensitiveFileError, sensitiveFilesAllowed, SENSITIVE_SEARCH_GLOBS, } from "../security/sensitive-files.js";
|
|
9
9
|
import { toolSubprocessEnv } from "../security/subprocess-env.js";
|
|
10
|
+
import { recursiveRootContainsHome, recursiveHomeSearchError } from "../context/workspace-scope.js";
|
|
10
11
|
const MAX_OUT = 60_000;
|
|
11
12
|
const MAX_MATCHES = 300;
|
|
12
13
|
const MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
13
14
|
const GREP_TIMEOUT_MS = 5_000;
|
|
14
15
|
const MAX_PATTERN_CHARS = 4_096;
|
|
15
16
|
const MAX_GLOB_CHARS = 256;
|
|
17
|
+
const GLOB_SCAN_BUDGET_MS = 1_000;
|
|
16
18
|
const GLOB_MATCH_BUDGET_MS = 250;
|
|
17
19
|
const toPosix = (p) => (sep === "/" ? p : p.split(sep).join("/"));
|
|
18
20
|
const absOf = (p, cwd) => (p ? (isAbsolute(p) ? p : resolve(cwd, p)) : cwd);
|
|
@@ -368,12 +370,32 @@ function normalizeGrepWorkerResult(parsed) {
|
|
|
368
370
|
error: typeof parsed.error === "string" ? parsed.error : undefined,
|
|
369
371
|
};
|
|
370
372
|
}
|
|
371
|
-
function runNodeGrep(pattern, root, isFile, cwd, glob, ignoreCase) {
|
|
373
|
+
async function runNodeGrep(pattern, root, isFile, cwd, glob, ignoreCase, signal) {
|
|
372
374
|
const allowSensitive = sensitiveFilesAllowed();
|
|
373
|
-
|
|
374
|
-
|
|
375
|
+
if (signal?.aborted)
|
|
376
|
+
throw signal.reason instanceof Error ? signal.reason : new Error("grep cancelled");
|
|
377
|
+
const inventory = isFile
|
|
378
|
+
? undefined
|
|
379
|
+
: await walkFilesAsync(root, {
|
|
380
|
+
maxFiles: 8_001,
|
|
381
|
+
maxDirectories: 20_000,
|
|
382
|
+
maxEntries: 100_000,
|
|
383
|
+
timeoutMs: GREP_TIMEOUT_MS,
|
|
384
|
+
signal,
|
|
385
|
+
yieldEvery: 64,
|
|
386
|
+
});
|
|
387
|
+
const discovered = isFile ? [root] : inventory.files.map((path) => join(root, path));
|
|
388
|
+
const candidatesTruncated = !isFile && (inventory.truncated || discovered.length > 8_000);
|
|
375
389
|
const files = [];
|
|
390
|
+
let candidateIndex = 0;
|
|
376
391
|
for (const path of discovered.slice(0, 8_000)) {
|
|
392
|
+
if (signal?.aborted)
|
|
393
|
+
throw signal.reason instanceof Error ? signal.reason : new Error("grep cancelled");
|
|
394
|
+
if (candidateIndex++ > 0 && candidateIndex % 64 === 0) {
|
|
395
|
+
await new Promise((resolveTurn) => setImmediate(resolveTurn));
|
|
396
|
+
if (signal?.aborted)
|
|
397
|
+
throw signal.reason instanceof Error ? signal.reason : new Error("grep cancelled");
|
|
398
|
+
}
|
|
377
399
|
// Broad searches intentionally omit every .env variant, including safe templates. Explicitly searching
|
|
378
400
|
// one safe template remains supported, matching the ripgrep branch.
|
|
379
401
|
const securityBase = basename(path).replace(/:.*$/u, "").replace(/[. ]+$/u, "").toLowerCase();
|
|
@@ -410,7 +432,7 @@ function runNodeGrep(pattern, root, isFile, cwd, glob, ignoreCase) {
|
|
|
410
432
|
if (Buffer.byteLength(payload, "utf8") > 16 * 1024 * 1024) {
|
|
411
433
|
return Promise.resolve({ kind: "error", lines: [], matches: 0, scanned: 0, truncated: false, error: "grep input exceeds its safety limit" });
|
|
412
434
|
}
|
|
413
|
-
return new Promise((resolveResult) => {
|
|
435
|
+
return new Promise((resolveResult, rejectResult) => {
|
|
414
436
|
const isBun = typeof process.versions.bun === "string";
|
|
415
437
|
const args = isBun ? ["-e", NODE_GREP_SOURCE] : ["--max-old-space-size=128", "-e", NODE_GREP_SOURCE];
|
|
416
438
|
// In a Bun --compile build process.execPath is the hara executable. BUN_BE_BUN makes that embedded
|
|
@@ -422,14 +444,27 @@ function runNodeGrep(pattern, root, isFile, cwd, glob, ignoreCase) {
|
|
|
422
444
|
let settled = false;
|
|
423
445
|
let timedOut = false;
|
|
424
446
|
let timer;
|
|
447
|
+
const onAbort = () => {
|
|
448
|
+
child.kill("SIGKILL");
|
|
449
|
+
if (settled)
|
|
450
|
+
return;
|
|
451
|
+
settled = true;
|
|
452
|
+
if (timer)
|
|
453
|
+
clearTimeout(timer);
|
|
454
|
+
rejectResult(signal?.reason instanceof Error ? signal.reason : new Error("grep cancelled"));
|
|
455
|
+
};
|
|
425
456
|
const finish = (result) => {
|
|
426
457
|
if (settled)
|
|
427
458
|
return;
|
|
428
459
|
settled = true;
|
|
429
460
|
if (timer)
|
|
430
461
|
clearTimeout(timer);
|
|
462
|
+
signal?.removeEventListener("abort", onAbort);
|
|
431
463
|
resolveResult(result);
|
|
432
464
|
};
|
|
465
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
466
|
+
if (signal?.aborted)
|
|
467
|
+
return onAbort();
|
|
433
468
|
child.stdout.setEncoding("utf8");
|
|
434
469
|
child.stdout.on("data", (chunk) => {
|
|
435
470
|
stdout += chunk;
|
|
@@ -503,6 +538,8 @@ registerTool({
|
|
|
503
538
|
catch {
|
|
504
539
|
return `Error: no such path: ${input.path ?? "."}`;
|
|
505
540
|
}
|
|
541
|
+
if (!isFile && recursiveRootContainsHome(root))
|
|
542
|
+
return recursiveHomeSearchError("grep");
|
|
506
543
|
const searchArgs = [
|
|
507
544
|
pattern,
|
|
508
545
|
root,
|
|
@@ -517,7 +554,7 @@ registerTool({
|
|
|
517
554
|
// opt back into only with the explicit one-process sensitive-file waiver.
|
|
518
555
|
const primary = sensitiveFilesAllowed()
|
|
519
556
|
? await runRipgrep(...searchArgs)
|
|
520
|
-
: await runNodeGrep(...searchArgs);
|
|
557
|
+
: await runNodeGrep(...searchArgs, ctx.signal);
|
|
521
558
|
if (primary.kind === "timeout")
|
|
522
559
|
return `Error: grep exceeded its ${GREP_TIMEOUT_MS}ms safety timeout and was stopped.`;
|
|
523
560
|
if (primary.kind === "invalid")
|
|
@@ -525,7 +562,7 @@ registerTool({
|
|
|
525
562
|
let { lines, matches, scanned } = primary;
|
|
526
563
|
let truncated = primary.truncated;
|
|
527
564
|
if (sensitiveFilesAllowed() && (primary.kind === "missing" || primary.kind === "error")) {
|
|
528
|
-
const fallback = await runNodeGrep(pattern, root, isFile, ctx.cwd, typeof input.glob === "string" ? input.glob : undefined, input.ignore_case === true);
|
|
565
|
+
const fallback = await runNodeGrep(pattern, root, isFile, ctx.cwd, typeof input.glob === "string" ? input.glob : undefined, input.ignore_case === true, ctx.signal);
|
|
529
566
|
if (fallback.kind === "timeout")
|
|
530
567
|
return `Error: grep exceeded its ${GREP_TIMEOUT_MS}ms safety timeout and was stopped.`;
|
|
531
568
|
if (fallback.kind === "invalid")
|
|
@@ -568,15 +605,30 @@ registerTool({
|
|
|
568
605
|
const denied = sensitiveFileError(root, "search");
|
|
569
606
|
if (denied)
|
|
570
607
|
return denied;
|
|
608
|
+
if (recursiveRootContainsHome(root))
|
|
609
|
+
return recursiveHomeSearchError("glob");
|
|
571
610
|
const pattern = typeof input.pattern === "string" ? input.pattern : "";
|
|
572
611
|
if (pattern.length > MAX_GLOB_CHARS)
|
|
573
612
|
return `Error: glob pattern exceeds ${MAX_GLOB_CHARS} characters.`;
|
|
574
|
-
const
|
|
613
|
+
const inventory = await walkFilesAsync(root, {
|
|
614
|
+
maxFiles: 8_000,
|
|
615
|
+
maxDirectories: 20_000,
|
|
616
|
+
maxEntries: 100_000,
|
|
617
|
+
timeoutMs: GLOB_SCAN_BUDGET_MS,
|
|
618
|
+
signal: ctx.signal,
|
|
619
|
+
yieldEvery: 64,
|
|
620
|
+
});
|
|
621
|
+
const files = inventory.files;
|
|
575
622
|
const hits = [];
|
|
576
623
|
const started = Date.now();
|
|
577
624
|
let examined = 0;
|
|
578
625
|
let budgetReached = false;
|
|
579
626
|
for (const file of files) {
|
|
627
|
+
if ((examined & 31) === 0) {
|
|
628
|
+
await new Promise((resolveTurn) => setImmediate(resolveTurn));
|
|
629
|
+
if (ctx.signal?.aborted)
|
|
630
|
+
throw ctx.signal.reason instanceof Error ? ctx.signal.reason : new Error("glob cancelled");
|
|
631
|
+
}
|
|
580
632
|
if ((examined & 31) === 0 && Date.now() - started >= GLOB_MATCH_BUDGET_MS) {
|
|
581
633
|
budgetReached = true;
|
|
582
634
|
break;
|
|
@@ -585,7 +637,12 @@ registerTool({
|
|
|
585
637
|
if (matchesGlob(pattern, file))
|
|
586
638
|
hits.push(file);
|
|
587
639
|
}
|
|
640
|
+
const scanNote = inventory.truncated
|
|
641
|
+
? `scan stopped at its ${inventory.reason?.replace("_", " ") ?? "safety limit"}`
|
|
642
|
+
: "";
|
|
588
643
|
if (!hits.length) {
|
|
644
|
+
if (scanNote)
|
|
645
|
+
return `No files matched ${pattern}; ${scanNote}. Narrow \`path\` and retry.`;
|
|
589
646
|
return budgetReached
|
|
590
647
|
? `No files matched ${pattern} before the ${GLOB_MATCH_BUDGET_MS}ms safety budget; narrow \`path\` or simplify the glob.`
|
|
591
648
|
: `No files match ${pattern}.`;
|
|
@@ -593,7 +650,9 @@ registerTool({
|
|
|
593
650
|
const shown = hits.slice(0, 400);
|
|
594
651
|
const head = budgetReached
|
|
595
652
|
? `(showing ${shown.length} matches found before the ${GLOB_MATCH_BUDGET_MS}ms safety budget; examined ${examined}/${files.length} files)\n`
|
|
596
|
-
:
|
|
653
|
+
: scanNote
|
|
654
|
+
? `(showing ${shown.length} matches; ${scanNote})\n`
|
|
655
|
+
: hits.length > shown.length ? `(${hits.length} matches, showing 400)\n` : "";
|
|
597
656
|
return head + shown.join("\n");
|
|
598
657
|
},
|
|
599
658
|
});
|
package/dist/tools/send.js
CHANGED
|
@@ -27,7 +27,7 @@ if (process.env.HARA_GATEWAY) {
|
|
|
27
27
|
return "send_file only works inside `hara gateway` — there is no chat to send to here.";
|
|
28
28
|
const p = resolve(ctx.cwd, String(input.path ?? ""));
|
|
29
29
|
try {
|
|
30
|
-
await queueOutboundSnapshot(p, outbox);
|
|
30
|
+
await queueOutboundSnapshot(p, outbox, ctx.signal);
|
|
31
31
|
return `Queued a private snapshot for delivery to the user in this chat: ${p}`;
|
|
32
32
|
}
|
|
33
33
|
catch (error) {
|