@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.
- package/CHANGELOG.md +108 -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 +31 -17
- 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/sessions.js +3 -3
- 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/org/projects.js +3 -3
- 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/sync-sleep.js +46 -0
- 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/task.js +3 -3
- 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/builtin.js
CHANGED
|
@@ -4,7 +4,7 @@ import { resolve, isAbsolute } from "node:path";
|
|
|
4
4
|
import { stdout as procOut } from "node:process";
|
|
5
5
|
import { registerTool } from "./registry.js";
|
|
6
6
|
import { runShell } from "../sandbox.js";
|
|
7
|
-
import {
|
|
7
|
+
import { nearestPathsAsync } from "../fs-walk.js";
|
|
8
8
|
import { emitDiff } from "../diff.js";
|
|
9
9
|
import { recordEdit } from "../undo.js";
|
|
10
10
|
import { atomicWriteText, bindAtomicWritePath } from "../fs-write.js";
|
|
@@ -140,7 +140,7 @@ registerTool({
|
|
|
140
140
|
catch (e) {
|
|
141
141
|
if (e instanceof BinaryFileError)
|
|
142
142
|
return `Error: cannot read ${input.path}: file appears binary; use an image/media-specific tool or inspect it with \`file\`.`;
|
|
143
|
-
const near =
|
|
143
|
+
const near = await nearestPathsAsync(ctx.cwd, input.path, 3, { timeoutMs: 1_000, signal: ctx.signal });
|
|
144
144
|
return `Error: cannot read ${input.path}: ${e.message ?? e.code}.` + (near.length ? ` Did you mean: ${near.join(", ")}?` : "");
|
|
145
145
|
}
|
|
146
146
|
},
|
|
@@ -185,6 +185,7 @@ registerTool({
|
|
|
185
185
|
expected: prev,
|
|
186
186
|
expectedIdentity: prevSnapshot ?? undefined,
|
|
187
187
|
boundary,
|
|
188
|
+
signal: ctx.signal,
|
|
188
189
|
});
|
|
189
190
|
}
|
|
190
191
|
catch (error) {
|
|
@@ -209,6 +210,7 @@ registerTool({
|
|
|
209
210
|
required: ["command"],
|
|
210
211
|
},
|
|
211
212
|
kind: "exec",
|
|
213
|
+
requiresProjectWorkspace: true,
|
|
212
214
|
async run(input, ctx) {
|
|
213
215
|
const protectedReason = sensitiveShellCommandReason(String(input.command ?? ""), ctx.cwd);
|
|
214
216
|
if (protectedReason) {
|
|
@@ -259,6 +261,7 @@ registerTool({
|
|
|
259
261
|
timeout,
|
|
260
262
|
maxBuffer: 10 * 1024 * 1024,
|
|
261
263
|
onData: live,
|
|
264
|
+
signal: ctx.signal,
|
|
262
265
|
});
|
|
263
266
|
flushLive();
|
|
264
267
|
const combined = (stdout || "") + (stderr ? `\n[stderr]\n${stderr}` : "");
|
package/dist/tools/codebase.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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: {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
39
|
-
continue;
|
|
40
|
-
let buf;
|
|
85
|
+
let text;
|
|
41
86
|
try {
|
|
42
|
-
|
|
87
|
+
text = readVerifiedRegularFileSnapshotSync(abs, MAX_FILE, { action: "search" }).text;
|
|
43
88
|
}
|
|
44
89
|
catch {
|
|
45
90
|
continue;
|
|
46
91
|
}
|
|
47
|
-
if (
|
|
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:
|
|
121
|
+
const cfg = loadConfig({ cwd: root });
|
|
78
122
|
const embed = getEmbedder(cfg);
|
|
79
|
-
if (embed && indexExists("repo",
|
|
123
|
+
if (embed && indexExists("repo", root)) {
|
|
80
124
|
try {
|
|
81
|
-
for (const s of await queryIndex("repo", String(input.query), embed,
|
|
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
|
});
|
package/dist/tools/computer.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
|
59
|
-
return (process.platform === "win32" ?
|
|
60
|
+
function hasProbeSync(cmd) {
|
|
61
|
+
return (process.platform === "win32" ? runProbeSync("where", [cmd]) : runProbeSync("which", [cmd])).ok;
|
|
60
62
|
}
|
|
61
|
-
|
|
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
|
|
143
|
+
return (await run("pbcopy", [], signal, text, 5_000)).ok;
|
|
67
144
|
if (process.platform === "win32")
|
|
68
|
-
return
|
|
69
|
-
if (has("wl-copy"))
|
|
70
|
-
return
|
|
71
|
-
if (has("xclip"))
|
|
72
|
-
return
|
|
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 =
|
|
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 ${
|
|
359
|
+
return `screencapture ✓ · cliclick ${hasProbeSync("cliclick") ? "✓" : "✗ (brew install cliclick)"}`;
|
|
281
360
|
if (process.platform === "linux")
|
|
282
|
-
return `scrot ${
|
|
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
|
});
|