@nanhara/hara 0.67.0 → 0.89.0

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.
@@ -6,6 +6,9 @@ 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";
9
12
  /** Whether a tool call needs user confirmation under the given approval mode. */
10
13
  export function needsConfirm(kind, mode) {
11
14
  if (kind === "read")
@@ -30,10 +33,24 @@ When a task matches one of the Skills listed below, call the \`skill\` tool to l
30
33
  before acting; save a reusable how-to as a new skill with skill_create. If you discover a durable project
31
34
  convention, you may propose an edit to AGENTS.md via edit_file (the user reviews the diff). After completing
32
35
  a task, give a one-line summary.`;
36
+ /** When running inside `hara gateway`, tell the agent it's in a chat — so it delivers files via send_file
37
+ * (the only channel that reaches the peer) and never reaches for the desktop client / computer tool. */
38
+ function gatewayNote() {
39
+ const plat = process.env.HARA_GATEWAY;
40
+ if (!plat)
41
+ return "";
42
+ return (`\n\n# You are in a chat gateway (${plat})\n` +
43
+ `You are talking to the user through the ${plat} chat — not a terminal, and NOT the desktop ${plat} app. ` +
44
+ `To send a file or image to them, call the \`send_file\` tool with an absolute path; that is the ONLY channel ` +
45
+ `that reaches this chat. Do NOT use the \`computer\` tool, AppleScript, or any desktop/${plat}-client automation ` +
46
+ `to deliver files — that drives a different window and silently fails to reach the user. Never tell the user a ` +
47
+ `file was sent unless \`send_file\` returned success. Keep replies short and chat-friendly.`);
48
+ }
33
49
  function composeSystem(cwd, projectContext, override, memory) {
34
50
  const head = override ? `${override}\n\nWorking directory: ${cwd}` : HARA_SYSTEM(cwd);
35
51
  const skills = skillsDigest(cwd);
36
52
  return (head +
53
+ gatewayNote() +
37
54
  (projectContext ? `\n\n# Project context (AGENTS.md)\n${projectContext}` : "") +
38
55
  (memory ? `\n\n# Memory (durable — facts/decisions/prefs you've saved; use memory_search/get for more)\n${memory}` : "") +
39
56
  (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 +58,16 @@ function composeSystem(cwd, projectContext, override, memory) {
41
58
  /** Provider-agnostic agentic loop. Mutates `history` in place. */
42
59
  export async function runAgent(history, opts) {
43
60
  const { provider, ctx } = opts;
61
+ const permRules = loadPermissionRules(ctx.cwd); // command-level allow/ask/deny policy for the bash tool
62
+ let activeProvider = provider; // may switch to a fallback model on a recoverable error (app-failover)
63
+ let triedFallback = false;
64
+ // Stuck/loop guard — only in headless chat (`hara gateway`), where a wrong approach can grind forever with
65
+ // nobody to hit Esc (e.g. screenshots it can't read). Once per run, when the agent keeps repeating one
66
+ // non-read tool or acting blind, we inject a reflection nudge so it steps back instead of spinning.
67
+ const guard = !!process.env.HARA_GATEWAY;
68
+ const toolCounts = new Map();
69
+ let blindShots = 0;
70
+ let nudged = false;
44
71
  for (;;) {
45
72
  // Type-ahead steering: fold in anything the user submitted while the previous step ran, so it
46
73
  // reaches the model on this next call (drained after the last tool round; empty on the 1st pass).
@@ -68,7 +95,7 @@ export async function runAgent(history, opts) {
68
95
  let fi = 0;
69
96
  spin = setInterval(() => out(`\r${c.dim(`${frames[fi++ % frames.length]} working ${Math.floor((Date.now() - t0) / 1000)}s`)}`), 100);
70
97
  }
71
- const r = await provider.turn({
98
+ const r = await activeProvider.turn({
72
99
  system: composeSystem(ctx.cwd, opts.projectContext, opts.systemOverride, opts.memory),
73
100
  history,
74
101
  tools: specs,
@@ -115,12 +142,26 @@ export async function runAgent(history, opts) {
115
142
  }
116
143
  history.push({ role: "assistant", text: r.text, toolUses: r.toolUses });
117
144
  if (r.stop === "error") {
118
- const msg = r.errorMsg === "interrupted" ? "(interrupted)" : `[${provider.id} error] ${r.errorMsg ?? "unknown"}`;
145
+ const kind = classifyError(r.errorMsg ?? "");
146
+ if (failoverAction(kind, { hasFallback: !!opts.fallback?.provider, triedFallback }) === "fallback") {
147
+ triedFallback = true;
148
+ history.pop(); // drop the errored (partial/empty) assistant turn before retrying
149
+ activeProvider = opts.fallback.provider;
150
+ if (!opts.quiet) {
151
+ const note = `✻ ${kind} → falling back to ${activeProvider.model}…`;
152
+ if (sink)
153
+ sink.notice(note);
154
+ else
155
+ out(c.dim(`${note}\n`));
156
+ }
157
+ continue; // retry once on the fallback model (guarded by triedFallback)
158
+ }
159
+ const msg = kind === "interrupted" ? "(interrupted)" : `[${activeProvider.id} error] ${r.errorMsg ?? "unknown"}${errorHint(kind)}`;
119
160
  if (!opts.quiet) {
120
161
  if (sink)
121
162
  sink.notice(msg);
122
163
  else
123
- out(r.errorMsg === "interrupted" ? c.dim(`\n${msg}\n`) : c.red(`${msg}\n`));
164
+ out(kind === "interrupted" ? c.dim(`\n${msg}\n`) : c.red(`${msg}\n`));
124
165
  }
125
166
  return;
126
167
  }
@@ -139,7 +180,14 @@ export async function runAgent(history, opts) {
139
180
  .trim();
140
181
  // Screen control is gated on EVERY action — a prior "don't ask again" must never satisfy it.
141
182
  const alwaysGate = tool.kind === "computer";
142
- if (needsConfirm(tool.kind, opts.approval) && (alwaysGate || !opts.autoApprove?.has(tu.name))) {
183
+ // Command-level policy for shell commands: a deny rule blocks even in full-auto; an allow rule (or a
184
+ // read-only command) auto-runs even in suggest mode. Composes with, doesn't replace, the approval mode.
185
+ const cmdDecision = tool.kind === "exec" && typeof input.command === "string" ? decideCommand(input.command, permRules) : null;
186
+ if (cmdDecision === "deny") {
187
+ plans.push({ tu, tool, denied: "Denied by a permission rule (~/.hara/permissions.json). Loosen the rule or run it yourself." });
188
+ continue;
189
+ }
190
+ if (cmdDecision !== "allow" && needsConfirm(tool.kind, opts.approval) && (alwaysGate || !opts.autoApprove?.has(tu.name))) {
143
191
  const reply = await opts.confirm(`${c.yellow("⚠")} ${c.bold(tu.name)} ${c.dim(preview)} — run?`);
144
192
  if (reply === false) {
145
193
  plans.push({ tu, tool, denied: "User denied this action." });
@@ -172,7 +220,8 @@ export async function runAgent(history, opts) {
172
220
  return;
173
221
  }
174
222
  const res = await p.tool.run(p.tu.input, ctx);
175
- results[idx] = { id: p.tu.id, name: p.tu.name, content: res };
223
+ // append any not-yet-seen subdirectory AGENTS.md/CLAUDE.md this call touched (monorepo-local conventions)
224
+ results[idx] = { id: p.tu.id, name: p.tu.name, content: res + subdirHint(p.tu.input, ctx.cwd) };
176
225
  runHooks("PostToolUse", p.tu.name, { input: p.tu.input, result: res }, ctx.cwd); // observe-only
177
226
  }
178
227
  catch (e) {
@@ -202,5 +251,26 @@ export async function runAgent(history, opts) {
202
251
  }
203
252
  await flush();
204
253
  history.push({ role: "tool", results });
254
+ if (guard && !nudged) {
255
+ for (const p of plans)
256
+ if (p.tool && p.tool.kind !== "read")
257
+ toolCounts.set(p.tu.name, (toolCounts.get(p.tu.name) ?? 0) + 1);
258
+ for (const res of results)
259
+ if (typeof res.content === "string" && /Configure a vision model/.test(res.content))
260
+ blindShots++;
261
+ const maxRepeat = Math.max(0, ...toolCounts.values());
262
+ const blind = blindShots >= 2;
263
+ if (blind || maxRepeat >= 5) {
264
+ nudged = true;
265
+ history.push({
266
+ role: "user",
267
+ content: blind
268
+ ? "⚠ 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."
269
+ : "⚠ 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.",
270
+ });
271
+ if (!opts.quiet && !ctx.ui)
272
+ out(c.dim(" ⟲ stuck-guard: nudging a rethink\n"));
273
+ }
274
+ }
205
275
  }
206
276
  }
@@ -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,7 @@ 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"];
16
16
  export const APPROVAL_MODES = ["suggest", "auto-edit", "full-auto"];
17
17
  export const SANDBOX_MODES = ["off", "workspace-write", "read-only"];
18
18
  const PROJECT_ROOT_MARKERS = [".git", "package.json", "Cargo.toml", "go.mod", "pyproject.toml", ".hg"];
@@ -112,6 +112,9 @@ export function loadConfig(opts = {}) {
112
112
  const embedModel = process.env.HARA_EMBED_MODEL ?? merged.embedModel;
113
113
  const embedBaseURL = process.env.HARA_EMBED_BASE_URL ?? merged.embedBaseURL;
114
114
  const embedApiKey = process.env.HARA_EMBED_API_KEY ?? merged.embedApiKey;
115
+ const routeModel = process.env.HARA_ROUTE_MODEL ?? merged.routeModel;
116
+ const routeBaseURL = process.env.HARA_ROUTE_BASE_URL ?? merged.routeBaseURL;
117
+ const routeApiKey = process.env.HARA_ROUTE_API_KEY ?? merged.routeApiKey;
115
118
  const mcpServers = {
116
119
  ...(globalBase.mcpServers ?? {}),
117
120
  ...(project.mcpServers ?? {}),
@@ -120,7 +123,12 @@ export function loadConfig(opts = {}) {
120
123
  const hooks = (merged.hooks && typeof merged.hooks === "object" ? merged.hooks : {});
121
124
  const notify = (process.env.HARA_NOTIFY ?? merged.notify ?? "off");
122
125
  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() };
126
+ const autoCompact = !(process.env.HARA_AUTO_COMPACT === "0" || merged.autoCompact === false || merged.autoCompact === "false"); // default ON
127
+ const fileCheckpoints = !(process.env.HARA_CHECKPOINTS === "0" || merged.fileCheckpoints === false || merged.fileCheckpoints === "false"); // default ON
128
+ const fallbackModel = process.env.HARA_FALLBACK_MODEL ?? merged.fallbackModel;
129
+ const fallbackBaseURL = process.env.HARA_FALLBACK_BASE_URL ?? merged.fallbackBaseURL;
130
+ const fallbackApiKey = process.env.HARA_FALLBACK_API_KEY ?? merged.fallbackApiKey;
131
+ 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, mcpServers, cwd: process.cwd() };
124
132
  }
125
133
  export function providerEnvKey(provider) {
126
134
  return (PROVIDER_DEFAULTS[provider] ?? PROVIDER_DEFAULTS.anthropic).envKey;
@@ -8,45 +8,49 @@ import { mediaTypeFor } from "../images.js";
8
8
  const MAX_FILE = 50_000;
9
9
  // @ at start-of-string or after whitespace; capture a path with no spaces/@ (avoids emails like a@b.com)
10
10
  const MENTION_RE = /(?:^|\s)@([^\s@]+)/g;
11
- /** Append the contents of any @mentioned files to the input as fenced blocks. */
11
+ /** Expand `@path` references **in place** the file/dir content lands where it's referenced, not
12
+ * dumped at the bottom (so "compare @a.ts with @b.ts" reads in context). A repeated mention keeps
13
+ * the bare `@path` the second time (no double-inlining), and a non-readable ref is left untouched. */
12
14
  export function expandMentions(input, cwd) {
13
15
  const seen = new Set();
14
- const blocks = [];
15
- let m;
16
16
  MENTION_RE.lastIndex = 0;
17
- while ((m = MENTION_RE.exec(input)) !== null) {
18
- const ref = m[1];
17
+ return input.replace(MENTION_RE, (whole, ref) => {
18
+ const prefix = whole.slice(0, whole.length - ref.length - 1); // BOL "" or the captured leading whitespace
19
19
  if (seen.has(ref))
20
- continue;
20
+ return whole; // already inlined above → leave this occurrence as the bare @ref
21
+ const block = expandRef(ref, cwd);
22
+ if (block === null)
23
+ return whole; // not a readable file/dir → leave the token as typed
21
24
  seen.add(ref);
22
- const abs = isAbsolute(ref) ? ref : resolve(cwd, ref);
23
- try {
24
- if (existsSync(abs)) {
25
- const st = statSync(abs);
26
- if (st.isFile()) {
27
- if (mediaTypeFor(abs)) {
28
- // don't inline binary image bytes as text — paste it with Ctrl+V (or drag the file in) to attach visually
29
- blocks.push(`Referenced \`${ref}\` is an image — paste it with Ctrl+V to attach it visually.`);
30
- }
31
- else {
32
- let txt = readFileSync(abs, "utf8");
33
- if (txt.length > MAX_FILE)
34
- txt = txt.slice(0, MAX_FILE) + "\n…[truncated]";
35
- blocks.push(`Referenced file \`${ref}\`:\n\`\`\`\n${txt}\n\`\`\``);
36
- }
37
- }
38
- else if (st.isDirectory()) {
39
- // `@dir` loads a listing of the directory's files (the agent can then read specific ones)
40
- const files = walkFiles(abs, 300);
41
- blocks.push(`Referenced directory \`${ref}\` (${files.length} files):\n\`\`\`\n${files.join("\n") || "(empty)"}\n\`\`\``);
42
- }
43
- }
25
+ return `${prefix}${block}`;
26
+ });
27
+ }
28
+ /** Render one mention as an inline block, or null if it isn't a readable file/dir. */
29
+ function expandRef(ref, cwd) {
30
+ const abs = isAbsolute(ref) ? ref : resolve(cwd, ref);
31
+ try {
32
+ if (!existsSync(abs))
33
+ return null;
34
+ const st = statSync(abs);
35
+ if (st.isFile()) {
36
+ // don't inline binary image bytes as text — paste with Ctrl+V (or drag the file in) to attach visually
37
+ if (mediaTypeFor(abs))
38
+ return `Referenced \`${ref}\` is an image — paste it with Ctrl+V to attach it visually.`;
39
+ let txt = readFileSync(abs, "utf8");
40
+ if (txt.length > MAX_FILE)
41
+ txt = txt.slice(0, MAX_FILE) + "\n…[truncated]";
42
+ return `\nReferenced file \`${ref}\`:\n\`\`\`\n${txt}\n\`\`\`\n`;
44
43
  }
45
- catch {
46
- /* ignore unreadable mention */
44
+ if (st.isDirectory()) {
45
+ // `@dir` loads a listing of the directory's files (the agent can then read specific ones)
46
+ const files = walkFiles(abs, 300);
47
+ return `\nReferenced directory \`${ref}\` (${files.length} files):\n\`\`\`\n${files.join("\n") || "(empty)"}\n\`\`\`\n`;
47
48
  }
48
49
  }
49
- return blocks.length ? `${input}\n\n${blocks.join("\n\n")}` : input;
50
+ catch {
51
+ /* ignore unreadable mention */
52
+ }
53
+ return null;
50
54
  }
51
55
  // Short-lived per-cwd cache so Tab completion stays snappy without re-scanning every press.
52
56
  const cache = new Map();
@@ -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
+ }