@nanhara/hara 0.48.0 → 0.62.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.
@@ -3,9 +3,21 @@
3
3
  // project <root>/.hara/memory. Lexical search reuses recall.ts; no embeddings (local-first).
4
4
  import { homedir } from "node:os";
5
5
  import { join, dirname } from "node:path";
6
- import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync } from "node:fs";
6
+ import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, readdirSync } from "node:fs";
7
7
  import { findProjectRoot } from "../context/agents-md.js";
8
- const DIGEST_CAP = 4000; // chars of MEMORY/USER injected at session start (logs reached via search)
8
+ // Per-source budgets for the frozen-snapshot digest (chars). Each source gets its own cap so a large
9
+ // project MEMORY can't crowd out the (smaller but high-value) USER prefs — and each is cut at a line
10
+ // boundary, never mid-entry. Anything beyond these is still reachable via memory_search. (hermes-style
11
+ // per-file budgets; both PAI and hermes confirm lexical injection + capped snapshot beats a vector store.)
12
+ const SOURCE_CAP = { memory: 2000, user: 1200, log: 0 };
13
+ /** Truncate at a line boundary at/under `cap` (never mid-entry), with a pointer to search for the rest. */
14
+ function capAtLine(text, cap) {
15
+ if (text.length <= cap)
16
+ return text;
17
+ const cut = text.slice(0, cap);
18
+ const nl = cut.lastIndexOf("\n");
19
+ return (nl > cap * 0.5 ? cut.slice(0, nl) : cut).trimEnd() + "\n…[truncated — memory_search for the rest]";
20
+ }
9
21
  export function memoryDir(scope, cwd) {
10
22
  if (scope === "global")
11
23
  return process.env.HARA_MEMORY || join(homedir(), ".hara", "memory");
@@ -49,7 +61,9 @@ export function forgetMemory(scope, target, match, cwd) {
49
61
  writeFileSync(f, kept.join("\n"), "utf8");
50
62
  return lines.length - kept.length;
51
63
  }
52
- /** Capped MEMORY + USER digest (project + global) for frozen-snapshot injection at session start. */
64
+ /** MEMORY + USER digest (project + global) for frozen-snapshot injection at session start. Each source is
65
+ * capped independently (SOURCE_CAP) at a line boundary, so every source is represented (project memory
66
+ * never starves USER prefs) and no entry is cut mid-line. Daily logs are reached via memory_search. */
53
67
  export function memoryDigest(cwd) {
54
68
  const sources = [
55
69
  ["project", "memory", "project MEMORY"],
@@ -64,14 +78,38 @@ export function memoryDigest(cwd) {
64
78
  try {
65
79
  const t = readFileSync(f, "utf8").trim();
66
80
  if (t)
67
- parts.push(`## ${label}\n${t}`);
81
+ parts.push(`## ${label}\n${capAtLine(t, SOURCE_CAP[target])}`);
82
+ }
83
+ catch {
84
+ /* skip unreadable */
85
+ }
86
+ }
87
+ return parts.join("\n\n");
88
+ }
89
+ /** Concatenate the daily logs (`log/YYYY-MM-DD.md`) from the last `days` for one scope — the short-term
90
+ * tier `hara memory distill` consolidates into evergreen MEMORY. Empty if there's no log dir. */
91
+ export function readRecentLogs(scope, cwd, days) {
92
+ const dir = join(memoryDir(scope, cwd), "log");
93
+ if (!existsSync(dir))
94
+ return "";
95
+ const cutoff = Date.now() - days * 86_400_000;
96
+ const out = [];
97
+ for (const f of readdirSync(dir).sort()) {
98
+ if (!f.endsWith(".md"))
99
+ continue;
100
+ const m = /^(\d{4})-(\d{2})-(\d{2})\.md$/.exec(f);
101
+ if (m && new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])).getTime() < cutoff)
102
+ continue;
103
+ try {
104
+ const t = readFileSync(join(dir, f), "utf8").trim();
105
+ if (t)
106
+ out.push(`### ${f}\n${t}`);
68
107
  }
69
108
  catch {
70
109
  /* skip unreadable */
71
110
  }
72
111
  }
73
- const out = parts.join("\n\n");
74
- return out.length > DIGEST_CAP ? out.slice(0, DIGEST_CAP) + "\n…[memory truncated — use memory_search]" : out;
112
+ return out.join("\n\n");
75
113
  }
76
114
  /** Create memory dirs + seed files (global + project). Returns files written. */
77
115
  export function scaffoldMemory(cwd) {
package/dist/notify.js ADDED
@@ -0,0 +1,42 @@
1
+ // Task-done notifications — ping the user when a turn finishes (or needs them) so they can walk away
2
+ // during a long run (codex/Claude-Code parity). off = nothing; bell = terminal BEL; system = an OS
3
+ // notification (best-effort, fire-and-forget) + bell. Gated on elapsed so quick turns you watched stay quiet.
4
+ import { spawn } from "node:child_process";
5
+ import { platform } from "node:os";
6
+ export const NOTIFY_MODES = ["off", "bell", "system"];
7
+ /** AppleScript double-quoted string (escape " and \). */
8
+ const osaStr = (s) => '"' + s.replace(/[\\"]/g, "\\$&") + '"';
9
+ /** Fire a notification for a finished/awaiting turn. No-op under `off` or when the turn was quicker than
10
+ * `minMs` (default 8s) — you were watching those. `system` shells out without blocking and also rings the bell. */
11
+ export function notifyDone(mode, opts) {
12
+ if (mode === "off")
13
+ return;
14
+ if (opts.elapsedMs < (opts.minMs ?? 8000))
15
+ return;
16
+ const bell = () => {
17
+ try {
18
+ process.stderr.write("\x07");
19
+ }
20
+ catch {
21
+ /* no tty */
22
+ }
23
+ };
24
+ if (mode === "bell")
25
+ return bell();
26
+ const title = (opts.title ?? "hara").slice(0, 80);
27
+ const msg = opts.message.slice(0, 200).replace(/\s*\n+\s*/g, " ").trim() || "done";
28
+ try {
29
+ const os = platform();
30
+ if (os === "darwin") {
31
+ spawn("osascript", ["-e", `display notification ${osaStr(msg)} with title ${osaStr(title)}`], { stdio: "ignore", detached: true }).unref();
32
+ }
33
+ else if (os === "linux") {
34
+ spawn("notify-send", ["-a", "hara", title, msg], { stdio: "ignore", detached: true }).unref();
35
+ }
36
+ // Windows (and any platform): the bell is the reliable cross-terminal signal; toast needs extra modules.
37
+ }
38
+ catch {
39
+ /* best-effort — a notification must never break the turn */
40
+ }
41
+ bell();
42
+ }
@@ -0,0 +1,91 @@
1
+ // Multi-role review chain — hara's "runs like an engineering org" move. After an implementer makes
2
+ // changes, a reviewer role inspects the diff and either APPROVES or requests changes; requested changes
3
+ // feed back to the implementer, looping until approved or a round cap. The orchestration lives in runOrg
4
+ // (it needs runAgent + providers); these are the pure, testable pieces: verdict parsing, change capture,
5
+ // and the prompts. Used by `hara org --review`.
6
+ import { execFileSync } from "node:child_process";
7
+ /** Fallback reviewer persona when the project has no `reviewer` role. Read-only by intent. */
8
+ export const REVIEWER_SYSTEM = `You are a senior code reviewer reviewing changes made to accomplish a task.
9
+ Inspect them for: correctness and bugs, security, missing edge cases, and whether they actually accomplish
10
+ the task. Use read_file / grep / glob / ls to inspect context and any new files. Be concrete and specific —
11
+ cite files. Block only on real problems (bugs, breakage, security), not style preferences.
12
+
13
+ A script parses your final line, so it MUST be EXACTLY one of these two, verbatim, as the LAST line —
14
+ the literal word APPROVED or CHANGES_REQUESTED. Do NOT paraphrase it (not "No issues found", not "LGTM"),
15
+ do NOT bold it, do NOT add words after the token:
16
+ VERDICT: APPROVED
17
+ VERDICT: CHANGES_REQUESTED
18
+
19
+ Use APPROVED only if the changes correctly and safely accomplish the task. If anything must be fixed, use
20
+ CHANGES_REQUESTED and list the required fixes as a short numbered list ABOVE the verdict line — each one
21
+ naming the file and exactly what to change.`;
22
+ // Real models won't reliably emit the literal token — across live runs glm-5 wrote `VERDICT: APPROVED`,
23
+ // `**VERDICT**: No issues found`, and `**VERDICT**: PASS`. So we anchor on the (markdown-tolerant) VERDICT
24
+ // marker, then CLASSIFY the phrase after it: a "changes" signal vetoes (safer), an "approve" signal passes,
25
+ // and anything ambiguous stays NOT approved — worst case is one extra review round, never a bad auto-commit.
26
+ const CHANGES_RE = /\b(changes?[ _-]?request\w*|request\w*[ _-]?changes?|fail(ed|ure)?|reject\w*|block\w*|rework|needs?[ _-]?(work|fix\w*|change\w*)|must[ _-]?(fix|change)|not[ _-]?approv\w*)\b/i;
27
+ const APPROVE_RE = /\b(approv\w*|passe?d?|lgtm|accept\w*|ship[ _-]?it|no[ _-]?(issues?|problems?|changes?|concerns?)|looks?[ _-]?good)\b/i;
28
+ /** Parse a reviewer's reply into a verdict — see the note above for why it's lenient. Takes the LAST
29
+ * VERDICT marker (the final call) and classifies the phrase after it; `issues` is the body before it. */
30
+ export function parseVerdict(text) {
31
+ const markers = [...text.matchAll(/VERDICT\b[*_:\s]*/gi)];
32
+ const last = markers[markers.length - 1];
33
+ if (!last)
34
+ return { approved: false, issues: text.trim() };
35
+ const idx = last.index ?? 0;
36
+ const after = text.slice(idx + last[0].length, idx + last[0].length + 80); // the verdict phrase itself
37
+ const approved = !CHANGES_RE.test(after) && APPROVE_RE.test(after); // changes-signal vetoes; ambiguous = not approved
38
+ return { approved, issues: text.slice(0, idx).trim() };
39
+ }
40
+ /** Capture the working-tree changes vs HEAD (what to review). Non-destructive; empty for a non-git dir
41
+ * or a repo with no commits. New (untracked) files are listed by name — the reviewer can read_file them. */
42
+ export function captureChanges(cwd, cap = 100_000) {
43
+ const git = (args) => {
44
+ try {
45
+ return execFileSync("git", args, { cwd, encoding: "utf8", maxBuffer: 50_000_000 });
46
+ }
47
+ catch {
48
+ return "";
49
+ }
50
+ };
51
+ let diff = git(["diff", "HEAD"]).trim();
52
+ if (diff.length > cap)
53
+ diff = diff.slice(0, cap) + "\n…[diff truncated]";
54
+ const newFiles = git(["ls-files", "--others", "--exclude-standard"])
55
+ .split("\n")
56
+ .map((s) => s.trim())
57
+ .filter(Boolean)
58
+ .slice(0, 50);
59
+ return { diff, newFiles };
60
+ }
61
+ /** True only if the working tree is fully clean — no uncommitted changes. The `--commit` capstone uses
62
+ * this as a guard: `git add -A` + commit is only safe to run when the tree was clean before the org ran,
63
+ * so it captures THIS run's work and never sweeps up pre-existing WIP. False for a non-git dir. */
64
+ export function isTreeClean(cwd) {
65
+ try {
66
+ return execFileSync("git", ["status", "--porcelain"], { cwd, encoding: "utf8", maxBuffer: 50_000_000 }).trim() === "";
67
+ }
68
+ catch {
69
+ return false; // not a git repo / git error → treat as "not clean" so we never auto-commit blindly
70
+ }
71
+ }
72
+ /** Strip a leading/trailing markdown code fence a model sometimes wraps a commit message in. */
73
+ export function stripCommitFence(text) {
74
+ return text.trim().replace(/^```[a-z]*\n?/i, "").replace(/\n?```$/i, "").trim();
75
+ }
76
+ /** The reviewer's input: the task + the changes to review. */
77
+ export function reviewPrompt(task, changes) {
78
+ const parts = [`Task that was implemented:\n${task}`, ""];
79
+ if (changes.diff)
80
+ parts.push("Changes (working tree vs HEAD):\n```diff\n" + changes.diff + "\n```");
81
+ if (changes.newFiles.length)
82
+ parts.push(`New files (use read_file to inspect): ${changes.newFiles.join(", ")}`);
83
+ if (!changes.diff && !changes.newFiles.length)
84
+ parts.push("(no diff captured — inspect the working tree with git / read_file)");
85
+ parts.push("\nReview these changes against the task. Finish with your VERDICT line.");
86
+ return parts.join("\n");
87
+ }
88
+ /** Feed the reviewer's requested changes back to the implementer. */
89
+ export function fixPrompt(issues) {
90
+ return `A code reviewer reviewed your changes and requires these fixes before this can ship:\n\n${issues}\n\nMake these fixes now — edit the files directly; don't just explain.`;
91
+ }
package/dist/org/roles.js CHANGED
@@ -49,6 +49,17 @@ function parseFrontmatter(text) {
49
49
  }
50
50
  return { fm, body: m[2].trim() };
51
51
  }
52
+ /** Tool filter for a fan-out sub-agent: ALWAYS read-only (sub-agents run full-auto + unconfirmed +
53
+ * parallel), with a role allowed to narrow further but never to grant write/exec. `isReadonly` is the
54
+ * read-kind predicate. This is the guard that keeps the `agent` tool from bypassing the approval gate. */
55
+ export function subagentToolFilter(role, isReadonly) {
56
+ const roleFilter = role?.allowTools
57
+ ? (n) => role.allowTools.includes(n)
58
+ : role?.denyTools
59
+ ? (n) => !role.denyTools.includes(n)
60
+ : null;
61
+ return (n) => isReadonly(n) && (roleFilter ? roleFilter(n) : true);
62
+ }
52
63
  export function loadRoles(cwd) {
53
64
  const byId = new Map();
54
65
  // lowest→highest precedence: plugins < global < .claude/agents < .hara/roles (project wins, same as memory/config)
@@ -66,6 +66,20 @@ export function pluginMcpServers() {
66
66
  Object.assign(out, p.manifest.mcpServers ?? {});
67
67
  return out;
68
68
  }
69
+ /** Lifecycle hooks contributed by enabled plugins (appended after user-config hooks). */
70
+ export function pluginHooks() {
71
+ const out = { PreToolUse: [], PostToolUse: [] };
72
+ for (const p of enabledPlugins()) {
73
+ const h = p.manifest.hooks;
74
+ if (!h || typeof h !== "object")
75
+ continue;
76
+ if (Array.isArray(h.PreToolUse))
77
+ out.PreToolUse.push(...h.PreToolUse);
78
+ if (Array.isArray(h.PostToolUse))
79
+ out.PostToolUse.push(...h.PostToolUse);
80
+ }
81
+ return out;
82
+ }
69
83
  /** Install a plugin from `file:<path>`, `github:<owner/repo>`, or `git:<url>` into ~/.hara/plugins/<name>. */
70
84
  export function installPlugin(source) {
71
85
  mkdirSync(pluginsDir(), { recursive: true });
@@ -2,6 +2,19 @@ import Anthropic from "@anthropic-ai/sdk";
2
2
  import { imageToBase64 } from "../images.js";
3
3
  export function toAnthropic(history) {
4
4
  const msgs = [];
5
+ // Append a user message, merging into the previous one if it's also `user` — Anthropic requires
6
+ // alternating roles, and tool-results map to a user message, so a mid-turn-injected user message
7
+ // (type-ahead steering) lands right after one. Merging keeps the request valid; dormant otherwise.
8
+ const pushUser = (content) => {
9
+ const last = msgs[msgs.length - 1];
10
+ if (last && last.role === "user") {
11
+ const toBlocks = (c) => typeof c === "string" ? [{ type: "text", text: c }] : c;
12
+ last.content = [...toBlocks(last.content), ...toBlocks(content)];
13
+ }
14
+ else {
15
+ msgs.push({ role: "user", content });
16
+ }
17
+ };
5
18
  for (const m of history) {
6
19
  if (m.role === "user") {
7
20
  if (m.images?.length) {
@@ -13,10 +26,10 @@ export function toAnthropic(history) {
13
26
  if (data)
14
27
  blocks.push({ type: "image", source: { type: "base64", media_type: img.mediaType, data } });
15
28
  }
16
- msgs.push({ role: "user", content: blocks.length ? blocks : m.content });
29
+ pushUser(blocks.length ? blocks : m.content);
17
30
  }
18
31
  else {
19
- msgs.push({ role: "user", content: m.content });
32
+ pushUser(m.content);
20
33
  }
21
34
  }
22
35
  else if (m.role === "assistant") {
@@ -28,15 +41,12 @@ export function toAnthropic(history) {
28
41
  msgs.push({ role: "assistant", content: content.length ? content : [{ type: "text", text: "(no output)" }] });
29
42
  }
30
43
  else {
31
- msgs.push({
32
- role: "user",
33
- content: m.results.map((r) => ({
34
- type: "tool_result",
35
- tool_use_id: r.id,
36
- content: r.content,
37
- is_error: r.isError,
38
- })),
39
- });
44
+ pushUser(m.results.map((r) => ({
45
+ type: "tool_result",
46
+ tool_use_id: r.id,
47
+ content: r.content,
48
+ is_error: r.isError,
49
+ })));
40
50
  }
41
51
  }
42
52
  return msgs;
@@ -3,7 +3,7 @@
3
3
  import { createHash, randomBytes } from "node:crypto";
4
4
  import { homedir } from "node:os";
5
5
  import { join } from "node:path";
6
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
6
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync } from "node:fs";
7
7
  const BASE = "https://chat.qwen.ai";
8
8
  const DEVICE_CODE_URL = `${BASE}/api/v1/oauth2/device/code`;
9
9
  const TOKEN_URL = `${BASE}/api/v1/oauth2/token`;
@@ -28,7 +28,14 @@ export function loadQwenToken() {
28
28
  function saveQwenToken(t) {
29
29
  const p = tokenPath();
30
30
  mkdirSync(join(homedir(), ".hara"), { recursive: true });
31
- writeFileSync(p, JSON.stringify(t, null, 2) + "\n", "utf8");
31
+ // 0600 the file holds long-lived access + refresh tokens; don't leave it world-readable on shared boxes.
32
+ writeFileSync(p, JSON.stringify(t, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
33
+ try {
34
+ chmodSync(p, 0o600); // tighten an existing file that predated the mode
35
+ }
36
+ catch {
37
+ /* best-effort */
38
+ }
32
39
  }
33
40
  const b64url = (b) => b.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
34
41
  function pkce() {
package/dist/sandbox.js CHANGED
@@ -1,6 +1,10 @@
1
- // OS sandboxing for the bash tool. macOS = Seatbelt (sandbox-exec); other platforms run unsandboxed
2
- // (the approval gate + cwd-scoped file tools still apply). Only the `bash` shell is sandboxed —
3
- // hara's own file tools (write_file/edit_file) are in-process, explicit, and gated.
1
+ // OS sandboxing for the bash tool WRITE CONFINEMENT, not a security jail. macOS = Seatbelt
2
+ // (sandbox-exec) restricting `file-write*` to the workspace (workspace-write) or to nothing outside
3
+ // temp (read-only). Reads, network, and process exec are NOT restricted, and /private/tmp +
4
+ // /private/var/folders stay writable in every mode — so this stops a stray `rm`/overwrite escaping the
5
+ // project, NOT a determined exfiltration. Other platforms run UNSANDBOXED (a one-time warning is
6
+ // emitted from runShell so every entry point — REPL, -p, org, cron — surfaces it). Only the `bash`
7
+ // shell is sandboxed; hara's own file tools are in-process, explicit, and gated by the approval flow.
4
8
  import { spawn } from "node:child_process";
5
9
  import { writeFileSync, mkdtempSync } from "node:fs";
6
10
  import { tmpdir, platform } from "node:os";
@@ -23,6 +27,7 @@ function seatbeltProfile(cwd, mode) {
23
27
  export function sandboxSupported() {
24
28
  return platform() === "darwin";
25
29
  }
30
+ let warnedUnsandboxed = false; // emit the "macOS-only" notice at most once per process
26
31
  /**
27
32
  * Run a shell command, sandboxed when mode != off and the platform supports it.
28
33
  * Streams output via `opts.onData` while capturing it for the resolved value.
@@ -39,6 +44,10 @@ export function runShell(command, cwd, mode, opts) {
39
44
  args = ["-f", profileFile, "/bin/bash", "-lc", command];
40
45
  }
41
46
  else {
47
+ if (mode !== "off" && !warnedUnsandboxed) {
48
+ warnedUnsandboxed = true;
49
+ process.stderr.write(`hara: --sandbox ${mode} is macOS-only — the shell runs UNSANDBOXED on ${platform()}.\n`);
50
+ }
42
51
  cmd = "/bin/sh";
43
52
  args = ["-c", command];
44
53
  }
@@ -47,20 +56,31 @@ export function runShell(command, cwd, mode, opts) {
47
56
  let stdout = "";
48
57
  let stderr = "";
49
58
  let timedOut = false;
59
+ let killedForSize = false;
50
60
  const grow = (cur, add) => (cur.length < opts.maxBuffer ? cur + add : cur);
51
61
  const timer = setTimeout(() => {
52
62
  timedOut = true;
53
63
  child.kill("SIGKILL");
54
64
  }, opts.timeout);
65
+ // Kill a runaway command once its total output passes maxBuffer — don't let it stream GBs to the UI
66
+ // until the timeout just because we stopped retaining the bytes.
67
+ const checkOverflow = () => {
68
+ if (!killedForSize && stdout.length + stderr.length >= opts.maxBuffer) {
69
+ killedForSize = true;
70
+ child.kill("SIGKILL");
71
+ }
72
+ };
55
73
  child.stdout.on("data", (d) => {
56
74
  const s = d.toString();
57
75
  stdout = grow(stdout, s);
58
76
  opts.onData?.(s);
77
+ checkOverflow();
59
78
  });
60
79
  child.stderr.on("data", (d) => {
61
80
  const s = d.toString();
62
81
  stderr = grow(stderr, s);
63
82
  opts.onData?.(s);
83
+ checkOverflow();
64
84
  });
65
85
  child.on("error", (e) => {
66
86
  clearTimeout(timer);
@@ -68,6 +88,8 @@ export function runShell(command, cwd, mode, opts) {
68
88
  });
69
89
  child.on("close", (code) => {
70
90
  clearTimeout(timer);
91
+ if (killedForSize)
92
+ return resolve({ stdout, stderr: stderr + `\n[output truncated — exceeded ${opts.maxBuffer} bytes; process killed]` });
71
93
  if (timedOut)
72
94
  return reject(Object.assign(new Error(`timed out after ${opts.timeout}ms`), { stdout, stderr }));
73
95
  if (code !== 0)
@@ -121,6 +121,13 @@ export async function buildIndex(name, chunks, embed, cwd, model = "embed") {
121
121
  export function indexExists(name, cwd) {
122
122
  return existsSync(indexPath(name, cwd));
123
123
  }
124
+ // Never embed (and POST to an embedding provider, then persist in the index) a secret-bearing file —
125
+ // the asset/skill/memory dirs aren't .gitignore-filtered, so a stray credentials.json/secrets.yaml/.env
126
+ // there would otherwise leak. Defense-in-depth (the repo walk already respects .gitignore).
127
+ const SECRET_FILE = /(^\.?env(\.|$)|secret|credential|password|apikey|api[_-]?key|\btoken|\.(pem|key|p12|pfx|keystore|crt)$|^\.netrc$|^\.npmrc$|id_(rsa|ed25519|ecdsa))/i;
128
+ function looksSecret(rel) {
129
+ return SECRET_FILE.test(rel.split("/").pop() ?? rel);
130
+ }
124
131
  /** Walk one knowledge directory (code-assets / skills / memory) and chunk its files. Files come back as
125
132
  * absolute paths so recall/memory_search can read or open them directly. */
126
133
  export function collectDirChunks(dir, source) {
@@ -128,7 +135,7 @@ export function collectDirChunks(dir, source) {
128
135
  return [];
129
136
  const chunks = [];
130
137
  for (const rel of walkFiles(dir)) {
131
- if (!CODE_RE.test(rel))
138
+ if (!CODE_RE.test(rel) || looksSecret(rel))
132
139
  continue;
133
140
  const abs = join(dir, rel);
134
141
  if (fileSize(abs) > 200_000)
@@ -150,7 +157,7 @@ export function collectDirChunks(dir, source) {
150
157
  export function collectRepoChunks(root) {
151
158
  const chunks = [];
152
159
  for (const rel of listProjectFiles(root)) {
153
- if (!CODE_RE.test(rel))
160
+ if (!CODE_RE.test(rel) || looksSecret(rel))
154
161
  continue;
155
162
  const abs = join(root, rel);
156
163
  if (fileSize(abs) > 200_000)
@@ -42,6 +42,8 @@ export function cleanSessionName(raw) {
42
42
  * (unlike the ASCII-slug `cleanSessionName`), trims code/whitespace, caps length. Empty for blank input
43
43
  * (callers fall back to the short id, never "new session"). */
44
44
  export function deriveTitle(text) {
45
+ if (typeof text !== "string")
46
+ return ""; // a malformed/hand-edited session may have a non-string content
45
47
  const t = text
46
48
  .replace(/^\/\S+\s*/, "") // drop a leading slash-command
47
49
  .replace(/```[\s\S]*?```/g, " ") // drop fenced code blocks
@@ -75,12 +77,18 @@ export function saveSession(meta, history) {
75
77
  const data = { meta, history };
76
78
  writeFileSync(sessionFile(meta.id), JSON.stringify(data, null, 2), "utf8");
77
79
  }
80
+ /** True if a parsed object has the SessionData shape we can safely use (meta object + history array). */
81
+ function isSessionData(d) {
82
+ const o = d;
83
+ return !!o && typeof o === "object" && !!o.meta && typeof o.meta === "object" && Array.isArray(o.history);
84
+ }
78
85
  export function loadSession(id) {
79
86
  const p = sessionFile(id);
80
87
  if (!existsSync(p))
81
88
  return null;
82
89
  try {
83
- return JSON.parse(readFileSync(p, "utf8"));
90
+ const d = JSON.parse(readFileSync(p, "utf8"));
91
+ return isSessionData(d) ? d : null; // a corrupt / hand-edited file resumes as "no session" instead of crashing
84
92
  }
85
93
  catch {
86
94
  return null;
@@ -93,7 +101,9 @@ export function listSessions(cwd) {
93
101
  if (!f.endsWith(".json"))
94
102
  continue;
95
103
  try {
96
- metas.push(JSON.parse(readFileSync(join(sessionsDir(), f), "utf8")).meta);
104
+ const d = JSON.parse(readFileSync(join(sessionsDir(), f), "utf8"));
105
+ if (d?.meta && typeof d.meta === "object" && d.meta.id && d.meta.updatedAt)
106
+ metas.push(d.meta); // skip metaless/corrupt
97
107
  }
98
108
  catch {
99
109
  /* skip corrupt */
@@ -12,15 +12,20 @@ import { registerTool } from "./registry.js";
12
12
  import { loadConfig } from "../config.js";
13
13
  const RANK = { off: 0, read: 1, click: 2, full: 3 };
14
14
  const ACTION_MIN = { screenshot: "read", find: "read", activate: "click", move: "click", click: "click", type: "full", key: "full" };
15
- // dangerous combos refused even at full tier (quit / close / delete / task-switch-kill)
15
+ // dangerous combos refused even at full tier (quit / close / delete / task-switch-kill).
16
16
  const KEY_BLOCK = /(?:\b(cmd|command|ctrl|control|alt|option|win|super|meta)\b.*\+.*\b(q|w|delete|del|f4|escape|esc)\b)|ctrl\+alt\+(?:delete|del|backspace)/i;
17
+ // Windows SendKeys spells modifiers as % (Alt) / ^ (Ctrl) with no modifier WORD, so the combo regex
18
+ // above misses them: block Alt+F4 / Ctrl+F4 (close window) and Ctrl+W (close tab) in that syntax.
19
+ const KEY_BLOCK_SENDKEYS = /[%^]\s*\{\s*f4\s*\}|\^\s*w\b/i;
20
+ // Linux/X keysyms for logout / power-off (xdotool key XF86LogOff …) — not modifier combos.
21
+ const KEY_BLOCK_KEYSYM = /\bxf86(logoff|poweroff|reboot|sleep)\b/i;
17
22
  /** Whether the configured tier permits the action. Exported for tests. */
18
23
  export function actionAllowed(tier, action) {
19
24
  return RANK[tier] >= RANK[ACTION_MIN[action] ?? "full"];
20
25
  }
21
26
  /** Whether a key combo is on the dangerous blocklist. Exported for tests. */
22
27
  export function keyIsBlocked(keys) {
23
- return KEY_BLOCK.test(keys);
28
+ return KEY_BLOCK.test(keys) || KEY_BLOCK_SENDKEYS.test(keys) || KEY_BLOCK_KEYSYM.test(keys);
24
29
  }
25
30
  // Circuit breaker (learned from codex): bound consecutive screen-control failures so the agent can't loop
26
31
  // forever on a broken setup. Reset on any success; after FAIL_LIMIT in a row, return a clear stop + how to fix.
@@ -315,7 +320,7 @@ registerTool({
315
320
  const app = String(input.app ?? input.target ?? "");
316
321
  if (!app)
317
322
  return "activate needs an `app` name (e.g. 'WeChat').";
318
- if (!cfg.computerApps.some((a) => app.toLowerCase().includes(a.toLowerCase()) || a.toLowerCase().includes(app.toLowerCase())))
323
+ if (!cfg.computerApps.some((a) => a.toLowerCase() === app.toLowerCase()))
319
324
  return `Refused: "${app}" isn't in your allowlist (${cfg.computerApps.join(", ") || "empty"}). Add it: \`hara config set computerApps "${app}"\`.`;
320
325
  const r = activateApp(app);
321
326
  return r.ok ? ok(`✓ ${r.msg} — now screenshot/find/click to act on it`) : fail(r.msg);
@@ -325,7 +330,7 @@ registerTool({
325
330
  if (!cfg.computerApps.length)
326
331
  return "No apps allowlisted — set `hara config set computerApps \"App Name, …\"` before clicking/typing.";
327
332
  const app = frontmostApp();
328
- const allowed = cfg.computerApps.some((a) => app.toLowerCase().includes(a.toLowerCase()) || a.toLowerCase().includes(app.toLowerCase()));
333
+ const allowed = cfg.computerApps.some((a) => a.toLowerCase() === app.toLowerCase());
329
334
  if (!allowed)
330
335
  return `Refused: frontmost app "${app || "unknown"}" isn't in your allowlist (${cfg.computerApps.join(", ")}). Switch to an allowed app or update computerApps.`;
331
336
  }
@@ -102,21 +102,40 @@ registerTool({
102
102
  }
103
103
  }
104
104
  }
105
- // PHASE 2 — commit all changes + show each diff.
106
- const summary = [];
107
- for (const pl of plans) {
108
- if (pl.type === "delete") {
109
- await unlink(pl.abs);
110
- emitDiff(pl.path, pl.before, "", ctx.ui);
111
- summary.push(`deleted ${pl.path}`);
105
+ // PHASE 2 — commit all changes. Truly all-or-nothing: if any write fails mid-way, roll back the ones
106
+ // already applied (restore updated/deleted files, remove created ones) so the tree is never half-patched.
107
+ const applied = [];
108
+ try {
109
+ for (const pl of plans) {
110
+ if (pl.type === "delete") {
111
+ await unlink(pl.abs);
112
+ }
113
+ else {
114
+ await mkdir(dirname(pl.abs), { recursive: true });
115
+ await writeFile(pl.abs, pl.after, "utf8");
116
+ }
117
+ applied.push(pl);
112
118
  }
113
- else {
114
- await mkdir(dirname(pl.abs), { recursive: true });
115
- await writeFile(pl.abs, pl.after, "utf8");
116
- emitDiff(pl.path, pl.before, pl.after, ctx.ui);
117
- summary.push(`${pl.type === "create" ? "created" : "updated"} ${pl.path}`);
119
+ }
120
+ catch (e) {
121
+ for (const pl of applied.reverse()) {
122
+ try {
123
+ if (pl.type === "create" && !pl.existed)
124
+ await unlink(pl.abs); // remove a file we created
125
+ else
126
+ await writeFile(pl.abs, pl.before, "utf8"); // restore an updated/deleted file's prior content
127
+ }
128
+ catch {
129
+ /* best-effort rollback */
130
+ }
118
131
  }
132
+ return `Error: apply_patch failed writing a file (${e instanceof Error ? e.message : String(e)}) — rolled back, nothing left changed.`;
119
133
  }
134
+ // All writes succeeded → now show diffs + record the undo snapshot.
135
+ const summary = plans.map((pl) => {
136
+ emitDiff(pl.path, pl.before, pl.type === "delete" ? "" : pl.after, ctx.ui);
137
+ return pl.type === "delete" ? `deleted ${pl.path}` : `${pl.type === "create" ? "created" : "updated"} ${pl.path}`;
138
+ });
120
139
  recordEdit(plans.map((pl) => ({ path: pl.path, absPath: pl.abs, before: pl.existed ? pl.before : null })));
121
140
  return `apply_patch: ${plans.length} file(s) — ${summary.join("; ")}.`;
122
141
  },
@@ -0,0 +1,51 @@
1
+ // todo_write — an inline task checklist the agent maintains during a turn (like codex's update_plan /
2
+ // Claude Code's TodoWrite). Keeps the model organized on multi-step work and shows the user live progress.
3
+ // In-memory, replace-whole-list semantics; kind:"read" so it never prompts and is safe to call freely.
4
+ import { registerTool } from "./registry.js";
5
+ let todos = [];
6
+ /** The current checklist (latest todo_write wins) — for a TUI/statusline to render. */
7
+ export function currentTodos() {
8
+ return todos;
9
+ }
10
+ const MARK = { pending: "☐", in_progress: "▶", done: "☑" };
11
+ export function renderTodos(list) {
12
+ if (!list.length)
13
+ return "(todo list cleared)";
14
+ const done = list.filter((t) => t.status === "done").length;
15
+ return `Todos (${done}/${list.length} done):\n` + list.map((t) => ` ${MARK[t.status]} ${t.text}`).join("\n");
16
+ }
17
+ registerTool({
18
+ name: "todo_write",
19
+ description: "Maintain a short task checklist for the CURRENT work. Use it to plan a multi-step task up front, then " +
20
+ "update it as you go: keep exactly one item 'in_progress', flip items to 'done' as you finish, add items " +
21
+ "you discover. Pass the FULL list each call (it replaces the previous). Skip it for trivial one-step tasks.",
22
+ input_schema: {
23
+ type: "object",
24
+ properties: {
25
+ todos: {
26
+ type: "array",
27
+ description: "the full checklist, in order",
28
+ items: {
29
+ type: "object",
30
+ properties: {
31
+ text: { type: "string", description: "the task, a short imperative phrase" },
32
+ status: { type: "string", enum: ["pending", "in_progress", "done"] },
33
+ },
34
+ required: ["text", "status"],
35
+ },
36
+ },
37
+ },
38
+ required: ["todos"],
39
+ },
40
+ kind: "read", // pure state + display: never prompts, parallel-safe
41
+ async run(input) {
42
+ const raw = Array.isArray(input.todos) ? input.todos : [];
43
+ todos = raw
44
+ .map((t) => ({
45
+ text: String(t?.text ?? "").trim(),
46
+ status: (["pending", "in_progress", "done"].includes(t?.status) ? t.status : "pending"),
47
+ }))
48
+ .filter((t) => t.text);
49
+ return renderTodos(todos);
50
+ },
51
+ });