@nanhara/hara 0.48.0 → 0.53.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,70 @@ All notable changes to `@nanhara/hara`.
5
5
  > Versioning (pre-1.0, SemVer-style): the **minor** (middle) number bumps for a **new feature**; the
6
6
  > **patch** (last) number bumps for **optimizations/fixes of existing features**.
7
7
 
8
+ ## 0.53.0 — unreleased (task-done notifications + steering in plan mode)
9
+
10
+ - **Notifications** — get pinged when a turn finishes so you can walk away during a long run
11
+ (codex/Claude-Code parity). `hara config set notify bell` rings the terminal BEL; `notify system` fires
12
+ an OS notification (macOS `osascript` / Linux `notify-send`) plus the bell; default `off`. Gated on
13
+ elapsed time (≥8s) so quick turns you were watching stay silent. Wired into the TUI turn, plan-mode
14
+ execute, and the plain REPL; `hara doctor` shows the setting. New `src/notify.ts` (`notifyDone`).
15
+ - **Type-ahead steering now covers plan mode too.** v0.52 wired steering into the regular turn only;
16
+ the `pendingInput` builder is now hoisted so plan-mode *investigation* and *execution* also fold in
17
+ messages you type mid-turn (previously they fell back to the old wait-for-turn-end behavior — an
18
+ inconsistency). All three turn paths now steer.
19
+
20
+ ## 0.52.0 — unreleased (type-ahead steering — mid-turn messages course-correct the live task)
21
+
22
+ - **Type-ahead now *steers* the running turn** instead of waiting for it to finish. Previously a message
23
+ typed while hara worked was held and replayed as a brand-new turn once the turn ended — so a
24
+ supplement ("also handle the error case", "use TS not JS") arrived *after* the task had already
25
+ finished on the old understanding, becoming rework. Now, studying how **codex** does it (its
26
+ `pending_input` drains at the next model-call boundary *inside* the same turn) vs **cc-haha/Claude
27
+ Code** (waits for full completion), hara adopts the codex model: queued messages are **folded into the
28
+ next model call** (drained after each tool round), so the model course-corrects mid-task. Each shows
29
+ inline in the transcript at the point it's folded in. Messages typed during the *final* step (no more
30
+ tool rounds) still start a fresh turn; **Esc** drops the queue and stops.
31
+ - New `RunOpts.pendingInput` (the loop drains it before each model call; unused outside the TUI = zero
32
+ change for `-p`/sub-agents/plain REPL). The TUI hands the queue through `Helpers.drainQueue`.
33
+ - **`toAnthropic` now coalesces consecutive `user` messages** — required since a steered message lands
34
+ right after tool-results (which map to a `user` message) and Anthropic rejects two `user` turns in a
35
+ row. Dormant in normal alternating histories. Unit-tested.
36
+
37
+ ## 0.51.0 — unreleased (lifecycle hooks — PreToolUse / PostToolUse)
38
+
39
+ - **Hooks dispatch** — run your own shell commands around every tool call (codex / Claude-Code parity, which
40
+ hara lacked). A **`PreToolUse`** hook runs *before* a tool and can **veto** it (non-zero exit blocks the
41
+ call; its stdout/stderr becomes the denial the model sees) — e.g. forbid `bash rm -rf`, gate edits to a
42
+ path, require a clean tree. A **`PostToolUse`** hook runs *after* (observe-only) — e.g. `prettier` a file
43
+ the agent just wrote, log/notify. The command gets `{tool, payload}` as JSON on stdin + `HARA_TOOL_NAME`
44
+ in its env; each is matched by a `matcher` (regex/literal on the tool name, `*`/omitted = all) with a 30s
45
+ timeout. Configure in `config.json` `"hooks"`; **plugins can contribute hooks** too. `hara doctor` shows
46
+ the active count. No hooks configured = zero overhead (fast no-op).
47
+
48
+ ```jsonc
49
+ // ~/.hara/config.json
50
+ "hooks": {
51
+ "PreToolUse": [{ "matcher": "bash", "command": "grep -q 'rm -rf' && { echo 'no rm -rf'; exit 1; } || exit 0" }],
52
+ "PostToolUse": [{ "matcher": "edit_file|write_file", "command": "prettier --write \"$(jq -r .payload.input.path)\" 2>/dev/null; exit 0" }]
53
+ }
54
+ ```
55
+
56
+ ## 0.50.0 — unreleased (web_search — find pages, not just fetch)
57
+
58
+ - New **`web_search`** tool — search the web (title/URL/snippet), then `web_fetch` a result to read it. Closes
59
+ the other codex/cc-haha gap (hara could previously only fetch a *known* URL). **Reliable with a Tavily key**
60
+ (`HARA_SEARCH_API_KEY` / `TAVILY_API_KEY`, free tier); a **keyless DuckDuckGo** fallback works best-effort
61
+ (POST endpoint; may rate-limit). Read-kind, available to sub-agents. Verified live (keyless: "anthropic
62
+ claude" → real results); parser unit-tested (incl. the DDG `uddg` redirect decode).
63
+
64
+ ## 0.49.0 — unreleased (inline todo tool — `todo_write`)
65
+
66
+ - New **`todo_write`** tool — the agent maintains a live task checklist during multi-step work (codex's
67
+ `update_plan` / Claude Code's `TodoWrite`, which hara lacked). Plan up front, keep one item `in_progress`,
68
+ flip to `done` as you go; pass the full list each call. Read-kind (never prompts); the system prompt nudges
69
+ its use for multi-step tasks; sub-agents can use it too. Renders a `☐/▶/☑` checklist with a done count.
70
+ *(Gap analysis vs codex + cc-haha: this was the top missing capability.)*
71
+
8
72
  ## 0.48.0 — unreleased (chrome plugin: drive your real logged-in Chrome)
9
73
 
10
74
  - New first-party **`chrome` plugin** — web automation via **`chrome-devtools-mcp`** against a **real Chrome with
package/README.md CHANGED
@@ -179,6 +179,8 @@ by the tier, the frontmost-app allowlist, a dangerous-key blocklist, and a once-
179
179
  vision model into **actionable** output — interactive elements + positions (pass `focus` to target what you're after) — so even a text-only main model can click.
180
180
  **Sessions**: conversations are saved automatically — `-c` / `--resume <id>` to continue, `hara sessions` to list.
181
181
  **MCP**: add an `mcpServers` map to config (global or project `.hara/config.json`); their tools appear to the agent as `mcp__<server>__<tool>`.
182
+ **Notifications**: `hara config set notify bell` (terminal bell) or `notify system` (OS notification) pings you when a turn finishes — handy for long runs you've stepped away from. Gated on elapsed time so quick turns stay quiet; off by default.
183
+ **Hooks**: run your own shell commands around tool calls via a `"hooks"` map in config. A **`PreToolUse`** hook can **veto** a call (non-zero exit blocks it; its output becomes the reason the model sees) — gate `bash`, forbid edits outside a path, require a clean tree. A **`PostToolUse`** hook observes (format/lint a file the agent just wrote, log, notify). Each has a `matcher` (regex/literal on the tool name, `*` = all) and gets `{tool, payload}` on stdin + `HARA_TOOL_NAME` in env. Plugins can contribute hooks too.
182
184
  **Profiles**: add a `profiles` map to `~/.hara/config.json` (`--profile <name>`), or drop a project-level `.hara/config.json` that overrides the global config.
183
185
 
184
186
  ### The org — what makes hara different
@@ -208,6 +210,7 @@ read-only **`grep`** / **`glob`** / **`ls`** / **`web_fetch`** — behind a huma
208
210
  dangerous ones unless `-y`. Read-only tools run in parallel within a turn, and edits print a
209
211
  **colored diff** of what changed. Shell output streams live; press **Esc** to interrupt a running
210
212
  turn, or **`/undo`** to revert the last edit.
213
+ - **Type-ahead steering**: keep typing while hara works — your message is held, then **folded into the next model call** (not deferred to a new turn), so a clarification or "also do X" course-corrects the task already in flight (codex-style). Messages typed after the final step start a fresh turn; **Esc** drops the queue and stops.
211
214
  - **Project context**: auto-loads `AGENTS.md` (the cross-tool standard) walking up to the repo root; `hara init` writes one by analyzing the repo.
212
215
  - **`@file` mentions**: attach file contents to a message (`@path`); Tab-completes with a **fuzzy** matcher over the project (subdirs, git-tracked + untracked) — `@idx` → `src/index.ts`. `@<dir>` loads a directory listing, `@src/`+Tab drills into a folder, and mistyped tool/file paths get a "did you mean" suggestion.
213
216
  - **Multi-provider**: Anthropic (Claude) or any OpenAI-compatible endpoint (Qwen/DashScope, GLM, Kimi, OpenAI) — **all streamed live**.
@@ -4,6 +4,7 @@ import { c, out } from "../ui.js";
4
4
  import { activity } from "../activity.js";
5
5
  import { makeRenderer } from "../md.js";
6
6
  import { skillsDigest } from "../skills/skills.js";
7
+ import { runHooks } from "../hooks.js";
7
8
  /** Whether a tool call needs user confirmation under the given approval mode. */
8
9
  export function needsConfirm(kind, mode) {
9
10
  if (kind === "read")
@@ -20,7 +21,9 @@ const HARA_SYSTEM = (cwd) => `You are hara, a coding agent running in the user's
20
21
  Working directory: ${cwd}
21
22
  Be concise and direct. Use the provided tools to read files, edit/write files, and run shell
22
23
  commands. Prefer small, verifiable steps; edit existing files with edit_file rather than rewriting
23
- them whole. You have a persistent memory: use memory_search before answering about prior decisions,
24
+ them whole. For a multi-step task, call \`todo_write\` to plan a short checklist and keep it updated as
25
+ you go (one item in_progress at a time) — skip it for trivial one-step tasks. You have a persistent
26
+ memory: use memory_search before answering about prior decisions,
24
27
  conventions, or the user's preferences, and memory_write to proactively save durable facts you learn.
25
28
  When a task matches one of the Skills listed below, call the \`skill\` tool to load its full instructions
26
29
  before acting; save a reusable how-to as a new skill with skill_create. If you discover a durable project
@@ -38,6 +41,12 @@ function composeSystem(cwd, projectContext, override, memory) {
38
41
  export async function runAgent(history, opts) {
39
42
  const { provider, ctx } = opts;
40
43
  for (;;) {
44
+ // Type-ahead steering: fold in anything the user submitted while the previous step ran, so it
45
+ // reaches the model on this next call (drained after the last tool round; empty on the 1st pass).
46
+ if (opts.pendingInput) {
47
+ for (const m of await opts.pendingInput())
48
+ history.push(m);
49
+ }
41
50
  const specs = opts.toolFilter ? toolSpecs().filter((t) => opts.toolFilter(t.name)) : toolSpecs();
42
51
  const sink = ctx.ui; // TUI mode: route output to ink instead of stdout
43
52
  const tty = stdout.isTTY && !opts.quiet && !sink;
@@ -154,8 +163,14 @@ export async function runAgent(history, opts) {
154
163
  }
155
164
  activity.inc();
156
165
  try {
166
+ const pre = runHooks("PreToolUse", p.tu.name, p.tu.input, ctx.cwd); // a hook may veto the call
167
+ if (pre.block) {
168
+ results[idx] = { id: p.tu.id, name: p.tu.name, content: pre.message, isError: true };
169
+ return;
170
+ }
157
171
  const res = await p.tool.run(p.tu.input, ctx);
158
172
  results[idx] = { id: p.tu.id, name: p.tu.name, content: res };
173
+ runHooks("PostToolUse", p.tu.name, { input: p.tu.input, result: res }, ctx.cwd); // observe-only
159
174
  }
160
175
  catch (e) {
161
176
  results[idx] = { id: p.tu.id, name: p.tu.name, content: `Error: ${e.message}`, isError: true };
package/dist/config.js CHANGED
@@ -11,7 +11,7 @@ const PROVIDER_DEFAULTS = {
11
11
  "qwen-oauth": { model: "coder-model", envKey: "QWEN_OAUTH_TOKEN" },
12
12
  openai: { model: "gpt-4o-mini", envKey: "OPENAI_API_KEY" },
13
13
  };
14
- export const CONFIG_KEYS = ["provider", "apiKey", "model", "baseURL", "approval", "sandbox", "theme", "evolve", "assetCapture", "computerUse", "computerApps", "visionModel", "visionBaseURL", "visionApiKey", "embedProvider", "embedModel", "embedBaseURL", "embedApiKey"];
14
+ export const CONFIG_KEYS = ["provider", "apiKey", "model", "baseURL", "approval", "sandbox", "theme", "evolve", "assetCapture", "computerUse", "computerApps", "visionModel", "visionBaseURL", "visionApiKey", "embedProvider", "embedModel", "embedBaseURL", "embedApiKey", "notify"];
15
15
  export const APPROVAL_MODES = ["suggest", "auto-edit", "full-auto"];
16
16
  export const SANDBOX_MODES = ["off", "workspace-write", "read-only"];
17
17
  const PROJECT_ROOT_MARKERS = [".git", "package.json", "Cargo.toml", "go.mod", "pyproject.toml", ".hg"];
@@ -107,7 +107,9 @@ export function loadConfig(opts = {}) {
107
107
  ...(project.mcpServers ?? {}),
108
108
  ...(profile.mcpServers ?? {}),
109
109
  };
110
- return { provider, apiKey, model, baseURL, approval, sandbox, theme, evolve, assetCapture, computerUse, computerApps, visionModel, visionBaseURL, visionApiKey, modelVision, embedProvider, embedModel, embedBaseURL, embedApiKey, mcpServers, cwd: process.cwd() };
110
+ const hooks = (merged.hooks && typeof merged.hooks === "object" ? merged.hooks : {});
111
+ const notify = (process.env.HARA_NOTIFY ?? merged.notify ?? "off");
112
+ return { provider, apiKey, model, baseURL, approval, sandbox, theme, evolve, assetCapture, computerUse, computerApps, visionModel, visionBaseURL, visionApiKey, modelVision, embedProvider, embedModel, embedBaseURL, embedApiKey, hooks, notify, mcpServers, cwd: process.cwd() };
111
113
  }
112
114
  export function providerEnvKey(provider) {
113
115
  return (PROVIDER_DEFAULTS[provider] ?? PROVIDER_DEFAULTS.anthropic).envKey;
package/dist/hooks.js ADDED
@@ -0,0 +1,64 @@
1
+ // Lifecycle hooks — run user/plugin shell commands around tool calls (codex/Claude-Code parity).
2
+ // PreToolUse runs BEFORE a tool: a non-zero exit BLOCKS the call (its output becomes the denial message).
3
+ // PostToolUse runs AFTER: observe-only (format, log, notify). Configured in config.json `hooks` + contributed
4
+ // by plugins. The command receives {tool, payload} as JSON on stdin + HARA_TOOL_NAME in the env.
5
+ import { spawnSync } from "node:child_process";
6
+ import { loadConfig } from "./config.js";
7
+ import { pluginHooks } from "./plugins/plugins.js";
8
+ let cache = null;
9
+ export function resetHooksCache() {
10
+ cache = null;
11
+ }
12
+ function merged() {
13
+ if (cache)
14
+ return cache;
15
+ const cfg = loadConfig().hooks ?? {};
16
+ const plg = pluginHooks();
17
+ cache = {
18
+ PreToolUse: [...(cfg.PreToolUse ?? []), ...(plg.PreToolUse ?? [])],
19
+ PostToolUse: [...(cfg.PostToolUse ?? []), ...(plg.PostToolUse ?? [])],
20
+ };
21
+ return cache;
22
+ }
23
+ const matches = (m, name) => {
24
+ if (!m || m === "*")
25
+ return true;
26
+ try {
27
+ return new RegExp(m).test(name);
28
+ }
29
+ catch {
30
+ return m === name;
31
+ }
32
+ };
33
+ /** True if any hook is configured (lets the loop skip the work entirely in the common case). */
34
+ export function hasHooks() {
35
+ const h = merged();
36
+ return !!(h.PreToolUse?.length || h.PostToolUse?.length);
37
+ }
38
+ /** Run hooks for an event matching `toolName`. PreToolUse: a non-zero exit BLOCKS (returns the message);
39
+ * PostToolUse: observe-only, never blocks. Sync (hooks are short, opt-in); 30s timeout each. */
40
+ export function runHooks(event, toolName, payload, cwd) {
41
+ for (const h of merged()[event] ?? []) {
42
+ if (!matches(h.matcher, toolName))
43
+ continue;
44
+ let r;
45
+ try {
46
+ r = spawnSync(h.command, {
47
+ shell: true,
48
+ cwd,
49
+ input: JSON.stringify({ tool: toolName, payload }),
50
+ encoding: "utf8",
51
+ timeout: 30_000,
52
+ env: { ...process.env, HARA_TOOL_NAME: toolName },
53
+ });
54
+ }
55
+ catch {
56
+ continue;
57
+ }
58
+ if (event === "PreToolUse" && r.status !== 0 && r.status !== null) {
59
+ const msg = (String(r.stdout ?? "") + String(r.stderr ?? "")).trim();
60
+ return { block: true, message: `⛔ blocked by a PreToolUse hook${msg ? `: ${msg}` : ` (exit ${r.status})`}` };
61
+ }
62
+ }
63
+ return { block: false, message: "" };
64
+ }
package/dist/index.js CHANGED
@@ -15,6 +15,7 @@ import { fileURLToPath } from "node:url";
15
15
  import { dirname, join } from "node:path";
16
16
  import { loadConfig, configPath, readRawConfig, writeConfigValue, setModelVisionOverride, providerEnvKey, CONFIG_KEYS, APPROVAL_MODES, SANDBOX_MODES, } from "./config.js";
17
17
  import { runAgent } from "./agent/loop.js";
18
+ import { notifyDone } from "./notify.js";
18
19
  import { getTools } from "./tools/registry.js";
19
20
  import { createAnthropicProvider } from "./providers/anthropic.js";
20
21
  import { createOpenAIProvider } from "./providers/openai.js";
@@ -27,7 +28,7 @@ import { expandMentions, fileCandidates } from "./context/mentions.js";
27
28
  import { newSessionId, shortId, resolveSessionId, saveSession, loadSession, listSessions, latestForCwd, titleFrom, slugify, } from "./session/store.js";
28
29
  import { loadRoles, scaffoldRoles } from "./org/roles.js";
29
30
  import { loadSkillIndex, loadSkillBody, scaffoldSkills, globalSkillsDir } from "./skills/skills.js";
30
- import { installPlugin, uninstallPlugin, listInstalled, enabledPlugins, setPluginEnabled, pluginMcpServers } from "./plugins/plugins.js";
31
+ import { installPlugin, uninstallPlugin, listInstalled, enabledPlugins, setPluginEnabled, pluginMcpServers, pluginHooks } from "./plugins/plugins.js";
31
32
  import { routeByKeywords, buildDispatchPrompt, parseRoleId } from "./org/router.js";
32
33
  import { decompose, topoOrder, topoWaves, savePlan, loadPlan, atomPrompt, verify, runCheck } from "./org/planner.js";
33
34
  import { connectMcpServers, closeMcp } from "./mcp/client.js";
@@ -46,6 +47,7 @@ import "./tools/agent.js"; // register agent (subagent spawn)
46
47
  import "./tools/memory.js"; // register memory_search/get/write/forget/skill_create
47
48
  import "./tools/skill.js"; // register the skill loader tool
48
49
  import "./tools/codebase.js"; // register codebase_search (repo as a knowledge base)
50
+ import "./tools/todo.js"; // register todo_write (inline task checklist)
49
51
  import { computerBackends } from "./tools/computer.js"; // register the computer tool + expose the backend probe
50
52
  const here = dirname(fileURLToPath(import.meta.url));
51
53
  const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8"));
@@ -271,7 +273,7 @@ async function runResume(o) {
271
273
  savePlan(o.cwd, plan);
272
274
  await executePlan(plan, roles, o);
273
275
  }
274
- const READONLY_TOOLS = new Set(["read_file", "grep", "glob", "ls", "web_fetch", "codebase_search"]);
276
+ const READONLY_TOOLS = new Set(["read_file", "grep", "glob", "ls", "web_fetch", "web_search", "codebase_search", "todo_write"]);
275
277
  const REVIEW_SYSTEM = "You are a senior code reviewer. Review the git diff the user provides for: correctness bugs, security " +
276
278
  "issues, missing error handling, unclear naming, and missing/weak tests. You may read files (read-only) " +
277
279
  "for context. Be concise and specific — cite file:line and the concrete fix. Group findings by severity: " +
@@ -376,6 +378,8 @@ function runDoctor(cfg) {
376
378
  `${dot} screen ${cfg.computerUse === "off" ? c.dim("off (hara config set computerUse read|click|full)") : c.bold(cfg.computerUse) + c.dim(` · ${computerBackends()}${cfg.computerApps.length ? " · apps: " + cfg.computerApps.join(", ") : " · no app allowlist"}`)}`,
377
379
  `${dot} plugins ${(() => { const inst = listInstalled(); const on = enabledPlugins().length; return inst.length ? c.dim(`${on}/${inst.length} enabled: ${inst.map((p) => p.name).slice(0, 6).join(", ")}`) : c.dim("none — hara plugin add <source>"); })()}`,
378
380
  `${dot} mcp servers ${c.dim(String(Object.keys({ ...pluginMcpServers(), ...cfg.mcpServers }).length))}`,
381
+ `${dot} hooks ${(() => { const ph = pluginHooks(); const pre = (cfg.hooks.PreToolUse ?? []).length + (ph.PreToolUse ?? []).length; const post = (cfg.hooks.PostToolUse ?? []).length + (ph.PostToolUse ?? []).length; return pre + post ? c.dim(`${pre} pre · ${post} post`) : c.dim("none — config.json \"hooks\""); })()}`,
382
+ `${dot} notify ${cfg.notify === "off" ? c.dim("off — hara config set notify bell|system") : c.bold(cfg.notify)}`,
379
383
  ];
380
384
  return lines.join("\n");
381
385
  }
@@ -1423,6 +1427,22 @@ program.action(async (opts) => {
1423
1427
  }
1424
1428
  const ui = { text: h.sink.assistantDelta, reasoning: h.sink.reasoningDelta, tool: h.sink.tool, diff: h.sink.diff, notice: h.sink.notice };
1425
1429
  const appr = h.approval;
1430
+ // Type-ahead steering: fold messages typed mid-turn into the next model call (codex-style) so a
1431
+ // clarification/addition course-corrects the live task, rather than waiting for a fresh turn.
1432
+ // Shared by every turn below (plan investigate, plan execute, and the regular turn).
1433
+ const pendingInput = async () => {
1434
+ const out = [];
1435
+ for (const it of h.drainQueue()) {
1436
+ const r2 = await resolveImages(it.images, h);
1437
+ const body = expandMentions(it.line, cwd) + (r2.skip ? "" : (r2.extraText ?? ""));
1438
+ const attach = !r2.skip && r2.attach?.length ? r2.attach : undefined;
1439
+ if (!body.trim() && !attach)
1440
+ continue; // image-only message whose image was skipped → nothing to add
1441
+ out.push({ role: "user", content: `[I sent this while you were working on the above]\n\n${body}`, ...(attach ? { images: attach } : {}) });
1442
+ }
1443
+ return out;
1444
+ };
1445
+ const turnStart = Date.now(); // for the task-done notification (gated on elapsed)
1426
1446
  if (appr === "plan") {
1427
1447
  // PLAN MODE: read-only investigate → propose a plan → selectable proceed → execute.
1428
1448
  const planImg = await resolveImages(images, h);
@@ -1443,6 +1463,7 @@ program.action(async (opts) => {
1443
1463
  projectContext,
1444
1464
  stats,
1445
1465
  signal: h.signal,
1466
+ pendingInput,
1446
1467
  });
1447
1468
  if (!meta.title) {
1448
1469
  meta.title = await nameSession(provider, history);
@@ -1470,10 +1491,12 @@ program.action(async (opts) => {
1470
1491
  projectContext,
1471
1492
  stats,
1472
1493
  signal: h.signal,
1494
+ pendingInput,
1473
1495
  });
1474
1496
  h.sink.usage(stats.input - xin, stats.output - xout);
1475
1497
  saveSession(meta, history);
1476
1498
  }
1499
+ notifyDone(cfg.notify, { message: meta.title || "plan turn complete", elapsedMs: Date.now() - turnStart });
1477
1500
  return;
1478
1501
  }
1479
1502
  const ri = await resolveImages(images, h);
@@ -1494,12 +1517,14 @@ program.action(async (opts) => {
1494
1517
  projectContext,
1495
1518
  stats,
1496
1519
  signal: h.signal,
1520
+ pendingInput,
1497
1521
  });
1498
1522
  if (!meta.title) {
1499
1523
  meta.title = await nameSession(provider, history);
1500
1524
  h.sink.session(meta.title);
1501
1525
  }
1502
1526
  h.sink.usage(stats.input - beforeIn, stats.output - beforeOut);
1527
+ notifyDone(cfg.notify, { message: meta.title || "turn complete", elapsedMs: Date.now() - turnStart });
1503
1528
  saveSession(meta, history);
1504
1529
  },
1505
1530
  });
@@ -1546,6 +1571,7 @@ program.action(async (opts) => {
1546
1571
  recalledContext = "";
1547
1572
  history.push({ role: "user", content: userContent });
1548
1573
  currentTurn = new AbortController();
1574
+ const t0 = Date.now();
1549
1575
  try {
1550
1576
  await runAgent(history, { provider, ctx: { cwd, sandbox, spawn }, approval, confirm, autoApprove, projectContext, memory: buildMemory(), stats, signal: currentTurn.signal });
1551
1577
  }
@@ -1555,6 +1581,7 @@ program.action(async (opts) => {
1555
1581
  finally {
1556
1582
  currentTurn = null;
1557
1583
  }
1584
+ notifyDone(cfg.notify, { message: meta.title || "turn complete", elapsedMs: Date.now() - t0 });
1558
1585
  if (!meta.title)
1559
1586
  meta.title = await nameSession(provider, history);
1560
1587
  if (bar.isActive()) {
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
+ }
@@ -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;
@@ -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
+ });
package/dist/tools/web.js CHANGED
@@ -24,6 +24,103 @@ export function htmlToText(html) {
24
24
  .replace(/\n{3,}/g, "\n\n")
25
25
  .trim();
26
26
  }
27
+ /** Parse DuckDuckGo HTML results → [{title, url, snippet}]. Best-effort HTML scrape (no key, no dependency). */
28
+ export function parseSearchResults(html, limit) {
29
+ const strip = (s) => s
30
+ .replace(/<[^>]+>/g, "")
31
+ .replace(/&amp;/g, "&")
32
+ .replace(/&lt;/g, "<")
33
+ .replace(/&gt;/g, ">")
34
+ .replace(/&quot;/g, '"')
35
+ .replace(/&#x27;|&#39;/g, "'")
36
+ .replace(/\s+/g, " ")
37
+ .trim();
38
+ const snippets = [];
39
+ const snipRe = /class="result__snippet"[^>]*>([\s\S]*?)<\/a>/g;
40
+ let m;
41
+ while ((m = snipRe.exec(html)))
42
+ snippets.push(strip(m[1]));
43
+ const out = [];
44
+ const linkRe = /class="result__a"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/g;
45
+ let i = 0;
46
+ while ((m = linkRe.exec(html)) && out.length < limit) {
47
+ let href = m[1].replace(/&amp;/g, "&");
48
+ const uddg = /[?&]uddg=([^&]+)/.exec(href); // DuckDuckGo wraps results in a /l/?uddg=<real-url> redirect
49
+ if (uddg)
50
+ href = decodeURIComponent(uddg[1]);
51
+ else if (href.startsWith("//"))
52
+ href = "https:" + href;
53
+ out.push({ title: strip(m[2]), url: href, snippet: snippets[i++] ?? "" });
54
+ }
55
+ return out;
56
+ }
57
+ registerTool({
58
+ name: "web_search",
59
+ description: "Search the web and return the top results (title, URL, snippet). Use it to FIND information or pages you " +
60
+ "don't already have a URL for, then `web_fetch` a result to read it. Read-only. Reliable with a Tavily key " +
61
+ "(env HARA_SEARCH_API_KEY); otherwise a best-effort keyless fallback that may be rate-limited.",
62
+ input_schema: {
63
+ type: "object",
64
+ properties: {
65
+ query: { type: "string" },
66
+ limit: { type: "number", description: "max results (default 6, max 10)" },
67
+ },
68
+ required: ["query"],
69
+ },
70
+ kind: "read",
71
+ async run(input) {
72
+ const q = String(input.query ?? "").trim();
73
+ if (!q)
74
+ return "(empty query)";
75
+ const limit = Math.min(Math.max(1, Number(input.limit) || 6), 10);
76
+ const fmt = (rs) => rs.map((r, n) => `${n + 1}. ${r.title}\n ${r.url}${r.snippet ? `\n ${r.snippet}` : ""}`).join("\n\n");
77
+ const ctrl = new AbortController();
78
+ const timer = setTimeout(() => ctrl.abort(), 20_000);
79
+ try {
80
+ // Reliable path: Tavily (designed for agents, free tier) when a key is configured.
81
+ const key = process.env.HARA_SEARCH_API_KEY || process.env.TAVILY_API_KEY;
82
+ if (key) {
83
+ const res = await fetch("https://api.tavily.com/search", {
84
+ method: "POST",
85
+ signal: ctrl.signal,
86
+ headers: { "content-type": "application/json" },
87
+ body: JSON.stringify({ api_key: key, query: q, max_results: limit }),
88
+ });
89
+ if (res.ok) {
90
+ const j = (await res.json());
91
+ const rs = (j.results ?? []).map((x) => ({ title: String(x.title ?? x.url ?? ""), url: String(x.url ?? ""), snippet: String(x.content ?? "").slice(0, 200) }));
92
+ if (rs.length)
93
+ return fmt(rs);
94
+ }
95
+ // Tavily failed → fall through to the keyless best-effort path.
96
+ }
97
+ // Keyless fallback: DuckDuckGo HTML (POST — GET returns a 202 challenge). May be rate-limited.
98
+ const res = await fetch("https://html.duckduckgo.com/html/", {
99
+ method: "POST",
100
+ signal: ctrl.signal,
101
+ redirect: "follow",
102
+ headers: {
103
+ "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36",
104
+ "content-type": "application/x-www-form-urlencoded",
105
+ accept: "text/html",
106
+ },
107
+ body: `q=${encodeURIComponent(q)}`,
108
+ });
109
+ if (!res.ok)
110
+ return `Search failed: HTTP ${res.status}. Keyless search is rate-limited — set HARA_SEARCH_API_KEY (Tavily) for reliable search, or web_fetch a known URL.`;
111
+ const results = parseSearchResults(await res.text(), limit);
112
+ if (!results.length)
113
+ return "(no results — the keyless endpoint is rate-limited or changed. Set HARA_SEARCH_API_KEY (Tavily) for reliable search, or web_fetch a known URL.)";
114
+ return fmt(results);
115
+ }
116
+ catch (e) {
117
+ return `Search failed: ${e?.name === "AbortError" ? "timed out (20s)" : (e?.message ?? e)}`;
118
+ }
119
+ finally {
120
+ clearTimeout(timer);
121
+ }
122
+ },
123
+ });
27
124
  registerTool({
28
125
  name: "web_fetch",
29
126
  description: "Fetch an http(s) URL and return its text content (HTML is reduced to readable text). Read-only. " +
package/dist/tui/App.js CHANGED
@@ -92,6 +92,19 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
92
92
  return [...cur, { id: nid(), kind, text }];
93
93
  });
94
94
  }, []);
95
+ // Type-ahead steering: hand the runner everything queued while the turn ran, showing each message
96
+ // inline (as a user block) at the point it gets folded into the conversation. Drained mid-turn so an
97
+ // addition reaches the model on its next call; whatever's still queued at turn end is the effect below.
98
+ const drainQueue = useCallback(() => {
99
+ if (!queueRef.current.length)
100
+ return [];
101
+ const batch = queueRef.current;
102
+ queueRef.current = [];
103
+ setPool([]);
104
+ for (const b of batch)
105
+ pushCurrent("user", b.line.trim() || "🖼 (image)");
106
+ return batch;
107
+ }, [pushCurrent]);
95
108
  const handleSubmit = useCallback(async (line, images) => {
96
109
  const t = line.trim();
97
110
  if ((!t && !images?.length) || prompt)
@@ -127,7 +140,7 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
127
140
  const selectFn = (title, options) => openPrompt(title, options);
128
141
  const setApprovalFn = (m) => setStatus((s) => ({ ...s, approval: m }));
129
142
  try {
130
- await onSubmit(t, { sink, confirm: confirmFn, select: selectFn, setApproval: setApprovalFn, signal: ctrl.signal, exit, approval: statusRef.current.approval }, images);
143
+ await onSubmit(t, { sink, confirm: confirmFn, select: selectFn, setApproval: setApprovalFn, signal: ctrl.signal, exit, approval: statusRef.current.approval, drainQueue }, images);
131
144
  }
132
145
  catch (e) {
133
146
  pushCurrent("notice", `error: ${e instanceof Error ? e.message : String(e)}`);
@@ -139,7 +152,7 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
139
152
  setCurrent([]);
140
153
  setWorking(false);
141
154
  ctrlRef.current = null;
142
- }, [working, prompt, onSubmit, pushCurrent, model, exit]);
155
+ }, [working, prompt, onSubmit, pushCurrent, model, exit, drainQueue]);
143
156
  // Drain the type-ahead pool: when the turn finishes (working → false) and nothing awaits a choice, COALESCE
144
157
  // every pooled message into ONE turn and send it — additions/clarifications go to the agent together, in order.
145
158
  useEffect(() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.48.0",
3
+ "version": "0.53.0",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "dist/index.js"