@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.
Files changed (60) hide show
  1. package/CHANGELOG.md +98 -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 +28 -14
  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/telegram.js +279 -29
  25. package/dist/gateway/tts.js +182 -38
  26. package/dist/hooks.js +22 -22
  27. package/dist/index.js +466 -158
  28. package/dist/memory/store.js +8 -5
  29. package/dist/org/planner.js +11 -6
  30. package/dist/process-identity.js +52 -0
  31. package/dist/providers/bounded-turn.js +42 -0
  32. package/dist/recall.js +69 -1
  33. package/dist/runtime.js +24 -0
  34. package/dist/sandbox.js +54 -4
  35. package/dist/search/embed.js +13 -2
  36. package/dist/search/hybrid.js +6 -3
  37. package/dist/search/semindex.js +87 -5
  38. package/dist/security/guardian.js +3 -15
  39. package/dist/security/permissions.js +11 -0
  40. package/dist/serve/server.js +70 -42
  41. package/dist/serve/sessions.js +4 -3
  42. package/dist/tools/agent.js +1 -1
  43. package/dist/tools/ask_user.js +5 -1
  44. package/dist/tools/builtin.js +5 -2
  45. package/dist/tools/codebase.js +67 -18
  46. package/dist/tools/computer.js +149 -68
  47. package/dist/tools/cron.js +72 -18
  48. package/dist/tools/edit.js +3 -2
  49. package/dist/tools/external_agent.js +66 -12
  50. package/dist/tools/memory.js +25 -7
  51. package/dist/tools/patch.js +11 -2
  52. package/dist/tools/registry.js +16 -1
  53. package/dist/tools/search.js +68 -9
  54. package/dist/tools/send.js +1 -1
  55. package/dist/tools/skill.js +1 -1
  56. package/dist/tools/web.js +43 -13
  57. package/dist/tui/App.js +93 -25
  58. package/dist/vision.js +5 -6
  59. package/package.json +2 -2
  60. package/runtime-bootstrap.cjs +22 -0
@@ -2,51 +2,95 @@
2
2
  // repo's code/text (respects .gitignore via listProjectFiles), ranked by how many distinct query words a
3
3
  // file contains, returning the densest snippet. Distinct from grep (exact pattern): this finds *related*
4
4
  // code from a natural-language query. The interface a semantic (zvec) index slots into later.
5
- import { readFileSync } from "node:fs";
6
- import { join } from "node:path";
5
+ import { statSync } from "node:fs";
6
+ import { isAbsolute, join, resolve } from "node:path";
7
7
  import { registerTool } from "./registry.js";
8
- import { listProjectFiles, isProbablyBinary, fileSize } from "../fs-walk.js";
8
+ import { listProjectFilesAsync } from "../fs-walk.js";
9
9
  import { findProjectRoot } from "../context/agents-md.js";
10
10
  import { loadConfig } from "../config.js";
11
11
  import { getEmbedder } from "../search/embed.js";
12
12
  import { queryIndex, indexExists } from "../search/semindex.js";
13
+ import { recursiveRootContainsHome, recursiveHomeSearchError } from "../context/workspace-scope.js";
14
+ import { readVerifiedRegularFileSnapshotSync } from "../fs-read.js";
13
15
  const MAX_FILE = 200_000; // skip very large files
16
+ const INVENTORY_TIMEOUT_MS = 2_000;
17
+ const LEXICAL_TIMEOUT_MS = 5_000;
14
18
  const CODE_RE = /\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|c|h|cc|cpp|hpp|cs|swift|scala|sh|bash|sql|md|mdx|json|ya?ml|toml|html|css|scss|less|vue|svelte|astro|tf|proto|graphql|gql|gradle|txt)$/i;
15
19
  registerTool({
16
20
  name: "codebase_search",
17
21
  description: "Find code in THIS project relevant to a natural-language query — ranked by relevance (not exact match). " +
18
22
  "Use it to locate similar/related code while working ('where is auth handled?', 'retry logic'); use grep " +
19
- "for exact strings/regex. Returns the top files with their most relevant snippet (file:line).",
23
+ "for exact strings/regex. Returns the top files with their most relevant snippet (file:line). When Hara " +
24
+ "was started in the home directory, set path to a specific project subdirectory.",
20
25
  input_schema: {
21
26
  type: "object",
22
- properties: { query: { type: "string" }, limit: { type: "number", description: "default 6 (max 20)" } },
27
+ properties: {
28
+ query: { type: "string" },
29
+ limit: { type: "number", description: "default 6 (max 20)" },
30
+ path: { type: "string", description: "project directory (default: cwd)" },
31
+ },
23
32
  required: ["query"],
24
33
  },
25
34
  kind: "read",
26
35
  async run(input, ctx) {
36
+ if (ctx.signal?.aborted)
37
+ throw new Error("codebase_search interrupted by agent run deadline or cancellation");
27
38
  const words = [...new Set(String(input.query ?? "").toLowerCase().split(/\s+/).filter((w) => w.length > 1))];
28
39
  if (!words.length)
29
40
  return "(empty query)";
30
41
  const need = Math.min(2, words.length); // require most of the query to actually appear (conceptual overlap)
31
42
  const limit = Math.min(Number(input.limit) || 6, 20);
32
- const root = findProjectRoot(ctx.cwd);
43
+ const requestedRoot = typeof input.path === "string" && input.path.trim()
44
+ ? (isAbsolute(input.path) ? input.path : resolve(ctx.cwd, input.path))
45
+ : ctx.cwd;
46
+ try {
47
+ if (!statSync(requestedRoot).isDirectory())
48
+ return `Error: codebase_search path is not a directory: ${input.path ?? "."}`;
49
+ }
50
+ catch {
51
+ return `Error: no such codebase_search path: ${input.path ?? "."}`;
52
+ }
53
+ if (recursiveRootContainsHome(requestedRoot))
54
+ return recursiveHomeSearchError("codebase_search");
55
+ const root = findProjectRoot(requestedRoot);
56
+ if (recursiveRootContainsHome(root))
57
+ return recursiveHomeSearchError("codebase_search");
33
58
  const hits = [];
34
- for (const rel of listProjectFiles(root)) {
59
+ const inventory = await listProjectFilesAsync(root, {
60
+ maxFiles: 8_000,
61
+ maxDirectories: 20_000,
62
+ maxEntries: 100_000,
63
+ timeoutMs: INVENTORY_TIMEOUT_MS,
64
+ signal: ctx.signal,
65
+ yieldEvery: 64,
66
+ });
67
+ const lexicalStartedAt = Date.now();
68
+ let lexicalTimedOut = false;
69
+ let fileIndex = 0;
70
+ for (const rel of inventory.files) {
71
+ if (ctx.signal?.aborted)
72
+ throw new Error("codebase_search interrupted by agent run deadline or cancellation");
73
+ if (fileIndex++ > 0 && fileIndex % 32 === 0) {
74
+ await new Promise((resolveTurn) => setImmediate(resolveTurn));
75
+ if (ctx.signal?.aborted)
76
+ throw new Error("codebase_search interrupted by agent run deadline or cancellation");
77
+ if (Date.now() - lexicalStartedAt >= LEXICAL_TIMEOUT_MS) {
78
+ lexicalTimedOut = true;
79
+ break;
80
+ }
81
+ }
35
82
  if (!CODE_RE.test(rel))
36
83
  continue;
37
84
  const abs = join(root, rel);
38
- if (fileSize(abs) > MAX_FILE)
39
- continue;
40
- let buf;
85
+ let text;
41
86
  try {
42
- buf = readFileSync(abs);
87
+ text = readVerifiedRegularFileSnapshotSync(abs, MAX_FILE, { action: "search" }).text;
43
88
  }
44
89
  catch {
45
90
  continue;
46
91
  }
47
- if (isProbablyBinary(buf))
92
+ if (text.includes("\0"))
48
93
  continue;
49
- const text = buf.toString("utf8");
50
94
  const lower = text.toLowerCase();
51
95
  const present = words.filter((w) => lower.includes(w));
52
96
  if (present.length < need)
@@ -74,11 +118,11 @@ registerTool({
74
118
  const seen = new Set();
75
119
  // Tool execution can be dispatched to a registered agent home without changing process.cwd(). Its
76
120
  // semantic-search provider/index settings belong to the tool context, not to the launcher directory.
77
- const cfg = loadConfig({ cwd: ctx.cwd });
121
+ const cfg = loadConfig({ cwd: root });
78
122
  const embed = getEmbedder(cfg);
79
- if (embed && indexExists("repo", ctx.cwd)) {
123
+ if (embed && indexExists("repo", root)) {
80
124
  try {
81
- for (const s of await queryIndex("repo", String(input.query), embed, ctx.cwd, limit)) {
125
+ for (const s of await queryIndex("repo", String(input.query), embed, root, limit, ctx.signal)) {
82
126
  if (s.score < 0.2 || seen.has(s.file))
83
127
  continue;
84
128
  seen.add(s.file);
@@ -86,6 +130,8 @@ registerTool({
86
130
  }
87
131
  }
88
132
  catch {
133
+ if (ctx.signal?.aborted)
134
+ throw new Error("codebase_search interrupted by agent run deadline or cancellation");
89
135
  /* embedding endpoint down → degrade to lexical */
90
136
  }
91
137
  }
@@ -97,8 +143,11 @@ registerTool({
97
143
  seen.add(h.file);
98
144
  out.push(`${h.file}:${h.line}\n${h.snippet}`);
99
145
  }
146
+ const boundedNote = inventory.truncated
147
+ ? `(project inventory stopped at its ${inventory.reason?.replace("_", " ") ?? "safety limit"})`
148
+ : lexicalTimedOut ? `(lexical scan stopped at its ${LEXICAL_TIMEOUT_MS}ms safety budget)` : "";
100
149
  if (!out.length)
101
- return "(no relevant code found)";
102
- return out.join("\n\n---\n\n");
150
+ return boundedNote ? `(no relevant code found)\n${boundedNote}` : "(no relevant code found)";
151
+ return (boundedNote ? `${boundedNote}\n\n` : "") + out.join("\n\n---\n\n");
103
152
  },
104
153
  });
@@ -4,12 +4,13 @@
4
4
  // frontmost-window check before any pointer/keyboard action) + dangerous-key blocklist + a once-per-session
5
5
  // grant (tool kind "computer" always confirms once, even in full-auto). Screenshots are read via the vision
6
6
  // sidecar (ctx.describeImage) so a text main model can still "see" them.
7
- import { spawnSync } from "node:child_process";
7
+ import { spawn, spawnSync } from "node:child_process";
8
8
  import { existsSync, statSync } from "node:fs";
9
9
  import { tmpdir } from "node:os";
10
10
  import { join } from "node:path";
11
11
  import { registerTool } from "./registry.js";
12
12
  import { loadConfig } from "../config.js";
13
+ import { terminateSubprocessTree, toolSubprocessEnv } from "../security/subprocess-env.js";
13
14
  const RANK = { off: 0, read: 1, click: 2, full: 3 };
14
15
  const ACTION_MIN = { screenshot: "read", find: "read", activate: "click", move: "click", click: "click", type: "full", key: "full" };
15
16
  // dangerous combos refused even at full tier (quit / close / delete / task-switch-kill).
@@ -46,30 +47,106 @@ function fail(msg) {
46
47
  }
47
48
  return `Failed: ${msg} [${consecFails}/${FAIL_LIMIT} before I stop]`;
48
49
  }
49
- function run(cmd, args) {
50
+ /** Doctor runs outside an agent turn and may retain a small bounded synchronous availability probe. */
51
+ function runProbeSync(cmd, args) {
50
52
  try {
51
- const r = spawnSync(cmd, args, { encoding: "utf8", timeout: 15000 });
53
+ const r = spawnSync(cmd, args, { encoding: "utf8", timeout: 3_000, env: toolSubprocessEnv() });
52
54
  return { ok: r.status === 0, out: ((r.stdout || "") + (r.stderr || "")).trim() };
53
55
  }
54
56
  catch (e) {
55
57
  return { ok: false, out: e?.message || "spawn failed" };
56
58
  }
57
59
  }
58
- function has(cmd) {
59
- return (process.platform === "win32" ? run("where", [cmd]) : run("which", [cmd])).ok;
60
+ function hasProbeSync(cmd) {
61
+ return (process.platform === "win32" ? runProbeSync("where", [cmd]) : runProbeSync("which", [cmd])).ok;
60
62
  }
61
- const ps = (script) => run("powershell", ["-NoProfile", "-Command", script]);
63
+ class ComputerInterruptedError extends Error {
64
+ constructor() {
65
+ super("computer action interrupted by agent run deadline or cancellation");
66
+ }
67
+ }
68
+ /** Async, signal-aware command primitive for every agent-driven screen action. It owns a process group so
69
+ * Esc/deadline kills descendants as well as the direct launcher, and it rechecks the signal before spawn. */
70
+ async function run(cmd, args, signal, input, timeoutMs = 15_000) {
71
+ if (signal?.aborted)
72
+ throw new ComputerInterruptedError();
73
+ return await new Promise((resolve, reject) => {
74
+ const processGroup = process.platform !== "win32";
75
+ let child;
76
+ try {
77
+ child = spawn(cmd, args, { detached: processGroup, env: toolSubprocessEnv() });
78
+ }
79
+ catch (error) {
80
+ resolve({ ok: false, out: error instanceof Error ? error.message : "spawn failed" });
81
+ return;
82
+ }
83
+ let stdout = "";
84
+ let stderr = "";
85
+ let done = false;
86
+ let timedOut = false;
87
+ let aborted = false;
88
+ let fallback;
89
+ const append = (current, chunk) => current.length >= 256 * 1024 ? current : current + chunk.toString().slice(0, 256 * 1024 - current.length);
90
+ const settle = (code, launchError) => {
91
+ if (done)
92
+ return;
93
+ done = true;
94
+ clearTimeout(timer);
95
+ if (fallback)
96
+ clearTimeout(fallback);
97
+ signal?.removeEventListener("abort", abortRun);
98
+ const out = (stdout + stderr).trim();
99
+ if (aborted)
100
+ reject(new ComputerInterruptedError());
101
+ else if (launchError)
102
+ resolve({ ok: false, out: launchError.message });
103
+ else if (timedOut)
104
+ resolve({ ok: false, out: `timed out after ${timeoutMs}ms${out ? `: ${out}` : ""}` });
105
+ else
106
+ resolve({ ok: code === 0, out });
107
+ };
108
+ const stop = (fromAbort) => {
109
+ if (done || aborted || timedOut)
110
+ return;
111
+ aborted = fromAbort;
112
+ timedOut = !fromAbort;
113
+ terminateSubprocessTree(child, { force: true, processGroup });
114
+ // A daemon can escape while retaining pipes. Destroy our ends so the API still settles promptly.
115
+ fallback = setTimeout(() => {
116
+ child.stdout?.destroy();
117
+ child.stderr?.destroy();
118
+ settle(null);
119
+ }, 750);
120
+ };
121
+ const abortRun = () => stop(true);
122
+ const timer = setTimeout(() => stop(false), timeoutMs);
123
+ signal?.addEventListener("abort", abortRun, { once: true });
124
+ if (signal?.aborted) {
125
+ abortRun();
126
+ }
127
+ child.stdout?.on("data", (chunk) => { if (!done)
128
+ stdout = append(stdout, chunk); });
129
+ child.stderr?.on("data", (chunk) => { if (!done)
130
+ stderr = append(stderr, chunk); });
131
+ child.stdin?.on("error", () => { });
132
+ child.stdin?.end(input);
133
+ child.once("error", (error) => settle(null, error));
134
+ child.once("close", (code) => settle(code));
135
+ });
136
+ }
137
+ const has = async (cmd, signal) => (await (process.platform === "win32" ? run("where", [cmd], signal) : run("which", [cmd], signal))).ok;
138
+ const ps = (script, signal) => run("powershell", ["-NoProfile", "-Command", script], signal);
62
139
  /** Put text on the OS clipboard (so `type` can paste it — IME-safe + Unicode-safe, unlike keystroke injection). */
63
- function setClipboard(text) {
140
+ async function setClipboard(text, signal) {
64
141
  try {
65
142
  if (process.platform === "darwin")
66
- return spawnSync("pbcopy", [], { input: text, timeout: 5000 }).status === 0;
143
+ return (await run("pbcopy", [], signal, text, 5_000)).ok;
67
144
  if (process.platform === "win32")
68
- return spawnSync("clip", [], { input: text, timeout: 5000 }).status === 0;
69
- if (has("wl-copy"))
70
- return spawnSync("wl-copy", [], { input: text, timeout: 5000 }).status === 0;
71
- if (has("xclip"))
72
- return spawnSync("xclip", ["-selection", "clipboard"], { input: text, timeout: 5000 }).status === 0;
145
+ return (await run("clip", [], signal, text, 5_000)).ok;
146
+ if (await has("wl-copy", signal))
147
+ return (await run("wl-copy", [], signal, text, 5_000)).ok;
148
+ if (await has("xclip", signal))
149
+ return (await run("xclip", ["-selection", "clipboard"], signal, text, 5_000)).ok;
73
150
  }
74
151
  catch {
75
152
  /* fall through */
@@ -81,25 +158,25 @@ function tmpShot() {
81
158
  seq += 1;
82
159
  return join(tmpdir(), `hara-screen-${process.pid}-${Date.now()}-${seq}.png`);
83
160
  }
84
- function screenshot() {
161
+ async function screenshot(signal) {
85
162
  const out = tmpShot();
86
163
  if (process.platform === "darwin") {
87
- if (!run("screencapture", ["-x", out]).ok)
164
+ if (!(await run("screencapture", ["-x", out], signal)).ok)
88
165
  return { error: "screencapture failed (grant Screen Recording permission)" };
89
166
  }
90
167
  else if (process.platform === "linux") {
91
- if (has("scrot"))
92
- run("scrot", ["-o", out]);
93
- else if (has("import"))
94
- run("import", ["-window", "root", out]);
95
- else if (has("grim"))
96
- run("grim", [out]);
168
+ if (await has("scrot", signal))
169
+ await run("scrot", ["-o", out], signal);
170
+ else if (await has("import", signal))
171
+ await run("import", ["-window", "root", out], signal);
172
+ else if (await has("grim", signal))
173
+ await run("grim", [out], signal);
97
174
  else
98
175
  return { error: "no screenshot tool — install scrot / imagemagick / grim" };
99
176
  }
100
177
  else if (process.platform === "win32") {
101
178
  const script = `Add-Type -AssemblyName System.Windows.Forms,System.Drawing; $b=[System.Windows.Forms.Screen]::PrimaryScreen.Bounds; $bmp=New-Object System.Drawing.Bitmap($b.Width,$b.Height); $g=[System.Drawing.Graphics]::FromImage($bmp); $g.CopyFromScreen($b.Location,[System.Drawing.Point]::Empty,$b.Size); $bmp.Save(${JSON.stringify(out)})`;
102
- if (!ps(script).ok)
179
+ if (!(await ps(script, signal)).ok)
103
180
  return { error: "PowerShell screenshot failed" };
104
181
  }
105
182
  else {
@@ -115,39 +192,41 @@ function screenshot() {
115
192
  return { path: out };
116
193
  }
117
194
  /** Bring an app to the foreground so screenshots/clicks land on IT, not the terminal hara runs in. */
118
- function activateApp(app) {
195
+ async function activateApp(app, signal) {
119
196
  if (process.platform === "darwin") {
120
197
  // `open -a` reliably launches+foregrounds; `osascript … activate` often leaves another window on top.
121
- const r = run("open", ["-a", app]);
198
+ const r = await run("open", ["-a", app], signal);
122
199
  return { ok: r.ok, msg: r.ok ? `activated ${app}` : r.out || `couldn't activate ${app}` };
123
200
  }
124
201
  if (process.platform === "win32") {
125
- const r = ps(`(New-Object -ComObject WScript.Shell).AppActivate(${JSON.stringify(app)})`);
202
+ const r = await ps(`(New-Object -ComObject WScript.Shell).AppActivate(${JSON.stringify(app)})`, signal);
126
203
  return { ok: r.ok, msg: r.ok ? `activated ${app}` : r.out || `couldn't activate ${app}` };
127
204
  }
128
205
  if (process.platform === "linux") {
129
- const r = has("wmctrl") ? run("wmctrl", ["-a", app]) : run("xdotool", ["search", "--name", app, "windowactivate"]);
206
+ const r = await (await has("wmctrl", signal)
207
+ ? run("wmctrl", ["-a", app], signal)
208
+ : run("xdotool", ["search", "--name", app, "windowactivate"], signal));
130
209
  return { ok: r.ok, msg: r.ok ? `activated ${app}` : r.out || `couldn't activate ${app} (need wmctrl/xdotool)` };
131
210
  }
132
211
  return { ok: false, msg: `activate unsupported on ${process.platform}` };
133
212
  }
134
213
  /** Logical screen size in the coordinate space the click backends use (points on mac, pixels on win/linux).
135
214
  * Grounding returns 0..1 fractions, so click = fraction × this. null if undetectable. */
136
- function screenSize() {
215
+ async function screenSize(signal) {
137
216
  try {
138
217
  if (process.platform === "darwin") {
139
- const r = run("osascript", ["-e", 'tell application "Finder" to get bounds of window of desktop']);
218
+ const r = await run("osascript", ["-e", 'tell application "Finder" to get bounds of window of desktop'], signal);
140
219
  const n = r.out.match(/-?\d+/g);
141
220
  if (n && n.length >= 4)
142
221
  return { w: Number(n[2]), h: Number(n[3]) };
143
222
  }
144
223
  else if (process.platform === "linux") {
145
- const [w, h] = run("xdotool", ["getdisplaygeometry"]).out.trim().split(/\s+/).map(Number);
224
+ const [w, h] = (await run("xdotool", ["getdisplaygeometry"], signal)).out.trim().split(/\s+/).map(Number);
146
225
  if (w && h)
147
226
  return { w, h };
148
227
  }
149
228
  else if (process.platform === "win32") {
150
- const [w, h] = ps('Add-Type -AssemblyName System.Windows.Forms; $b=[System.Windows.Forms.Screen]::PrimaryScreen.Bounds; "$($b.Width) $($b.Height)"').out.trim().split(/\s+/).map(Number);
229
+ const [w, h] = (await ps('Add-Type -AssemblyName System.Windows.Forms; $b=[System.Windows.Forms.Screen]::PrimaryScreen.Bounds; "$($b.Width) $($b.Height)"', signal)).out.trim().split(/\s+/).map(Number);
151
230
  if (w && h)
152
231
  return { w, h };
153
232
  }
@@ -158,25 +237,25 @@ function screenSize() {
158
237
  return null;
159
238
  }
160
239
  /** Name of the frontmost application/window (for the allowlist check). "" if undetectable. */
161
- function frontmostApp() {
240
+ async function frontmostApp(signal) {
162
241
  if (process.platform === "darwin") {
163
- const r = run("osascript", ["-e", 'tell application "System Events" to get name of first application process whose frontmost is true']);
242
+ const r = await run("osascript", ["-e", 'tell application "System Events" to get name of first application process whose frontmost is true'], signal);
164
243
  return r.ok ? r.out : "";
165
244
  }
166
245
  if (process.platform === "linux") {
167
- const r = run("xdotool", ["getactivewindow", "getwindowclassname"]);
246
+ const r = await run("xdotool", ["getactivewindow", "getwindowclassname"], signal);
168
247
  return r.ok ? r.out : "";
169
248
  }
170
249
  if (process.platform === "win32") {
171
250
  const script = `Add-Type @"
172
251
  using System;using System.Runtime.InteropServices;public class Hw{[DllImport("user32.dll")]public static extern IntPtr GetForegroundWindow();[DllImport("user32.dll")]public static extern int GetWindowThreadProcessId(IntPtr h,out int p);}
173
252
  "@; $p=0;[void][Hw]::GetWindowThreadProcessId([Hw]::GetForegroundWindow(),[ref]$p);(Get-Process -Id $p).ProcessName`;
174
- const r = ps(script);
253
+ const r = await ps(script, signal);
175
254
  return r.ok ? r.out : "";
176
255
  }
177
256
  return "";
178
257
  }
179
- function pointerOrKeyboard(action, input) {
258
+ async function pointerOrKeyboard(action, input, signal) {
180
259
  const x = Math.round(Number(input.x));
181
260
  const y = Math.round(Number(input.y));
182
261
  const mac = process.platform === "darwin";
@@ -186,24 +265,24 @@ function pointerOrKeyboard(action, input) {
186
265
  if (!Number.isFinite(x) || !Number.isFinite(y))
187
266
  return { ok: false, msg: `${action} needs x,y` };
188
267
  if (mac) {
189
- if (!has("cliclick"))
268
+ if (!(await has("cliclick", signal)))
190
269
  return { ok: false, msg: "cliclick not found — install with `brew install cliclick`" };
191
- const r = run("cliclick", [`${action === "click" ? "c" : "m"}:${x},${y}`]);
270
+ const r = await run("cliclick", [`${action === "click" ? "c" : "m"}:${x},${y}`], signal);
192
271
  return { ok: r.ok, msg: r.ok ? `${action} at ${x},${y}` : r.out };
193
272
  }
194
273
  if (lin) {
195
- if (!has("xdotool"))
274
+ if (!(await has("xdotool", signal)))
196
275
  return { ok: false, msg: "xdotool not found" };
197
- const r = run("xdotool", action === "click" ? ["mousemove", `${x}`, `${y}`, "click", "1"] : ["mousemove", `${x}`, `${y}`]);
276
+ const r = await run("xdotool", action === "click" ? ["mousemove", `${x}`, `${y}`, "click", "1"] : ["mousemove", `${x}`, `${y}`], signal);
198
277
  return { ok: r.ok, msg: r.ok ? `${action} at ${x},${y}` : r.out };
199
278
  }
200
279
  if (win) {
201
280
  const move = `Add-Type -AssemblyName System.Windows.Forms;[System.Windows.Forms.Cursor]::Position=New-Object System.Drawing.Point(${x},${y})`;
202
- const m1 = ps(`Add-Type -AssemblyName System.Drawing;${move}`);
281
+ const m1 = await ps(`Add-Type -AssemblyName System.Drawing;${move}`, signal);
203
282
  if (action === "click" && m1.ok) {
204
- ps(`Add-Type @"
283
+ await ps(`Add-Type @"
205
284
  using System;using System.Runtime.InteropServices;public class Ms{[DllImport("user32.dll")]public static extern void mouse_event(int f,int x,int y,int d,int e);}
206
- "@; [Ms]::mouse_event(0x2,0,0,0,0);[Ms]::mouse_event(0x4,0,0,0,0)`);
285
+ "@; [Ms]::mouse_event(0x2,0,0,0,0);[Ms]::mouse_event(0x4,0,0,0,0)`, signal);
207
286
  }
208
287
  return { ok: m1.ok, msg: m1.ok ? `${action} at ${x},${y}` : m1.out };
209
288
  }
@@ -214,38 +293,38 @@ using System;using System.Runtime.InteropServices;public class Ms{[DllImport("us
214
293
  return { ok: false, msg: "type needs text" };
215
294
  // IME-safe path: set the clipboard and paste. Keystroke injection (below) is intercepted/garbled by a
216
295
  // CJK input method and can't enter Chinese/emoji reliably; pasting is immune and Unicode-safe.
217
- if (setClipboard(text)) {
218
- if (mac && has("cliclick")) {
219
- const r = run("cliclick", ["kd:cmd", "t:v", "ku:cmd"]); // Cmd+V
296
+ if (await setClipboard(text, signal)) {
297
+ if (mac && await has("cliclick", signal)) {
298
+ const r = await run("cliclick", ["kd:cmd", "t:v", "ku:cmd"], signal); // Cmd+V
220
299
  if (r.ok)
221
300
  return { ok: true, msg: `pasted ${text.length} chars` };
222
301
  }
223
- else if (lin && has("xdotool")) {
224
- const r = run("xdotool", ["key", "ctrl+v"]);
302
+ else if (lin && await has("xdotool", signal)) {
303
+ const r = await run("xdotool", ["key", "ctrl+v"], signal);
225
304
  if (r.ok)
226
305
  return { ok: true, msg: `pasted ${text.length} chars` };
227
306
  }
228
307
  else if (win) {
229
- const r = ps("Add-Type -AssemblyName System.Windows.Forms;[System.Windows.Forms.SendKeys]::SendWait('^v')");
308
+ const r = await ps("Add-Type -AssemblyName System.Windows.Forms;[System.Windows.Forms.SendKeys]::SendWait('^v')", signal);
230
309
  if (r.ok)
231
310
  return { ok: true, msg: `pasted ${text.length} chars` };
232
311
  }
233
312
  }
234
313
  // Fallback: keystroke injection (fine for ASCII when no IME is active).
235
314
  if (mac) {
236
- if (!has("cliclick"))
315
+ if (!(await has("cliclick", signal)))
237
316
  return { ok: false, msg: "cliclick not found — install with `brew install cliclick`" };
238
- const r = run("cliclick", [`t:${text}`]);
317
+ const r = await run("cliclick", [`t:${text}`], signal);
239
318
  return { ok: r.ok, msg: r.ok ? `typed ${text.length} chars (keystroke)` : r.out };
240
319
  }
241
320
  if (lin) {
242
- if (!has("xdotool"))
321
+ if (!(await has("xdotool", signal)))
243
322
  return { ok: false, msg: "xdotool not found" };
244
- const r = run("xdotool", ["type", "--clearmodifiers", text]);
323
+ const r = await run("xdotool", ["type", "--clearmodifiers", text], signal);
245
324
  return { ok: r.ok, msg: r.ok ? `typed ${text.length} chars (keystroke)` : r.out };
246
325
  }
247
326
  if (win) {
248
- const r = ps(`Add-Type -AssemblyName System.Windows.Forms;[System.Windows.Forms.SendKeys]::SendWait(${JSON.stringify(text)})`);
327
+ const r = await ps(`Add-Type -AssemblyName System.Windows.Forms;[System.Windows.Forms.SendKeys]::SendWait(${JSON.stringify(text)})`, signal);
249
328
  return { ok: r.ok, msg: r.ok ? `typed ${text.length} chars (keystroke)` : r.out };
250
329
  }
251
330
  }
@@ -256,19 +335,19 @@ using System;using System.Runtime.InteropServices;public class Ms{[DllImport("us
256
335
  if (keyIsBlocked(keys))
257
336
  return { ok: false, msg: `refused dangerous key combo: ${keys}` };
258
337
  if (mac) {
259
- if (!has("cliclick"))
338
+ if (!(await has("cliclick", signal)))
260
339
  return { ok: false, msg: "cliclick not found — install with `brew install cliclick`" };
261
- const r = run("cliclick", [`kp:${keys}`]);
340
+ const r = await run("cliclick", [`kp:${keys}`], signal);
262
341
  return { ok: r.ok, msg: r.ok ? `pressed ${keys}` : r.out };
263
342
  }
264
343
  if (lin) {
265
- if (!has("xdotool"))
344
+ if (!(await has("xdotool", signal)))
266
345
  return { ok: false, msg: "xdotool not found" };
267
- const r = run("xdotool", ["key", keys]);
346
+ const r = await run("xdotool", ["key", keys], signal);
268
347
  return { ok: r.ok, msg: r.ok ? `pressed ${keys}` : r.out };
269
348
  }
270
349
  if (win) {
271
- const r = ps(`Add-Type -AssemblyName System.Windows.Forms;[System.Windows.Forms.SendKeys]::SendWait(${JSON.stringify(keys)})`);
350
+ const r = await ps(`Add-Type -AssemblyName System.Windows.Forms;[System.Windows.Forms.SendKeys]::SendWait(${JSON.stringify(keys)})`, signal);
272
351
  return { ok: r.ok, msg: r.ok ? `pressed ${keys}` : r.out };
273
352
  }
274
353
  }
@@ -277,9 +356,9 @@ using System;using System.Runtime.InteropServices;public class Ms{[DllImport("us
277
356
  /** Per-OS backend availability — for `hara doctor`. */
278
357
  export function computerBackends() {
279
358
  if (process.platform === "darwin")
280
- return `screencapture ✓ · cliclick ${has("cliclick") ? "✓" : "✗ (brew install cliclick)"}`;
359
+ return `screencapture ✓ · cliclick ${hasProbeSync("cliclick") ? "✓" : "✗ (brew install cliclick)"}`;
281
360
  if (process.platform === "linux")
282
- return `scrot ${has("scrot") ? "✓" : "✗"} · xdotool ${has("xdotool") ? "✓" : "✗"}`;
361
+ return `scrot ${hasProbeSync("scrot") ? "✓" : "✗"} · xdotool ${hasProbeSync("xdotool") ? "✓" : "✗"}`;
283
362
  if (process.platform === "win32")
284
363
  return "PowerShell (built-in)";
285
364
  return `unsupported (${process.platform})`;
@@ -313,6 +392,8 @@ if (!process.env.HARA_GATEWAY || process.env.HARA_GATEWAY_COMPUTER === "1") {
313
392
  },
314
393
  kind: "computer",
315
394
  async run(input, ctx) {
395
+ if (ctx.signal?.aborted)
396
+ throw new ComputerInterruptedError();
316
397
  const cfg = loadConfig();
317
398
  const tier = cfg.computerUse;
318
399
  if (tier === "off")
@@ -327,25 +408,25 @@ if (!process.env.HARA_GATEWAY || process.env.HARA_GATEWAY_COMPUTER === "1") {
327
408
  return "activate needs an `app` name (e.g. 'WeChat').";
328
409
  if (!cfg.computerApps.some((a) => a.toLowerCase() === app.toLowerCase()))
329
410
  return `Refused: "${app}" isn't in your allowlist (${cfg.computerApps.join(", ") || "empty"}). Add it: \`hara config set computerApps "${app}"\`.`;
330
- const r = activateApp(app);
411
+ const r = await activateApp(app, ctx.signal);
331
412
  return r.ok ? ok(`✓ ${r.msg} — now screenshot/find/click to act on it`) : fail(r.msg);
332
413
  }
333
414
  if (action !== "screenshot" && action !== "find") {
334
415
  // per-app allowlist: only act when an allowlisted app is frontmost (the key guard against wrong-window clicks)
335
416
  if (!cfg.computerApps.length)
336
417
  return "No apps allowlisted — set `hara config set computerApps \"App Name, …\"` before clicking/typing.";
337
- const app = frontmostApp();
418
+ const app = await frontmostApp(ctx.signal);
338
419
  const allowed = cfg.computerApps.some((a) => a.toLowerCase() === app.toLowerCase());
339
420
  if (!allowed)
340
421
  return `Refused: frontmost app "${app || "unknown"}" isn't in your allowlist (${cfg.computerApps.join(", ")}). Switch to an allowed app or update computerApps.`;
341
422
  }
342
423
  if (action === "screenshot") {
343
- const s = screenshot();
424
+ const s = await screenshot(ctx.signal);
344
425
  if (s.error)
345
426
  return fail(`screenshot — ${s.error}`);
346
427
  if (ctx.describeImage) {
347
428
  try {
348
- const desc = await ctx.describeImage(s.path, input.focus ? String(input.focus) : undefined);
429
+ const desc = await ctx.describeImage(s.path, input.focus ? String(input.focus) : undefined, ctx.signal);
349
430
  if (desc)
350
431
  return ok(`Screenshot (read via vision):\n${desc}`);
351
432
  }
@@ -364,13 +445,13 @@ if (!process.env.HARA_GATEWAY || process.env.HARA_GATEWAY_COMPUTER === "1") {
364
445
  return action === "find" ? "find needs a `target` (what to locate)." : "click/move needs `x,y` or a `target`.";
365
446
  if (!ctx.locate)
366
447
  return "Grounding needs a vision model that can see images — set one: `hara config set visionModel <model>`.";
367
- const s = screenshot();
448
+ const s = await screenshot(ctx.signal);
368
449
  if (s.error)
369
450
  return fail(`screenshot — ${s.error}`);
370
- const loc = await ctx.locate(s.path, target);
451
+ const loc = await ctx.locate(s.path, target, ctx.signal);
371
452
  if (!loc)
372
453
  return fail(`couldn't locate "${target}" on screen — try a screenshot first, or rephrase the target`);
373
- const size = screenSize();
454
+ const size = await screenSize(ctx.signal);
374
455
  if (!size)
375
456
  return fail(`located "${target}" but couldn't read the screen size to convert coordinates`);
376
457
  const gx = Math.round(loc.x * size.w);
@@ -380,7 +461,7 @@ if (!process.env.HARA_GATEWAY || process.env.HARA_GATEWAY_COMPUTER === "1") {
380
461
  input.x = gx;
381
462
  input.y = gy;
382
463
  }
383
- const r = pointerOrKeyboard(action, input);
464
+ const r = await pointerOrKeyboard(action, input, ctx.signal);
384
465
  return r.ok ? ok(`✓ ${r.msg}${needsLocate ? ` (located "${input.target}")` : ""}`) : fail(r.msg);
385
466
  },
386
467
  });