@nanhara/hara 0.122.2 → 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.
Files changed (64) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/README.md +15 -2
  3. package/dist/agent/limits.js +51 -0
  4. package/dist/agent/loop.js +367 -112
  5. package/dist/agent/repeat-guard.js +49 -12
  6. package/dist/checkpoints.js +9 -0
  7. package/dist/cli.js +2 -1
  8. package/dist/config.js +10 -2
  9. package/dist/context/agents-md.js +21 -2
  10. package/dist/context/mentions.js +84 -1
  11. package/dist/context/workspace-scope.js +49 -0
  12. package/dist/cron/deliver.js +61 -38
  13. package/dist/cron/runner.js +423 -65
  14. package/dist/cron/schedule.js +23 -7
  15. package/dist/cron/store.js +709 -43
  16. package/dist/fs-walk.js +279 -7
  17. package/dist/fs-write.js +9 -0
  18. package/dist/gateway/feishu.js +351 -64
  19. package/dist/gateway/flows-pending.js +31 -17
  20. package/dist/gateway/flows.js +306 -31
  21. package/dist/gateway/outbound-files.js +20 -6
  22. package/dist/gateway/runtime-state.js +1485 -0
  23. package/dist/gateway/serve.js +550 -242
  24. package/dist/gateway/sessions.js +3 -3
  25. package/dist/gateway/telegram.js +279 -29
  26. package/dist/gateway/tts.js +182 -38
  27. package/dist/hooks.js +22 -22
  28. package/dist/index.js +466 -158
  29. package/dist/memory/store.js +8 -5
  30. package/dist/org/planner.js +11 -6
  31. package/dist/org/projects.js +3 -3
  32. package/dist/process-identity.js +52 -0
  33. package/dist/providers/bounded-turn.js +42 -0
  34. package/dist/recall.js +69 -1
  35. package/dist/runtime.js +24 -0
  36. package/dist/sandbox.js +54 -4
  37. package/dist/search/embed.js +13 -2
  38. package/dist/search/hybrid.js +6 -3
  39. package/dist/search/semindex.js +87 -5
  40. package/dist/security/guardian.js +3 -15
  41. package/dist/security/permissions.js +11 -0
  42. package/dist/serve/server.js +70 -42
  43. package/dist/serve/sessions.js +4 -3
  44. package/dist/sync-sleep.js +46 -0
  45. package/dist/tools/agent.js +1 -1
  46. package/dist/tools/ask_user.js +5 -1
  47. package/dist/tools/builtin.js +5 -2
  48. package/dist/tools/codebase.js +67 -18
  49. package/dist/tools/computer.js +149 -68
  50. package/dist/tools/cron.js +72 -18
  51. package/dist/tools/edit.js +3 -2
  52. package/dist/tools/external_agent.js +66 -12
  53. package/dist/tools/memory.js +25 -7
  54. package/dist/tools/patch.js +11 -2
  55. package/dist/tools/registry.js +16 -1
  56. package/dist/tools/search.js +68 -9
  57. package/dist/tools/send.js +1 -1
  58. package/dist/tools/skill.js +1 -1
  59. package/dist/tools/task.js +3 -3
  60. package/dist/tools/web.js +43 -13
  61. package/dist/tui/App.js +93 -25
  62. package/dist/vision.js +5 -6
  63. package/package.json +2 -2
  64. package/runtime-bootstrap.cjs +22 -0
@@ -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 { runJobOnce } from "../cron/runner.js";
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>). Other actions: list · remove · enable · disable · run (fire now), with `id`. " +
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
- const jobs = loadJobs();
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 next = nextRun(j, Date.now());
50
- return `${j.id} · ${j.enabled ? "on " : "OFF"} · ${j.name} · ${describeSchedule(j.schedule)}${j.tz ? ` @ ${j.tz}` : ""} · mode ${j.mode}${j.deliver ? ` · → ${j.deliver}` : ""} · next ${fmt(next)} · last ${j.lastStatus ?? "—"}${j.consecutiveErrors ? ` (${j.consecutiveErrors}✗)` : ""}`;
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
- const job = addJob({
80
- name: input.name ? String(input.name) : task.slice(0, 48),
81
- schedule: sched,
82
- task,
83
- mode,
84
- cwd: ctx.cwd,
85
- ...(tz ? { tz } : {}),
86
- ...(deliver ? { deliver } : {}),
87
- createdAt: Date.now(),
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
- const j = resolveJob(idArg);
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 runJobOnce(j);
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}".`;
@@ -1,6 +1,6 @@
1
1
  import { isAbsolute, resolve } from "node:path";
2
2
  import { registerTool } from "./registry.js";
3
- import { nearestPaths } from "../fs-walk.js";
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 = nearestPaths(ctx.cwd, input.path);
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, execFileSync } from "node:child_process";
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
- let ok = false;
49
- try {
50
- execFileSync(bin, ["--version"], { stdio: "ignore", timeout: 5000, env: toolSubprocessEnv() });
51
- ok = true;
52
- }
53
- catch {
54
- ok = false;
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 installed = BUILTIN_BACKENDS.filter((b) => available(b));
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) => {
@@ -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 { searchAssets, assetSearchRoots } from "../recall.js";
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
  });
@@ -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) {
@@ -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) => limitToolResult(await run(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
  }
@@ -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, walkFiles } from "../fs-walk.js";
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
- const discovered = isFile ? [root] : walkFiles(root, 8_001).map((path) => join(root, path));
374
- const candidatesTruncated = !isFile && discovered.length > 8_000;
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 files = walkFiles(root);
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
- : hits.length > shown.length ? `(${hits.length} matches, showing 400)\n` : "";
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
  });
@@ -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) {