@nanhara/hara 0.70.0 → 0.95.2

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 (47) hide show
  1. package/CHANGELOG.md +395 -0
  2. package/README.md +7 -2
  3. package/dist/agent/compact.js +10 -0
  4. package/dist/agent/context-report.js +33 -0
  5. package/dist/agent/failover.js +53 -0
  6. package/dist/agent/loop.js +120 -13
  7. package/dist/agent/rewind.js +20 -0
  8. package/dist/agent/route.js +47 -0
  9. package/dist/checkpoints.js +91 -0
  10. package/dist/config.js +33 -9
  11. package/dist/context/subdir-hints.js +76 -0
  12. package/dist/cron/runner.js +7 -4
  13. package/dist/exec/jobs.js +82 -0
  14. package/dist/gateway/dingtalk.js +209 -0
  15. package/dist/gateway/discord.js +164 -0
  16. package/dist/gateway/feishu.js +144 -0
  17. package/dist/gateway/matrix.js +188 -0
  18. package/dist/gateway/mattermost.js +206 -0
  19. package/dist/gateway/serve.js +339 -0
  20. package/dist/gateway/sessions.js +89 -0
  21. package/dist/gateway/signal.js +220 -0
  22. package/dist/gateway/slack.js +190 -0
  23. package/dist/gateway/telegram.js +115 -0
  24. package/dist/gateway/tmux-routes.js +135 -0
  25. package/dist/gateway/tts.js +100 -0
  26. package/dist/gateway/wecom.js +383 -0
  27. package/dist/gateway/weixin.js +590 -0
  28. package/dist/index.js +1073 -120
  29. package/dist/org-fleet/enroll.js +28 -5
  30. package/dist/plugins/plugins.js +49 -1
  31. package/dist/profile/profile.js +436 -0
  32. package/dist/providers/anthropic.js +28 -1
  33. package/dist/providers/openai.js +16 -1
  34. package/dist/sandbox.js +15 -12
  35. package/dist/security/external-content.js +57 -0
  36. package/dist/security/permissions.js +211 -0
  37. package/dist/session/session-model.js +36 -0
  38. package/dist/statusbar.js +12 -3
  39. package/dist/tools/builtin.js +49 -2
  40. package/dist/tools/external_agent.js +118 -0
  41. package/dist/tools/send.js +35 -0
  42. package/dist/tools/skill.js +6 -2
  43. package/dist/tools/todo.js +65 -8
  44. package/dist/tools/web.js +4 -3
  45. package/dist/tui/App.js +241 -30
  46. package/dist/tui/run.js +36 -1
  47. package/package.json +4 -2
@@ -6,6 +6,21 @@ import { makeRenderer } from "../md.js";
6
6
  import { skillsDigest } from "../skills/skills.js";
7
7
  import { runHooks } from "../hooks.js";
8
8
  import { mapLimit, maxParallel } from "../concurrency.js";
9
+ import { decideCommand, loadPermissionRules } from "../security/permissions.js";
10
+ import { subdirHint } from "../context/subdir-hints.js";
11
+ import { classifyError, failoverAction, errorHint } from "./failover.js";
12
+ import { currentTodos } from "../tools/todo.js";
13
+ /** Spinner verb (terminal mode + reused by TUI tests): when the agent has an in_progress todo,
14
+ * surface its activeForm/text so the bottom-of-screen line reads concretely ("▶ updating tests… 3s")
15
+ * instead of "working 3s". Pure: takes a snapshot + elapsed seconds. */
16
+ export function spinnerVerb(list, elapsedSec) {
17
+ const active = list.find((t) => t.status === "in_progress");
18
+ if (active) {
19
+ const phrase = active.activeForm?.trim() || active.text;
20
+ return `${phrase}… ${elapsedSec}s`;
21
+ }
22
+ return `working ${elapsedSec}s`;
23
+ }
9
24
  /** Whether a tool call needs user confirmation under the given approval mode. */
10
25
  export function needsConfirm(kind, mode) {
11
26
  if (kind === "read")
@@ -30,10 +45,24 @@ When a task matches one of the Skills listed below, call the \`skill\` tool to l
30
45
  before acting; save a reusable how-to as a new skill with skill_create. If you discover a durable project
31
46
  convention, you may propose an edit to AGENTS.md via edit_file (the user reviews the diff). After completing
32
47
  a task, give a one-line summary.`;
48
+ /** When running inside `hara gateway`, tell the agent it's in a chat — so it delivers files via send_file
49
+ * (the only channel that reaches the peer) and never reaches for the desktop client / computer tool. */
50
+ function gatewayNote() {
51
+ const plat = process.env.HARA_GATEWAY;
52
+ if (!plat)
53
+ return "";
54
+ return (`\n\n# You are in a chat gateway (${plat})\n` +
55
+ `You are talking to the user through the ${plat} chat — not a terminal, and NOT the desktop ${plat} app. ` +
56
+ `To send a file or image to them, call the \`send_file\` tool with an absolute path; that is the ONLY channel ` +
57
+ `that reaches this chat. Do NOT use the \`computer\` tool, AppleScript, or any desktop/${plat}-client automation ` +
58
+ `to deliver files — that drives a different window and silently fails to reach the user. Never tell the user a ` +
59
+ `file was sent unless \`send_file\` returned success. Keep replies short and chat-friendly.`);
60
+ }
33
61
  function composeSystem(cwd, projectContext, override, memory) {
34
62
  const head = override ? `${override}\n\nWorking directory: ${cwd}` : HARA_SYSTEM(cwd);
35
63
  const skills = skillsDigest(cwd);
36
64
  return (head +
65
+ gatewayNote() +
37
66
  (projectContext ? `\n\n# Project context (AGENTS.md)\n${projectContext}` : "") +
38
67
  (memory ? `\n\n# Memory (durable — facts/decisions/prefs you've saved; use memory_search/get for more)\n${memory}` : "") +
39
68
  (skills ? `\n\n# Skills (capabilities you can load — call the \`skill\` tool with the id for full instructions before using one)\n${skills}` : ""));
@@ -41,6 +70,16 @@ function composeSystem(cwd, projectContext, override, memory) {
41
70
  /** Provider-agnostic agentic loop. Mutates `history` in place. */
42
71
  export async function runAgent(history, opts) {
43
72
  const { provider, ctx } = opts;
73
+ const permRules = loadPermissionRules(ctx.cwd); // command-level allow/ask/deny policy for the bash tool
74
+ let activeProvider = provider; // may switch to a fallback model on a recoverable error (app-failover)
75
+ let triedFallback = false;
76
+ // Stuck/loop guard — only in headless chat (`hara gateway`), where a wrong approach can grind forever with
77
+ // nobody to hit Esc (e.g. screenshots it can't read). Once per run, when the agent keeps repeating one
78
+ // non-read tool or acting blind, we inject a reflection nudge so it steps back instead of spinning.
79
+ const guard = !!process.env.HARA_GATEWAY;
80
+ const toolCounts = new Map();
81
+ let blindShots = 0;
82
+ let nudged = false;
44
83
  for (;;) {
45
84
  // Type-ahead steering: fold in anything the user submitted while the previous step ran, so it
46
85
  // reaches the model on this next call (drained after the last tool round; empty on the 1st pass).
@@ -52,7 +91,17 @@ export async function runAgent(history, opts) {
52
91
  const sink = ctx.ui; // TUI mode: route output to ink instead of stdout
53
92
  const tty = stdout.isTTY && !opts.quiet && !sink;
54
93
  const md = tty && process.env.HARA_MD !== "0" ? makeRenderer(out) : null;
55
- let sawReasoning = false;
94
+ // Reasoning rendering in plain-terminal mode: we put reasoning on its OWN dim lines (prefixed
95
+ // "│ ") instead of sharing a line with the spinner — that's what was eating DeepSeek's
96
+ // reasoning_content in non-TUI mode (each spinner tick `\r`-overwrote it). The TUI keeps its
97
+ // existing 5-line scroll window via the ink Block; this is the terminal equivalent.
98
+ let reasoningOpen = false;
99
+ const flushReasoningTail = () => {
100
+ if (reasoningOpen) {
101
+ out("\n");
102
+ reasoningOpen = false;
103
+ }
104
+ };
56
105
  // "working Ns" spinner until the first output arrives (cleared on text/reasoning or turn end)
57
106
  let spin = null;
58
107
  const stopSpin = () => {
@@ -66,9 +115,12 @@ export async function runAgent(history, opts) {
66
115
  const frames = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏";
67
116
  const t0 = Date.now();
68
117
  let fi = 0;
69
- spin = setInterval(() => out(`\r${c.dim(`${frames[fi++ % frames.length]} working ${Math.floor((Date.now() - t0) / 1000)}s`)}`), 100);
118
+ spin = setInterval(() => {
119
+ const verb = spinnerVerb(currentTodos(), Math.floor((Date.now() - t0) / 1000));
120
+ out(`\r\x1b[K${c.dim(`${frames[fi++ % frames.length]} ${verb}`)}`);
121
+ }, 100);
70
122
  }
71
- const r = await provider.turn({
123
+ const r = await activeProvider.turn({
72
124
  system: composeSystem(ctx.cwd, opts.projectContext, opts.systemOverride, opts.memory),
73
125
  history,
74
126
  tools: specs,
@@ -80,10 +132,7 @@ export async function runAgent(history, opts) {
80
132
  return;
81
133
  }
82
134
  stopSpin();
83
- if (sawReasoning) {
84
- out("\n");
85
- sawReasoning = false;
86
- }
135
+ flushReasoningTail();
87
136
  if (md)
88
137
  md.push(d);
89
138
  else
@@ -97,14 +146,29 @@ export async function runAgent(history, opts) {
97
146
  sink.reasoning(d);
98
147
  return;
99
148
  }
149
+ // Terminal mode: render reasoning on its own dim lines (prefix `│ ` per line). Each
150
+ // line is committed once and never overwritten — so a subsequent spinner tick can't
151
+ // clobber it (the old `out(c.dim(d))` bug). Multi-line deltas split cleanly; the
152
+ // current line resumes mid-output when the next delta arrives.
100
153
  stopSpin();
101
- sawReasoning = true;
102
- out(c.dim(d));
154
+ const lines = d.split("\n");
155
+ for (let i = 0; i < lines.length; i++) {
156
+ if (!reasoningOpen) {
157
+ out(c.dim("│ "));
158
+ reasoningOpen = true;
159
+ }
160
+ out(c.dim(lines[i]));
161
+ if (i < lines.length - 1) {
162
+ out("\n");
163
+ reasoningOpen = false;
164
+ }
165
+ }
103
166
  }
104
167
  : undefined,
105
168
  signal: opts.signal,
106
169
  });
107
170
  stopSpin();
171
+ flushReasoningTail();
108
172
  md?.end();
109
173
  if (!opts.quiet && !sink)
110
174
  out("\n");
@@ -115,12 +179,26 @@ export async function runAgent(history, opts) {
115
179
  }
116
180
  history.push({ role: "assistant", text: r.text, toolUses: r.toolUses });
117
181
  if (r.stop === "error") {
118
- const msg = r.errorMsg === "interrupted" ? "(interrupted)" : `[${provider.id} error] ${r.errorMsg ?? "unknown"}`;
182
+ const kind = classifyError(r.errorMsg ?? "");
183
+ if (failoverAction(kind, { hasFallback: !!opts.fallback?.provider, triedFallback }) === "fallback") {
184
+ triedFallback = true;
185
+ history.pop(); // drop the errored (partial/empty) assistant turn before retrying
186
+ activeProvider = opts.fallback.provider;
187
+ if (!opts.quiet) {
188
+ const note = `✻ ${kind} → falling back to ${activeProvider.model}…`;
189
+ if (sink)
190
+ sink.notice(note);
191
+ else
192
+ out(c.dim(`${note}\n`));
193
+ }
194
+ continue; // retry once on the fallback model (guarded by triedFallback)
195
+ }
196
+ const msg = kind === "interrupted" ? "(interrupted)" : `[${activeProvider.id} error] ${r.errorMsg ?? "unknown"}${errorHint(kind)}`;
119
197
  if (!opts.quiet) {
120
198
  if (sink)
121
199
  sink.notice(msg);
122
200
  else
123
- out(r.errorMsg === "interrupted" ? c.dim(`\n${msg}\n`) : c.red(`${msg}\n`));
201
+ out(kind === "interrupted" ? c.dim(`\n${msg}\n`) : c.red(`${msg}\n`));
124
202
  }
125
203
  return;
126
204
  }
@@ -139,7 +217,14 @@ export async function runAgent(history, opts) {
139
217
  .trim();
140
218
  // Screen control is gated on EVERY action — a prior "don't ask again" must never satisfy it.
141
219
  const alwaysGate = tool.kind === "computer";
142
- if (needsConfirm(tool.kind, opts.approval) && (alwaysGate || !opts.autoApprove?.has(tu.name))) {
220
+ // Command-level policy for shell commands: a deny rule blocks even in full-auto; an allow rule (or a
221
+ // read-only command) auto-runs even in suggest mode. Composes with, doesn't replace, the approval mode.
222
+ const cmdDecision = tool.kind === "exec" && typeof input.command === "string" ? decideCommand(input.command, permRules) : null;
223
+ if (cmdDecision === "deny") {
224
+ plans.push({ tu, tool, denied: "Denied by a permission rule (~/.hara/permissions.json). Loosen the rule or run it yourself." });
225
+ continue;
226
+ }
227
+ if (cmdDecision !== "allow" && needsConfirm(tool.kind, opts.approval) && (alwaysGate || !opts.autoApprove?.has(tu.name))) {
143
228
  const reply = await opts.confirm(`${c.yellow("⚠")} ${c.bold(tu.name)} ${c.dim(preview)} — run?`);
144
229
  if (reply === false) {
145
230
  plans.push({ tu, tool, denied: "User denied this action." });
@@ -172,7 +257,8 @@ export async function runAgent(history, opts) {
172
257
  return;
173
258
  }
174
259
  const res = await p.tool.run(p.tu.input, ctx);
175
- results[idx] = { id: p.tu.id, name: p.tu.name, content: res };
260
+ // append any not-yet-seen subdirectory AGENTS.md/CLAUDE.md this call touched (monorepo-local conventions)
261
+ results[idx] = { id: p.tu.id, name: p.tu.name, content: res + subdirHint(p.tu.input, ctx.cwd) };
176
262
  runHooks("PostToolUse", p.tu.name, { input: p.tu.input, result: res }, ctx.cwd); // observe-only
177
263
  }
178
264
  catch (e) {
@@ -202,5 +288,26 @@ export async function runAgent(history, opts) {
202
288
  }
203
289
  await flush();
204
290
  history.push({ role: "tool", results });
291
+ if (guard && !nudged) {
292
+ for (const p of plans)
293
+ if (p.tool && p.tool.kind !== "read")
294
+ toolCounts.set(p.tu.name, (toolCounts.get(p.tu.name) ?? 0) + 1);
295
+ for (const res of results)
296
+ if (typeof res.content === "string" && /Configure a vision model/.test(res.content))
297
+ blindShots++;
298
+ const maxRepeat = Math.max(0, ...toolCounts.values());
299
+ const blind = blindShots >= 2;
300
+ if (blind || maxRepeat >= 5) {
301
+ nudged = true;
302
+ history.push({
303
+ role: "user",
304
+ content: blind
305
+ ? "⚠ Self-check: your screenshots come back unreadable (no vision model) — you are acting blind, so this approach cannot work. Stop using the computer tool. Reach the user through a non-visual path instead (a CLI, an API, or the send_file tool). State the new plan in one line, then do it."
306
+ : "⚠ Self-check: you've repeated the same action several times without resolving the task. Stop and reconsider — is there a more direct tool or channel (e.g. send_file to deliver a file)? Don't keep retrying the same thing. State your revised plan in one line, then act.",
307
+ });
308
+ if (!opts.quiet && !ctx.ui)
309
+ out(c.dim(" ⟲ stuck-guard: nudging a rethink\n"));
310
+ }
311
+ }
205
312
  }
206
313
  }
@@ -0,0 +1,20 @@
1
+ /** Recent user turns, newest first (n=1 = most recent), each with a short preview — for `/rewind` to list. */
2
+ export function userTurnPreviews(history, max = 10) {
3
+ const idxs = [];
4
+ history.forEach((m, i) => m.role === "user" && idxs.push(i));
5
+ const out = [];
6
+ for (let k = idxs.length - 1, n = 1; k >= 0 && n <= max; k--, n++) {
7
+ const m = history[idxs[k]];
8
+ out.push({ n, preview: (m.role === "user" ? m.content : "").replace(/\s+/g, " ").slice(0, 70) });
9
+ }
10
+ return out;
11
+ }
12
+ /** Truncate history to just BEFORE the n-th-most-recent user turn (n=1 drops the last exchange), forking the
13
+ * conversation from that point. Returns the new history array, or null if n is out of range. */
14
+ export function rewindTo(history, n) {
15
+ const idxs = [];
16
+ history.forEach((m, i) => m.role === "user" && idxs.push(i));
17
+ if (!Number.isInteger(n) || n < 1 || n > idxs.length)
18
+ return null;
19
+ return history.slice(0, idxs[idxs.length - n]); // cut at the n-th-from-last user message
20
+ }
@@ -0,0 +1,47 @@
1
+ // Words that signal real coding/action work → keep the primary (strong) model. Broad on purpose: routing
2
+ // should fire only on clearly trivial, non-actionable turns (questions, lookups, chit-chat).
3
+ const COMPLEX = /\b(debug|refactor|implement|fix(es|ed|ing)?|build|test|deploy|migrat\w*|optimi[sz]e|architect|design|review|trace|profile|benchmark|docker|kubernetes|compile|exception|error|bug|patch|diff|commit|merge|rebase|rename|add|remove|delete|update|change|write|create|edit|run|install|generate|convert|parse|format|lint|setup|configure|wire|hook|render|fetch|query|schema|class|function|async|await|import|export)\b/i;
4
+ /** The text of the most recent genuine user message (tool results are role:"tool", so this is stable
5
+ * across a turn's tool rounds). */
6
+ export function lastUserText(history) {
7
+ for (let i = history.length - 1; i >= 0; i--) {
8
+ const m = history[i];
9
+ if (m.role === "user")
10
+ return typeof m.content === "string" ? m.content : "";
11
+ }
12
+ return "";
13
+ }
14
+ /** True if a turn is trivial enough to hand to the cheap/general model: short, single-line, no code,
15
+ * no URL, and no coding/action keyword. Defaults to FALSE (stay on the strong model) when unsure. */
16
+ export function isTrivialTurn(text) {
17
+ const t = (text ?? "").trim();
18
+ if (!t)
19
+ return false;
20
+ if (t.length > 160)
21
+ return false; // long → probably substantive
22
+ if (t.split(/\s+/).length > 28)
23
+ return false;
24
+ if (t.includes("\n"))
25
+ return false; // multi-line / paste
26
+ if (t.includes("`"))
27
+ return false; // inline code / fences
28
+ if (/https?:\/\//i.test(t))
29
+ return false; // a URL to act on
30
+ if (/[{}();=]|=>|::|\/|\\/.test(t))
31
+ return false; // code-ish / a path
32
+ if (COMPLEX.test(t))
33
+ return false; // an action/coding verb
34
+ return true;
35
+ }
36
+ /** Wrap a primary + alternate provider so each turn routes to the alternate when the latest user message is
37
+ * trivial, else the primary. Decided per turn from history (stable across tool rounds). */
38
+ export function routingProvider(primary, alt) {
39
+ return {
40
+ id: primary.id,
41
+ model: primary.model, // reported model = primary; routing is transparent
42
+ turn(args) {
43
+ const useAlt = isTrivialTurn(lastUserText(args.history));
44
+ return (useAlt ? alt : primary).turn(args);
45
+ },
46
+ };
47
+ }
@@ -0,0 +1,91 @@
1
+ // File-state checkpoints via a SHADOW git repo — durable "undo the agent's file changes" beyond the
2
+ // edit-only in-memory undo (which misses bash-made changes). The shadow repo lives OUTSIDE the project
3
+ // (~/.hara/checkpoints/<hash>/git) with GIT_DIR there + GIT_WORK_TREE = the project root, so it captures the
4
+ // WHOLE tree, NEVER touches the user's real .git/index, and the model never sees it. Restore is
5
+ // snapshot-then-checkout: SAFE — it reverts changed/deleted files to the checkpoint and never deletes files
6
+ // created since (so a stray restore can't nuke new work; it's also itself undoable via the auto-snapshot).
7
+ import { execFileSync } from "node:child_process";
8
+ import { mkdirSync, writeFileSync, existsSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { homedir } from "node:os";
11
+ import { createHash } from "node:crypto";
12
+ import { findProjectRoot } from "./context/agents-md.js";
13
+ // Heavy/derived dirs the shadow repo must never snapshot (in addition to the project's own .gitignore).
14
+ const EXCLUDES = ["node_modules/", ".git/", "dist/", "build/", "out/", ".next/", "target/", ".venv/", "venv/", "__pycache__/", ".hara/", ".cache/", ".turbo/", "coverage/", "*.log", ".DS_Store"];
15
+ function shadowGitDir(root) {
16
+ return join(homedir(), ".hara", "checkpoints", createHash("sha256").update(root).digest("hex").slice(0, 16), "git");
17
+ }
18
+ function git(root, gitDir, args) {
19
+ return execFileSync("git", args, {
20
+ cwd: root,
21
+ env: { ...process.env, GIT_DIR: gitDir, GIT_WORK_TREE: root, GIT_AUTHOR_NAME: "hara", GIT_AUTHOR_EMAIL: "hara@local", GIT_COMMITTER_NAME: "hara", GIT_COMMITTER_EMAIL: "hara@local" },
22
+ encoding: "utf8",
23
+ stdio: ["ignore", "pipe", "ignore"],
24
+ maxBuffer: 64 * 1024 * 1024,
25
+ }).toString();
26
+ }
27
+ function ensureRepo(root, gitDir) {
28
+ try {
29
+ if (!existsSync(join(gitDir, "HEAD"))) {
30
+ mkdirSync(gitDir, { recursive: true });
31
+ git(root, gitDir, ["init", "-q"]);
32
+ mkdirSync(join(gitDir, "info"), { recursive: true });
33
+ writeFileSync(join(gitDir, "info", "exclude"), EXCLUDES.join("\n") + "\n");
34
+ }
35
+ return true;
36
+ }
37
+ catch {
38
+ return false;
39
+ }
40
+ }
41
+ /** Snapshot the current working tree into the shadow repo. Returns the short sha, or null on failure. */
42
+ export function checkpoint(cwd, label) {
43
+ const root = findProjectRoot(cwd);
44
+ const gitDir = shadowGitDir(root);
45
+ if (!ensureRepo(root, gitDir))
46
+ return null;
47
+ try {
48
+ git(root, gitDir, ["add", "-A"]);
49
+ git(root, gitDir, ["commit", "-q", "--allow-empty", "--no-gpg-sign", "-m", (label || "checkpoint").slice(0, 120)]);
50
+ return git(root, gitDir, ["rev-parse", "--short", "HEAD"]).trim();
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ }
56
+ /** Recent checkpoints, newest first. */
57
+ export function listCheckpoints(cwd, n = 15) {
58
+ const gitDir = shadowGitDir(findProjectRoot(cwd));
59
+ if (!existsSync(join(gitDir, "HEAD")))
60
+ return [];
61
+ try {
62
+ const out = git(findProjectRoot(cwd), gitDir, ["log", `-n${n}`, "--format=%h\x1f%ct\x1f%s"]).trim();
63
+ if (!out)
64
+ return [];
65
+ return out.split("\n").map((l) => {
66
+ const [sha, ct, ...rest] = l.split("\x1f");
67
+ return { sha, when: Number(ct) * 1000, label: rest.join("\x1f") };
68
+ });
69
+ }
70
+ catch {
71
+ return [];
72
+ }
73
+ }
74
+ /** Restore the working tree's changed/deleted files to a checkpoint (snapshots current first, so it's
75
+ * undoable). Files created AFTER the checkpoint are left in place — nothing is deleted. Returns the count of
76
+ * files restored, or null on failure. */
77
+ export function restoreCheckpoint(cwd, ref) {
78
+ const root = findProjectRoot(cwd);
79
+ const gitDir = shadowGitDir(root);
80
+ if (!existsSync(join(gitDir, "HEAD")))
81
+ return null;
82
+ try {
83
+ checkpoint(cwd, `before restore to ${ref}`); // make the restore itself undoable
84
+ const changed = git(root, gitDir, ["diff", "--name-only", ref, "--"]).trim(); // files differing now vs ref
85
+ git(root, gitDir, ["checkout", ref, "--", "."]);
86
+ return changed ? changed.split("\n").length : 0;
87
+ }
88
+ catch {
89
+ return null;
90
+ }
91
+ }
package/dist/config.js CHANGED
@@ -12,7 +12,8 @@ const PROVIDER_DEFAULTS = {
12
12
  openai: { model: "gpt-4o-mini", envKey: "OPENAI_API_KEY" },
13
13
  "hara-gateway": { model: "", envKey: "HARA_GATEWAY_TOKEN" }, // B-end: enrolled device → token in ~/.hara/org.json, routed by the gateway
14
14
  };
15
- export const CONFIG_KEYS = ["provider", "apiKey", "model", "baseURL", "approval", "sandbox", "theme", "evolve", "assetCapture", "computerUse", "computerApps", "visionModel", "visionBaseURL", "visionApiKey", "embedProvider", "embedModel", "embedBaseURL", "embedApiKey", "notify", "vimMode"];
15
+ export const CONFIG_KEYS = ["provider", "apiKey", "model", "baseURL", "approval", "sandbox", "theme", "evolve", "assetCapture", "computerUse", "computerApps", "visionModel", "visionBaseURL", "visionApiKey", "embedProvider", "embedModel", "embedBaseURL", "embedApiKey", "routeModel", "routeBaseURL", "routeApiKey", "notify", "vimMode", "autoCompact", "fileCheckpoints", "fallbackModel", "fallbackBaseURL", "fallbackApiKey", "reasoningEffort"];
16
+ export const REASONING_EFFORTS = ["off", "low", "medium", "high"];
16
17
  export const APPROVAL_MODES = ["suggest", "auto-edit", "full-auto"];
17
18
  export const SANDBOX_MODES = ["off", "workspace-write", "read-only"];
18
19
  const PROJECT_ROOT_MARKERS = [".git", "package.json", "Cargo.toml", "go.mod", "pyproject.toml", ".hg"];
@@ -82,16 +83,27 @@ export function setModelVisionOverride(model, cap) {
82
83
  persistConfig(p, cfg);
83
84
  }
84
85
  /**
85
- * Effective config. Precedence (high→low): env vars > selected profile >
86
- * project `.hara/config.json` > global `~/.hara/config.json` > provider defaults.
86
+ * Effective config. Precedence (high→low): env vars > project `.hara/config.json` >
87
+ * named overlay (`overlays.<name>` in global config) > global `~/.hara/config.json`
88
+ * > provider defaults.
89
+ *
90
+ * NOTE: `--profile` / `HARA_PROFILE` is the IDENTITY-profile selector (personal ↔ org A
91
+ * ↔ org B) — see src/profile/profile.ts. The legacy `profiles:{name:partial}` overlay
92
+ * mechanism (a tiny in-config preset / overlay) has been renamed to `overlays:{...}`
93
+ * to free the "profile" word for identity. We still read the legacy `profiles:{...}`
94
+ * key for one release for back-compat. Overlays are addressed by env var
95
+ * `HARA_OVERLAY=<name>` (or `opts.overlay`).
87
96
  */
88
97
  export function loadConfig(opts = {}) {
89
98
  const global = readRawConfig();
90
- const { profiles, ...globalBase } = global;
99
+ // Strip both the new (`overlays`) and legacy (`profiles`) overlay containers from the base merge.
100
+ // The legacy `profiles` key is kept readable for back-compat with users who already have it.
101
+ const { overlays, profiles, ...globalBase } = global;
91
102
  const project = readProjectConfig(process.cwd());
92
- const profileName = process.env.HARA_PROFILE ?? opts.profile;
93
- const profile = profileName && profiles && profiles[profileName] ? profiles[profileName] : {};
94
- const merged = { ...globalBase, ...project, ...profile };
103
+ const overlayName = process.env.HARA_OVERLAY ?? opts.overlay;
104
+ const overlayMap = overlays && typeof overlays === "object" ? overlays : profiles && typeof profiles === "object" ? profiles : null;
105
+ const overlay = overlayName && overlayMap && overlayMap[overlayName] ? overlayMap[overlayName] : {};
106
+ const merged = { ...globalBase, ...project, ...overlay };
95
107
  const provider = (process.env.HARA_PROVIDER ?? merged.provider ?? "anthropic");
96
108
  const d = PROVIDER_DEFAULTS[provider] ?? PROVIDER_DEFAULTS.anthropic;
97
109
  const model = process.env.HARA_MODEL ?? merged.model ?? d.model;
@@ -112,15 +124,27 @@ export function loadConfig(opts = {}) {
112
124
  const embedModel = process.env.HARA_EMBED_MODEL ?? merged.embedModel;
113
125
  const embedBaseURL = process.env.HARA_EMBED_BASE_URL ?? merged.embedBaseURL;
114
126
  const embedApiKey = process.env.HARA_EMBED_API_KEY ?? merged.embedApiKey;
127
+ const routeModel = process.env.HARA_ROUTE_MODEL ?? merged.routeModel;
128
+ const routeBaseURL = process.env.HARA_ROUTE_BASE_URL ?? merged.routeBaseURL;
129
+ const routeApiKey = process.env.HARA_ROUTE_API_KEY ?? merged.routeApiKey;
115
130
  const mcpServers = {
116
131
  ...(globalBase.mcpServers ?? {}),
117
132
  ...(project.mcpServers ?? {}),
118
- ...(profile.mcpServers ?? {}),
133
+ ...(overlay.mcpServers ?? {}),
119
134
  };
120
135
  const hooks = (merged.hooks && typeof merged.hooks === "object" ? merged.hooks : {});
121
136
  const notify = (process.env.HARA_NOTIFY ?? merged.notify ?? "off");
122
137
  const vimMode = process.env.HARA_VIM === "1" || merged.vimMode === true || merged.vimMode === "true";
123
- return { provider, apiKey, model, baseURL, approval, sandbox, theme, evolve, assetCapture, computerUse, computerApps, visionModel, visionBaseURL, visionApiKey, modelVision, embedProvider, embedModel, embedBaseURL, embedApiKey, hooks, notify, vimMode, mcpServers, cwd: process.cwd() };
138
+ const autoCompact = !(process.env.HARA_AUTO_COMPACT === "0" || merged.autoCompact === false || merged.autoCompact === "false"); // default ON
139
+ const fileCheckpoints = !(process.env.HARA_CHECKPOINTS === "0" || merged.fileCheckpoints === false || merged.fileCheckpoints === "false"); // default ON
140
+ const fallbackModel = process.env.HARA_FALLBACK_MODEL ?? merged.fallbackModel;
141
+ const fallbackBaseURL = process.env.HARA_FALLBACK_BASE_URL ?? merged.fallbackBaseURL;
142
+ const fallbackApiKey = process.env.HARA_FALLBACK_API_KEY ?? merged.fallbackApiKey;
143
+ const reasoningRaw = process.env.HARA_REASONING_EFFORT ?? merged.reasoningEffort;
144
+ const reasoningEffort = reasoningRaw && ["off", "low", "medium", "high"].includes(reasoningRaw)
145
+ ? reasoningRaw
146
+ : undefined;
147
+ return { provider, apiKey, model, baseURL, approval, sandbox, theme, evolve, assetCapture, computerUse, computerApps, visionModel, visionBaseURL, visionApiKey, modelVision, embedProvider, embedModel, embedBaseURL, embedApiKey, routeModel, routeBaseURL, routeApiKey, hooks, notify, vimMode, autoCompact, fileCheckpoints, fallbackModel, fallbackBaseURL, fallbackApiKey, reasoningEffort, mcpServers, cwd: process.cwd() };
124
148
  }
125
149
  export function providerEnvKey(provider) {
126
150
  return (PROVIDER_DEFAULTS[provider] ?? PROVIDER_DEFAULTS.anthropic).envKey;
@@ -0,0 +1,76 @@
1
+ // Lazy per-directory project docs — when a tool touches a directory we haven't seen yet, surface that dir's
2
+ // AGENTS.md / CLAUDE.md (the local conventions for that package) by appending it to the tool result, so a
3
+ // monorepo's per-package rules reach the model exactly when work moves into that package. Startup already
4
+ // loads root→cwd (agents-md.ts); this covers the subdirs the agent navigates INTO. Each dir loaded once.
5
+ import { readFileSync, existsSync, statSync } from "node:fs";
6
+ import { join, dirname, resolve, relative, isAbsolute } from "node:path";
7
+ import { findProjectRoot } from "./agents-md.js";
8
+ const FILENAMES = ["AGENTS.override.md", "AGENTS.md", "CLAUDE.md"];
9
+ const MAX = 8 * 1024;
10
+ const loaded = new Set(); // dirs whose local doc we've already injected (per process / session)
11
+ function isDir(p) {
12
+ try {
13
+ return statSync(p).isDirectory();
14
+ }
15
+ catch {
16
+ return false;
17
+ }
18
+ }
19
+ /** Candidate file/dir paths referenced by a tool call (its `path`, or path-like tokens in a shell command). */
20
+ function pathsFrom(input) {
21
+ const out = [];
22
+ if (typeof input?.path === "string")
23
+ out.push(input.path);
24
+ if (typeof input?.command === "string")
25
+ for (const m of input.command.matchAll(/[\w.@~+-]*\/[\w./@+-]+/g))
26
+ out.push(m[0]);
27
+ return out;
28
+ }
29
+ /** Project docs for any NEW directory (strictly under cwd) this tool call touches — appendable to the tool
30
+ * result. Returns "" when nothing new. Each directory is checked/loaded at most once per session. */
31
+ export function subdirHint(input, cwd) {
32
+ const base = resolve(cwd);
33
+ const root = findProjectRoot(cwd);
34
+ const parts = [];
35
+ for (const raw of pathsFrom(input)) {
36
+ const absPath = isAbsolute(raw) ? resolve(raw) : resolve(cwd, raw);
37
+ const startDir = isDir(absPath) ? absPath : dirname(absPath);
38
+ const rel = relative(base, startDir);
39
+ if (!rel || rel.startsWith("..") || isAbsolute(rel))
40
+ continue; // not strictly under cwd → startup already covered it
41
+ // collect unseen dirs from startDir up to (but excluding) cwd
42
+ const chain = [];
43
+ let d = startDir;
44
+ while (d.startsWith(base) && d !== base) {
45
+ if (!loaded.has(d))
46
+ chain.unshift(d);
47
+ if (d === root)
48
+ break;
49
+ const parent = dirname(d);
50
+ if (parent === d)
51
+ break;
52
+ d = parent;
53
+ }
54
+ for (const cd of chain) {
55
+ loaded.add(cd); // mark checked even if no doc here, so we don't re-scan it
56
+ for (const name of FILENAMES) {
57
+ const fp = join(cd, name);
58
+ if (!existsSync(fp))
59
+ continue;
60
+ try {
61
+ let txt = readFileSync(fp, "utf8").trim();
62
+ if (!txt)
63
+ break;
64
+ if (Buffer.byteLength(txt, "utf8") > MAX)
65
+ txt = txt.slice(0, MAX) + "\n…[truncated]";
66
+ parts.push(`<!-- ${name} @ ${cd} — local conventions for this directory -->\n${txt}`);
67
+ }
68
+ catch {
69
+ /* ignore unreadable */
70
+ }
71
+ break; // first filename match per dir
72
+ }
73
+ }
74
+ }
75
+ return parts.length ? "\n\n" + parts.join("\n\n") : "";
76
+ }
@@ -10,11 +10,14 @@ import { isDue } from "./schedule.js";
10
10
  export function dueJobs(jobs, nowMs) {
11
11
  return jobs.filter((j) => j.enabled && isDue(j, nowMs));
12
12
  }
13
- /** How to invoke hara again handles both `node dist/index.js` (argv[1] is a script) and the compiled
14
- * single-binary (argv[1] is a user arg, so re-invoke the binary directly). Used by the tick + by install. */
13
+ /** How to invoke hara again. Under node, argv[1] is the entry to hand back to node — either `dist/index.js`
14
+ * OR the installed `hara` bin symlink (node runs both); as a compiled single-binary, execPath itself IS hara
15
+ * (argv[1] is a user arg), so re-invoke the binary directly. Used by the cron tick + the chat gateway.
16
+ * Discriminator is whether execPath is node — NOT argv[1]'s extension (the bin symlink has no `.js`). */
15
17
  export function selfArgv() {
16
- const a1 = process.argv[1];
17
- return a1 && /\.[cm]?js$|\.ts$/.test(a1) ? [process.execPath, a1] : [process.execPath];
18
+ const exec = process.execPath;
19
+ const underNode = /(^|[\\/])node(\.exe)?$/i.test(exec);
20
+ return underNode && process.argv[1] ? [exec, process.argv[1]] : [exec];
18
21
  }
19
22
  const lockPath = () => join(cronDir(), ".tick.lock");
20
23
  // Generous: a live-PID owner is respected this long (so a genuinely long job isn't double-fired); past it
@@ -0,0 +1,82 @@
1
+ // Background shell jobs — run long-lived commands (dev servers, `tsc --watch`, long builds) WITHOUT blocking
2
+ // the agent, then tail/kill them. Pipe-based (no PTY yet; a true interactive PTY can come later behind an
3
+ // optional dep). Same sandbox write-confinement as runShell (shared `shellCommand`). Jobs are the agent's
4
+ // own child processes and are terminated when hara exits.
5
+ import { spawn } from "node:child_process";
6
+ import { shellCommand } from "../sandbox.js";
7
+ const MAX_BUF = 64 * 1024; // retain only the tail of each job's combined output
8
+ const jobs = new Map();
9
+ let seq = 0;
10
+ let hooked = false;
11
+ function ensureExitCleanup() {
12
+ if (hooked)
13
+ return;
14
+ hooked = true;
15
+ process.on("exit", killAllJobs); // sync; runs on normal quit so background jobs don't orphan
16
+ }
17
+ /** Start a background shell job; returns its id immediately. Output is captured to a capped tail buffer. */
18
+ export function startJob(command, cwd, mode) {
19
+ ensureExitCleanup();
20
+ const { cmd, args } = shellCommand(command, cwd, mode);
21
+ const child = spawn(cmd, args, { cwd });
22
+ const job = { id: "j" + ++seq, command, child, status: "running", code: null, startedAt: Date.now(), buf: "" };
23
+ const onData = (d) => {
24
+ job.buf = (job.buf + d.toString()).slice(-MAX_BUF);
25
+ };
26
+ child.stdout?.on("data", onData);
27
+ child.stderr?.on("data", onData);
28
+ child.on("close", (code) => {
29
+ if (job.status === "running") {
30
+ job.status = "exited";
31
+ job.code = code;
32
+ }
33
+ });
34
+ child.on("error", (e) => {
35
+ if (job.status === "running") {
36
+ job.status = "exited";
37
+ job.code = -1;
38
+ job.buf = (job.buf + `\n[spawn error] ${e.message}`).slice(-MAX_BUF);
39
+ }
40
+ });
41
+ jobs.set(job.id, job);
42
+ return job.id;
43
+ }
44
+ export function listJobs() {
45
+ const now = Date.now();
46
+ return [...jobs.values()].map((j) => ({ id: j.id, command: j.command, status: j.status, code: j.code, ageMs: now - j.startedAt }));
47
+ }
48
+ /** Last `lines` lines of a job's output buffer, or null if there is no such job. */
49
+ export function tailJob(id, lines = 40) {
50
+ const j = jobs.get(id);
51
+ if (!j)
52
+ return null;
53
+ return j.buf.split("\n").slice(-lines).join("\n");
54
+ }
55
+ /** Terminate a running job (SIGTERM). Returns true only if it was running. */
56
+ export function killJob(id) {
57
+ const j = jobs.get(id);
58
+ if (!j || j.status !== "running")
59
+ return false;
60
+ try {
61
+ j.child.kill("SIGTERM");
62
+ }
63
+ catch {
64
+ /* already gone */
65
+ }
66
+ j.status = "killed";
67
+ return true;
68
+ }
69
+ /** Terminate every running job — registered on process exit so dev servers don't outlive hara. */
70
+ export function killAllJobs() {
71
+ for (const j of jobs.values()) {
72
+ if (j.status === "running") {
73
+ try {
74
+ j.child.kill("SIGTERM");
75
+ }
76
+ catch {
77
+ /* ignore */
78
+ }
79
+ j.status = "killed";
80
+ }
81
+ }
82
+ }