@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
@@ -11,8 +11,18 @@
11
11
  import { spawn } from "node:child_process";
12
12
  import { tmpdir } from "node:os";
13
13
  import { join } from "node:path";
14
- import { writeFileSync } from "node:fs";
14
+ import { chmodSync, lstatSync, rmSync, writeFileSync } from "node:fs";
15
15
  import { randomUUID } from "node:crypto";
16
+ import { terminateSubprocessTree } from "../security/subprocess-env.js";
17
+ const DEFAULT_TTS_TIMEOUT_MS = 60_000;
18
+ const MAX_TTS_TIMEOUT_MS = 120_000;
19
+ /** TTS cannot opt out of a deadline. Small values remain available for deterministic health checks/tests. */
20
+ export function ttsTimeoutMs(value = process.env.HARA_TTS_TIMEOUT_MS) {
21
+ const parsed = typeof value === "number" ? value : Number(value);
22
+ return Number.isFinite(parsed) && parsed > 0
23
+ ? Math.max(50, Math.min(Math.floor(parsed), MAX_TTS_TIMEOUT_MS))
24
+ : DEFAULT_TTS_TIMEOUT_MS;
25
+ }
16
26
  /** Read TTS settings from the environment (fully configurable — nothing hardcoded). */
17
27
  export function ttsConfigFromEnv(env = process.env) {
18
28
  return {
@@ -22,6 +32,8 @@ export function ttsConfigFromEnv(env = process.env) {
22
32
  baseURL: (env.HARA_TTS_BASE_URL || "").trim(),
23
33
  apiKey: (env.HARA_TTS_API_KEY || "").trim(),
24
34
  cmd: (env.HARA_TTS_CMD || "").trim(),
35
+ // Passing an explicit fixture env must not accidentally fall back to the live process environment.
36
+ timeoutMs: ttsTimeoutMs(env.HARA_TTS_TIMEOUT_MS ?? Number.NaN),
25
37
  };
26
38
  }
27
39
  /** Normalize a reply for speech: collapse whitespace, drop code fences, cap length (long audio is unwanted). */
@@ -32,38 +44,113 @@ export function ttsCleanText(text, max = 1200) {
32
44
  .trim()
33
45
  .slice(0, max);
34
46
  }
35
- function run(cmd, args, stdin) {
36
- return new Promise((resolve) => {
37
- const c = spawn(cmd, args, { stdio: [stdin != null ? "pipe" : "ignore", "ignore", "ignore"] });
38
- c.on("error", () => resolve(false));
39
- c.on("close", (code) => resolve(code === 0));
40
- if (stdin != null)
41
- c.stdin?.end(stdin);
47
+ function abortReason(signal) {
48
+ return signal.reason instanceof Error ? signal.reason : new Error("TTS cancelled");
49
+ }
50
+ function throwIfAborted(signal) {
51
+ if (signal.aborted)
52
+ throw abortReason(signal);
53
+ }
54
+ /** Race even an API implementation that ignores AbortSignal, while still passing the signal to its fetch. */
55
+ function withAbort(task, signal) {
56
+ if (signal.aborted)
57
+ return Promise.reject(abortReason(signal));
58
+ return new Promise((resolve, reject) => {
59
+ let settled = false;
60
+ const finish = (fn) => {
61
+ if (settled)
62
+ return;
63
+ settled = true;
64
+ signal.removeEventListener("abort", onAbort);
65
+ fn();
66
+ };
67
+ const onAbort = () => finish(() => reject(abortReason(signal)));
68
+ signal.addEventListener("abort", onAbort, { once: true });
69
+ task.then((value) => finish(() => resolve(value)), (error) => finish(() => reject(error)));
70
+ });
71
+ }
72
+ /** Run local TTS in its own process group. Cancellation escalates TERM→KILL for the whole descendant tree. */
73
+ function run(cmd, args, signal, stdin) {
74
+ throwIfAborted(signal);
75
+ return new Promise((resolve, reject) => {
76
+ const processGroup = process.platform !== "win32";
77
+ let child;
78
+ try {
79
+ child = spawn(cmd, args, {
80
+ stdio: [stdin != null ? "pipe" : "ignore", "ignore", "ignore"],
81
+ detached: processGroup,
82
+ windowsHide: true,
83
+ });
84
+ }
85
+ catch {
86
+ resolve(false);
87
+ return;
88
+ }
89
+ let settled = false;
90
+ let stopping = false;
91
+ let cancelTermination;
92
+ const settle = (ok, error) => {
93
+ if (settled)
94
+ return;
95
+ settled = true;
96
+ signal.removeEventListener("abort", stop);
97
+ // Cancel only the API fallback. If TERM closed the direct child while a descendant survived, the helper's
98
+ // referenced force timer still kills the owned process group after the grace period.
99
+ cancelTermination?.();
100
+ child.stdin?.destroy();
101
+ if (error)
102
+ reject(error);
103
+ else
104
+ resolve(ok);
105
+ };
106
+ const stop = () => {
107
+ if (settled || stopping)
108
+ return;
109
+ stopping = true;
110
+ cancelTermination = terminateSubprocessTree(child, {
111
+ processGroup,
112
+ graceMs: 250,
113
+ fallbackMs: 250,
114
+ onFallback: () => settle(false, abortReason(signal)),
115
+ });
116
+ };
117
+ child.once("error", () => settle(false, stopping ? abortReason(signal) : undefined));
118
+ child.once("close", (code) => settle(code === 0, stopping ? abortReason(signal) : undefined));
119
+ child.stdin?.on("error", () => { });
120
+ signal.addEventListener("abort", stop, { once: true });
121
+ if (signal.aborted)
122
+ stop();
123
+ if (stdin != null && !stopping)
124
+ child.stdin?.end(stdin);
42
125
  });
43
126
  }
44
127
  // local: macOS `say` → m4a (AAC). Zero config; HARA_TTS_VOICE picks the voice (default Tingting / zh).
45
- async function sayTts(text, out, cfg) {
46
- return run("say", ["-v", cfg.voice || "Tingting", "-o", out, "--file-format", "m4af", "--data-format", "aac", text]);
128
+ async function sayTts(text, out, cfg, signal) {
129
+ return run("say", ["-v", cfg.voice || "Tingting", "-o", out, "--file-format", "m4af", "--data-format", "aac", text], signal);
47
130
  }
48
131
  // API: OpenAI-compatible /audio/speech. Works against OpenAI, Aliyun DashScope (compatible-mode), or a local
49
132
  // server exposing the same shape — set HARA_TTS_BASE_URL + HARA_TTS_API_KEY + HARA_TTS_MODEL + HARA_TTS_VOICE.
50
- async function openaiTts(text, out, cfg) {
133
+ async function openaiTts(text, out, cfg, signal) {
51
134
  if (!cfg.apiKey && !cfg.baseURL)
52
135
  return false; // not configured → unavailable
53
- const { default: OpenAI } = (await import("openai"));
54
- const client = new OpenAI({ baseURL: cfg.baseURL || undefined, apiKey: cfg.apiKey || "x" });
55
- const res = await client.audio.speech.create({ model: cfg.model || "tts-1", voice: cfg.voice || "alloy", input: text });
56
- const buf = Buffer.from(await res.arrayBuffer());
57
- if (!buf.length)
58
- return false;
59
- writeFileSync(out, buf);
60
- return true;
136
+ return withAbort((async () => {
137
+ const { default: OpenAI } = (await import("openai"));
138
+ throwIfAborted(signal);
139
+ const client = new OpenAI({ baseURL: cfg.baseURL || undefined, apiKey: cfg.apiKey || "x" });
140
+ const res = await client.audio.speech.create({ model: cfg.model || "tts-1", voice: cfg.voice || "alloy", input: text }, { signal });
141
+ const buf = Buffer.from(await res.arrayBuffer());
142
+ throwIfAborted(signal);
143
+ if (!buf.length)
144
+ return false;
145
+ writeFileSync(out, buf, { flag: "wx", mode: 0o600 });
146
+ return true;
147
+ })(), signal);
61
148
  }
62
149
  // local: a configurable command (e.g. VoxCPM). `{out}` → output path; the text is piped on stdin.
63
- async function cmdTts(text, out, cfg) {
150
+ async function cmdTts(text, out, cfg, signal) {
64
151
  if (!cfg.cmd)
65
152
  return false;
66
- return run("sh", ["-c", cfg.cmd.replace(/\{out\}/g, out)], text);
153
+ return run("sh", ["-c", cfg.cmd.replace(/\{out\}/g, out)], signal, text);
67
154
  }
68
155
  const PROVIDERS = {
69
156
  say: sayTts,
@@ -71,30 +158,87 @@ const PROVIDERS = {
71
158
  cmd: cmdTts,
72
159
  };
73
160
  const EXT = { say: "m4a", openai: "mp3", cmd: "wav" };
74
- /** Synthesize `text` to a temp audio file; returns its path, or null on failure. Falls back to local `say`
75
- * if a configured provider fails (so a misconfigured API never silently kills voice replies). */
76
- export async function synthesize(text, cfg = ttsConfigFromEnv()) {
161
+ function isAbortSignal(value) {
162
+ return Boolean(value && typeof value.addEventListener === "function" && typeof value.aborted === "boolean");
163
+ }
164
+ function safeProviderError(error, cfg) {
165
+ let message = error instanceof Error ? error.message : String(error);
166
+ for (const [secret, replacement] of [[cfg.apiKey, "[redacted]"], [cfg.baseURL, "[redacted-url]"]]) {
167
+ if (secret)
168
+ message = message.split(secret).join(replacement);
169
+ }
170
+ return message;
171
+ }
172
+ function secureCompletedAudio(path) {
173
+ try {
174
+ const stat = lstatSync(path);
175
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size <= 0)
176
+ return false;
177
+ chmodSync(path, 0o600);
178
+ return true;
179
+ }
180
+ catch {
181
+ return false;
182
+ }
183
+ }
184
+ /** Synthesize `text` to a temp audio file; returns its path, or null on provider/deadline failure. The legacy
185
+ * `synthesize(text, config)` form remains valid; gateway callers use `synthesize(text, signal, config?)`. */
186
+ export async function synthesize(text, signalOrConfig, explicitConfig) {
187
+ const parentSignal = isAbortSignal(signalOrConfig) ? signalOrConfig : undefined;
188
+ if (parentSignal?.aborted)
189
+ throw abortReason(parentSignal);
77
190
  const clean = ttsCleanText(text);
78
191
  if (!clean)
79
192
  return null;
193
+ const cfg = isAbortSignal(signalOrConfig)
194
+ ? (explicitConfig ?? ttsConfigFromEnv())
195
+ : (explicitConfig ?? signalOrConfig ?? ttsConfigFromEnv());
196
+ const deadline = new AbortController();
197
+ const timeoutMs = ttsTimeoutMs(cfg.timeoutMs);
198
+ const timeoutError = new Error(`TTS timed out after ${timeoutMs}ms`);
199
+ const timeout = setTimeout(() => deadline.abort(timeoutError), timeoutMs);
200
+ const cancel = () => deadline.abort(parentSignal?.reason instanceof Error ? parentSignal.reason : new Error("TTS cancelled"));
201
+ parentSignal?.addEventListener("abort", cancel, { once: true });
80
202
  const provider = PROVIDERS[cfg.provider] ? cfg.provider : "say";
81
- const out = join(tmpdir(), `hara-tts-${randomUUID().slice(0, 8)}.${EXT[provider] || "wav"}`);
203
+ const out = join(tmpdir(), `hara-tts-${randomUUID()}.${EXT[provider] || "wav"}`);
82
204
  try {
83
- if (await PROVIDERS[provider](clean, out, cfg))
84
- return out;
85
- }
86
- catch (e) {
87
- console.error(`tts(${provider}): ${e?.message ?? e}`);
88
- }
89
- if (provider !== "say") {
90
205
  try {
91
- const o2 = join(tmpdir(), `hara-tts-${randomUUID().slice(0, 8)}.m4a`);
92
- if (await sayTts(clean, o2, cfg))
93
- return o2; // graceful fallback to local
206
+ if (await PROVIDERS[provider](clean, out, cfg, deadline.signal) && secureCompletedAudio(out))
207
+ return out;
94
208
  }
95
- catch {
96
- /* give up */
209
+ catch (error) {
210
+ if (parentSignal?.aborted)
211
+ throw abortReason(parentSignal);
212
+ if (deadline.signal.aborted) {
213
+ console.error(`tts(${provider}): ${timeoutError.message}`);
214
+ return null;
215
+ }
216
+ console.error(`tts(${provider}): ${safeProviderError(error, cfg)}`);
97
217
  }
218
+ rmSync(out, { force: true });
219
+ if (provider !== "say" && !deadline.signal.aborted) {
220
+ const fallback = join(tmpdir(), `hara-tts-${randomUUID()}.m4a`);
221
+ try {
222
+ if (await sayTts(clean, fallback, cfg, deadline.signal) && secureCompletedAudio(fallback))
223
+ return fallback; // graceful local fallback
224
+ }
225
+ catch (error) {
226
+ if (parentSignal?.aborted)
227
+ throw abortReason(parentSignal);
228
+ if (deadline.signal.aborted)
229
+ console.error(`tts(say): ${timeoutError.message}`);
230
+ else
231
+ console.error(`tts(say): ${safeProviderError(error, cfg)}`);
232
+ }
233
+ rmSync(fallback, { force: true });
234
+ }
235
+ return null;
236
+ }
237
+ finally {
238
+ clearTimeout(timeout);
239
+ parentSignal?.removeEventListener("abort", cancel);
240
+ // Returned paths are owned by the caller. Every other partial/late provider output is removed here.
241
+ if (deadline.signal.aborted)
242
+ rmSync(out, { force: true });
98
243
  }
99
- return null;
100
244
  }
package/dist/hooks.js CHANGED
@@ -2,12 +2,11 @@
2
2
  // PreToolUse runs BEFORE a tool: non-zero, timeout, signal, or launch failure BLOCKS the call.
3
3
  // PostToolUse runs AFTER: observe-only (format, log, notify). Configured in config.json `hooks` + contributed
4
4
  // by plugins. The command receives {tool, payload} as JSON on stdin + HARA_TOOL_NAME in the env.
5
- import { spawnSync } from "node:child_process";
6
5
  import { resolve } from "node:path";
7
6
  import { loadConfig } from "./config.js";
8
7
  import { pluginHooks } from "./plugins/plugins.js";
9
- import { redactToolSubprocessOutput, toolSubprocessEnv } from "./security/subprocess-env.js";
10
- import { shellCommand } from "./sandbox.js";
8
+ import { redactToolSubprocessOutput } from "./security/subprocess-env.js";
9
+ import { runShell } from "./sandbox.js";
11
10
  const cache = new Map();
12
11
  export function resetHooksCache() {
13
12
  cache.clear();
@@ -42,41 +41,42 @@ export function hasHooks(cwd = process.cwd()) {
42
41
  return !!(h.PreToolUse?.length || h.PostToolUse?.length);
43
42
  }
44
43
  /** Run hooks for an event matching `toolName`. Any abnormal PreToolUse termination fails closed;
45
- * PostToolUse remains observe-only. Sync (hooks are short, opt-in); 30s timeout each. */
46
- export function runHooks(event, toolName, payload, cwd, timeoutMs = 30_000) {
44
+ * PostToolUse remains observe-only. Every child owns a cancellable process group; no hook may hold the
45
+ * event loop past the parent run deadline, and an aborted batch never starts its next command. */
46
+ export async function runHooks(event, toolName, payload, cwd, timeoutMs = 30_000, signal) {
47
47
  for (const h of merged(cwd)[event] ?? []) {
48
+ if (signal?.aborted) {
49
+ return event === "PreToolUse"
50
+ ? { block: true, message: "⛔ blocked because the agent run was cancelled before the PreToolUse hook completed" }
51
+ : { block: false, message: "" };
52
+ }
48
53
  if (!matches(h.matcher, toolName))
49
54
  continue;
50
- let r;
51
55
  try {
52
56
  // Hooks are user/plugin-configured external code. Route them through the same command preflight and
53
57
  // macOS protected-read mask as Bash, even though hooks remain observe-only after a tool has run.
54
- const shell = shellCommand(h.command, cwd, "off");
55
- r = spawnSync(shell.cmd, shell.args, {
56
- cwd,
57
- input: JSON.stringify({ tool: toolName, payload }),
58
- encoding: "utf8",
58
+ await runShell(h.command, cwd, "off", {
59
+ signal,
59
60
  timeout: timeoutMs,
60
- env: toolSubprocessEnv(process.env, { HARA_TOOL_NAME: toolName }),
61
+ maxBuffer: 256 * 1024,
62
+ input: JSON.stringify({ tool: toolName, payload }),
63
+ env: { HARA_TOOL_NAME: toolName },
61
64
  });
62
65
  }
63
66
  catch (error) {
64
67
  if (event === "PreToolUse") {
65
- const detail = redactToolSubprocessOutput(error instanceof Error ? error.message : String(error));
66
- return { block: true, message: `⛔ blocked because a PreToolUse hook could not start${detail ? `: ${detail}` : ""}` };
68
+ const failure = error;
69
+ const output = redactToolSubprocessOutput(`${failure.stdout ?? ""}${failure.stderr ?? ""}`.trim());
70
+ const detail = redactToolSubprocessOutput(failure.message || String(error));
71
+ return {
72
+ block: true,
73
+ message: `⛔ blocked by a PreToolUse hook${output ? `: ${output}` : detail ? ` (${detail})` : ""}`,
74
+ };
67
75
  }
68
76
  // PostToolUse is best-effort: a policy-blocked or broken observer must never rewrite the result of a
69
77
  // tool that already completed. In particular, do not retry it through an unsandboxed fallback shell.
70
78
  continue;
71
79
  }
72
- // A hook is allowed to ignore stdin. On Linux, a command that exits successfully before Node finishes
73
- // writing `input` can report EPIPE in r.error *alongside status=0*. The wait status is authoritative in
74
- // that case; true launch/write failures still have status=null, while timeouts/signals remain blocked.
75
- if (event === "PreToolUse" && (r.status !== 0 || !!r.signal)) {
76
- const output = redactToolSubprocessOutput((String(r.stdout ?? "") + String(r.stderr ?? "")).trim());
77
- const failure = redactToolSubprocessOutput(r.error?.message || (r.signal ? `terminated by ${r.signal}` : `exit ${r.status ?? "unknown"}`));
78
- return { block: true, message: `⛔ blocked by a PreToolUse hook${output ? `: ${output}` : ` (${failure})`}` };
79
- }
80
80
  }
81
81
  return { block: false, message: "" };
82
82
  }