@nanhara/hara 0.33.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/dist/index.js CHANGED
@@ -4,17 +4,18 @@ import { createInterface } from "node:readline/promises";
4
4
  import { emitKeypressEvents } from "node:readline";
5
5
  import { runTui } from "./tui/run.js";
6
6
  import { readClipboardImage } from "./images.js";
7
- import { describeImages, classifyVision } from "./vision.js";
7
+ import { describeImages, locateImage, classifyVision, SCREENSHOT_SYSTEM } from "./vision.js";
8
8
  import { setTheme } from "./tui/theme.js";
9
9
  import { memoryDigest, memoryDir } from "./memory/store.js";
10
10
  import { nextMode as cycleMode } from "./tui/InputBox.js";
11
11
  import { stdin, stdout } from "node:process";
12
- import { readFileSync, existsSync } from "node:fs";
13
- import { homedir } from "node:os";
12
+ import { readFileSync, existsSync, writeFileSync, rmSync } from "node:fs";
13
+ import { homedir, tmpdir } from "node:os";
14
14
  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";
@@ -24,14 +25,14 @@ import { getEmbedder } from "./search/embed.js";
24
25
  import { collectRepoChunks, collectDirChunks, buildIndex, indexPath, indexExists } from "./search/semindex.js";
25
26
  import { searchHybrid } from "./search/hybrid.js";
26
27
  import { expandMentions, fileCandidates } from "./context/mentions.js";
27
- import { newSessionId, shortId, resolveSessionId, saveSession, loadSession, listSessions, latestForCwd, titleFrom, } from "./session/store.js";
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
- import { decompose, topoOrder, savePlan, atomPrompt, verify, runCheck } from "./org/planner.js";
33
+ import { decompose, topoOrder, topoWaves, savePlan, loadPlan, atomPrompt, verify, runCheck } from "./org/planner.js";
33
34
  import { connectMcpServers, closeMcp } from "./mcp/client.js";
34
- import { sandboxSupported } from "./sandbox.js";
35
+ import { sandboxSupported, runShell } from "./sandbox.js";
35
36
  import { undoLast } from "./undo.js";
36
37
  import { scaffoldAssets, assetsDir, assetSearchRoots } from "./recall.js";
37
38
  import { c, out, statusLine } from "./ui.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"));
@@ -133,7 +135,96 @@ function lastAssistantText(history) {
133
135
  }
134
136
  return "";
135
137
  }
136
- /** Decompose a task into atoms, sequence them (DAG), and execute each with a verify gate. */
138
+ /** Run one atom (routed to its role if any), then gate it (its `check` command, else an LLM verify). */
139
+ async function executeAtom(atom, plan, done, roles, o) {
140
+ atom.status = "running";
141
+ savePlan(o.cwd, plan);
142
+ const role = atom.role ? roles.find((r) => r.id === atom.role) : undefined;
143
+ const roleProvider = role?.model && role.model !== o.cfg.model ? ((await buildProvider({ ...o.cfg, model: role.model })) ?? o.baseProvider) : o.baseProvider;
144
+ const toolFilter = role?.allowTools
145
+ ? (n) => role.allowTools.includes(n)
146
+ : role?.denyTools
147
+ ? (n) => !role.denyTools.includes(n)
148
+ : undefined;
149
+ const history = [{ role: "user", content: atomPrompt(atom, plan, done) }];
150
+ try {
151
+ await runAgent(history, {
152
+ provider: roleProvider,
153
+ ctx: { cwd: o.cwd, sandbox: o.sandbox },
154
+ approval: o.approval,
155
+ confirm: o.confirm,
156
+ projectContext: o.projectContext,
157
+ memory: memoryDigest(o.cwd),
158
+ stats: o.stats,
159
+ systemOverride: role?.system,
160
+ toolFilter,
161
+ quiet: o.parallel, // concurrent atoms would otherwise interleave their streamed output
162
+ });
163
+ }
164
+ catch (e) {
165
+ atom.status = "failed";
166
+ atom.note = e.message;
167
+ savePlan(o.cwd, plan);
168
+ out(c.red(` ✗ ${atom.id} errored: ${e.message}\n`));
169
+ return false;
170
+ }
171
+ const v = atom.check ? await runCheck(atom.check, o.cwd, o.sandbox) : await verify(o.baseProvider, atom, lastAssistantText(history));
172
+ atom.status = v.ok ? "done" : "failed";
173
+ atom.note = v.reason;
174
+ savePlan(o.cwd, plan);
175
+ out(v.ok ? c.green(` ✓ ${atom.id} verified\n`) : c.yellow(` ⚠ ${atom.id}: ${v.reason}\n`));
176
+ return v.ok;
177
+ }
178
+ /** Execute a plan's atoms (sequential, or parallel waves with --parallel). Atoms already marked `done`
179
+ * are skipped — so this doubles as the resume engine. Stops on the first failure. */
180
+ async function executePlan(plan, roles, o) {
181
+ const done = plan.atoms.filter((a) => a.status === "done");
182
+ const doneIds = new Set(done.map((a) => a.id));
183
+ if (o.parallel) {
184
+ const waved = topoWaves(plan.atoms);
185
+ if ("error" in waved)
186
+ return void out(c.red(`${waved.error}\n`));
187
+ out(c.dim(`Parallel mode — ${waved.ok.length} wave(s).\n`));
188
+ for (const wave of waved.ok) {
189
+ const todo = wave.filter((a) => !doneIds.has(a.id));
190
+ if (!todo.length)
191
+ continue; // whole wave already complete (resume)
192
+ out(c.cyan(`\n▶ wave [${todo.map((a) => a.id).join(", ")}] — ${todo.length} in parallel\n`));
193
+ const results = await Promise.all(todo.map((atom) => executeAtom(atom, plan, done, roles, o)));
194
+ todo.forEach((atom, i) => {
195
+ if (results[i]) {
196
+ done.push(atom);
197
+ doneIds.add(atom.id);
198
+ }
199
+ });
200
+ if (results.some((r) => !r)) {
201
+ out(c.dim("Stopping — a wave atom failed. Inspect .hara/org/plan.json, then fix & `hara plan resume`.\n"));
202
+ break;
203
+ }
204
+ }
205
+ }
206
+ else {
207
+ const ord = topoOrder(plan.atoms);
208
+ if ("error" in ord)
209
+ return void out(c.red(`${ord.error}\n`));
210
+ for (const atom of ord.ok) {
211
+ if (doneIds.has(atom.id))
212
+ continue; // resume: skip completed atoms
213
+ out(c.cyan(`\n▶ ${atom.id} ${atom.title}\n`));
214
+ if (await executeAtom(atom, plan, done, roles, o)) {
215
+ done.push(atom);
216
+ doneIds.add(atom.id);
217
+ }
218
+ else {
219
+ out(c.dim("Stopping — inspect .hara/org/plan.json, then fix & `hara plan resume`.\n"));
220
+ break;
221
+ }
222
+ }
223
+ }
224
+ out(c.bold(`\nPlan: ${plan.atoms.filter((a) => a.status === "done").length}/${plan.atoms.length} atoms done.\n`));
225
+ }
226
+ /** Decompose a task into atoms, sequence them (DAG), and execute each with a verify gate.
227
+ * With `parallel`, independent atoms (the same dependency wave) run concurrently. */
137
228
  async function runPlan(task, o) {
138
229
  const roles = loadRoles(o.cwd);
139
230
  out(c.dim("Planning…\n"));
@@ -147,68 +238,75 @@ async function runPlan(task, o) {
147
238
  out(c.red(`${ord.error}\n`));
148
239
  return;
149
240
  }
150
- const ordered = ord.ok;
151
- out(c.bold(`\nPlan (${ordered.length} atoms):\n`));
152
- for (const a of ordered) {
241
+ out(c.bold(`\nPlan (${ord.ok.length} atoms):\n`));
242
+ for (const a of ord.ok) {
153
243
  out(` ${c.cyan(a.id)} ${a.title}${a.deps.length ? c.dim(" ←" + a.deps.join(",")) : ""}${a.role ? c.dim(" @" + a.role) : ""}${a.check ? c.dim(" ✓" + a.check) : ""}\n`);
154
244
  }
155
245
  if (o.approval !== "full-auto") {
156
- const ok = await o.confirm(`${c.yellow("▶")} Execute this ${ordered.length}-atom plan?`);
246
+ const ok = await o.confirm(`${c.yellow("▶")} Execute this ${ord.ok.length}-atom plan?`);
157
247
  if (!ok)
158
248
  return void out(c.dim("(cancelled)\n"));
159
249
  }
160
250
  savePlan(o.cwd, plan);
161
- const done = [];
162
- for (const atom of ordered) {
163
- atom.status = "running";
164
- savePlan(o.cwd, plan);
165
- out(c.cyan(`\n▶ ${atom.id} ${atom.title}\n`));
166
- const role = atom.role ? roles.find((r) => r.id === atom.role) : undefined;
167
- const roleProvider = role?.model && role.model !== o.cfg.model ? ((await buildProvider({ ...o.cfg, model: role.model })) ?? o.baseProvider) : o.baseProvider;
168
- const toolFilter = role?.allowTools
169
- ? (n) => role.allowTools.includes(n)
170
- : role?.denyTools
171
- ? (n) => !role.denyTools.includes(n)
172
- : undefined;
173
- const history = [{ role: "user", content: atomPrompt(atom, plan, done) }];
174
- try {
175
- await runAgent(history, {
176
- provider: roleProvider,
177
- ctx: { cwd: o.cwd, sandbox: o.sandbox },
178
- approval: o.approval,
179
- confirm: o.confirm,
180
- projectContext: o.projectContext,
181
- memory: memoryDigest(o.cwd),
182
- stats: o.stats,
183
- systemOverride: role?.system,
184
- toolFilter,
185
- });
186
- }
187
- catch (e) {
188
- atom.status = "failed";
189
- atom.note = e.message;
190
- savePlan(o.cwd, plan);
191
- out(c.red(` ✗ ${atom.id} errored: ${e.message}\n`));
192
- break;
193
- }
194
- if (atom.check)
195
- out(c.dim(` check: ${atom.check}\n`));
196
- const v = atom.check ? await runCheck(atom.check, o.cwd, o.sandbox) : await verify(o.baseProvider, atom, lastAssistantText(history));
197
- atom.status = v.ok ? "done" : "failed";
198
- atom.note = v.reason;
199
- savePlan(o.cwd, plan);
200
- if (v.ok) {
201
- out(c.green(` ✓ ${atom.id} verified\n`));
202
- done.push(atom);
203
- }
204
- else {
205
- out(c.yellow(` ⚠ ${atom.id}: ${v.reason}\n`) + c.dim("Stopping inspect .hara/org/plan.json, then refine & re-run.\n"));
206
- break;
207
- }
251
+ await executePlan(plan, roles, o);
252
+ }
253
+ /** Resume the saved plan (.hara/org/plan.json): re-run atoms that aren't done; completed atoms are skipped. */
254
+ async function runResume(o) {
255
+ const roles = loadRoles(o.cwd);
256
+ const plan = loadPlan(o.cwd);
257
+ if (!plan)
258
+ return void out(c.red('No saved plan at .hara/org/plan.json — run `hara plan "<task>"` first.\n'));
259
+ const remaining = plan.atoms.filter((a) => a.status !== "done");
260
+ if (!remaining.length)
261
+ return void out(c.green(`Plan already complete — ${plan.atoms.length}/${plan.atoms.length} done.\n`));
262
+ out(c.bold(`Resuming: ${plan.task}\n`) + c.dim(`${plan.atoms.length - remaining.length}/${plan.atoms.length} done · ${remaining.length} to go\n`));
263
+ for (const a of remaining)
264
+ out(` ${c.cyan(a.id)} ${a.title} ${c.dim("(" + a.status + ")")}\n`);
265
+ if (o.approval !== "full-auto") {
266
+ const ok = await o.confirm(`${c.yellow("▶")} Resume the ${remaining.length} remaining atom(s)?`);
267
+ if (!ok)
268
+ return void out(c.dim("(cancelled)\n"));
269
+ }
270
+ for (const a of plan.atoms)
271
+ if (a.status === "failed" || a.status === "running")
272
+ a.status = "pending"; // retry interrupted
273
+ savePlan(o.cwd, plan);
274
+ await executePlan(plan, roles, o);
275
+ }
276
+ const READONLY_TOOLS = new Set(["read_file", "grep", "glob", "ls", "web_fetch", "web_search", "codebase_search", "todo_write"]);
277
+ const REVIEW_SYSTEM = "You are a senior code reviewer. Review the git diff the user provides for: correctness bugs, security " +
278
+ "issues, missing error handling, unclear naming, and missing/weak tests. You may read files (read-only) " +
279
+ "for context. Be concise and specific — cite file:line and the concrete fix. Group findings by severity: " +
280
+ "**Blocker**, **Should-fix**, **Nit**. If nothing material is wrong, say the diff looks good. Never edit files.";
281
+ const COMMIT_SYSTEM = "Write a git commit message for the staged diff. A concise imperative subject (≤72 chars; an optional " +
282
+ "conventional-commits prefix like feat:/fix:/refactor:/docs:/test:/chore: is welcome). If the change is " +
283
+ "non-trivial, add a blank line then a short body (a few bullets or sentences) on what changed and why. " +
284
+ "Output ONLY the commit message — no code fences, no preamble, no surrounding quotes.";
285
+ const SESSION_NAME_SYSTEM = "Name this coding session as a SHORT slug: 2–4 English words, lowercase, hyphen-separated, ASCII only " +
286
+ "(e.g. add-semantic-search, fix-login-redirect). If the conversation is in another language, translate the " +
287
+ "gist to English (use pinyin only if a term is untranslatable). Output ONLY the slug.";
288
+ /** One short model call → a 2–4 word English kebab-case session name summarizing the work.
289
+ * Always ASCII (translates non-English gist). Falls back to the lexical title on any failure. */
290
+ async function nameSession(provider, history) {
291
+ const text = (m) => {
292
+ if (!m)
293
+ return "";
294
+ if (m.role === "assistant")
295
+ return typeof m.text === "string" ? m.text : "";
296
+ if (m.role === "user")
297
+ return typeof m.content === "string" ? m.content : "";
298
+ return "";
299
+ };
300
+ const basis = `User: ${text(history.find((m) => m.role === "user")).slice(0, 800)}\n` +
301
+ `Assistant: ${text(history.find((m) => m.role === "assistant")).slice(0, 800)}`;
302
+ try {
303
+ const r = await provider.turn({ system: SESSION_NAME_SYSTEM, history: [{ role: "user", content: basis }], tools: [], onText: () => { } });
304
+ return slugify(r.text) || titleFrom(history);
305
+ }
306
+ catch {
307
+ return titleFrom(history);
208
308
  }
209
- out(c.bold(`\nPlan: ${plan.atoms.filter((a) => a.status === "done").length}/${plan.atoms.length} atoms done.\n`));
210
309
  }
211
- const READONLY_TOOLS = new Set(["read_file", "grep", "glob", "ls", "web_fetch"]);
212
310
  const PLAN_SYSTEM = "You are in PLAN MODE. Investigate read-only (read_file / grep / glob / ls / web_fetch) and think, " +
213
311
  "then propose a concise step-by-step plan for the task. Do NOT edit files or run commands yet — only plan. " +
214
312
  "End your message with the plan as a short numbered list.";
@@ -280,6 +378,8 @@ function runDoctor(cfg) {
280
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"}`)}`,
281
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>"); })()}`,
282
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)}`,
283
383
  ];
284
384
  return lines.join("\n");
285
385
  }
@@ -359,9 +459,10 @@ program
359
459
  out(statusLine(cfg.model, stats.input, stats.output) + "\n");
360
460
  });
361
461
  program
362
- .command("plan <task...>")
462
+ .command("plan [task...]")
363
463
  .description("decompose a task into atoms, sequence them (DAG), and execute each with a verify gate")
364
- .action(async (taskParts) => {
464
+ .option("--parallel", "run independent atoms (same dependency wave) concurrently")
465
+ .action(async (taskParts, opts) => {
365
466
  const cfg = loadConfig();
366
467
  const provider = await buildProvider(cfg);
367
468
  if (!provider) {
@@ -369,7 +470,7 @@ program
369
470
  process.exit(1);
370
471
  }
371
472
  const stats = { input: 0, output: 0, lastInput: 0 };
372
- await runPlan(taskParts.join(" "), {
473
+ const o = {
373
474
  cfg,
374
475
  baseProvider: provider,
375
476
  cwd: cfg.cwd,
@@ -378,7 +479,15 @@ program
378
479
  confirm: async () => true,
379
480
  projectContext: loadAgentsMd(cfg.cwd) || undefined,
380
481
  stats,
381
- });
482
+ parallel: opts.parallel,
483
+ };
484
+ const task = (taskParts ?? []).join(" ").trim();
485
+ if (task === "resume")
486
+ await runResume(o);
487
+ else if (!task)
488
+ out(c.dim('usage: hara plan "<task>" (or: hara plan resume)\n'));
489
+ else
490
+ await runPlan(task, o);
382
491
  if (stats.input || stats.output)
383
492
  out(statusLine(cfg.model, stats.input, stats.output) + "\n");
384
493
  });
@@ -424,10 +533,11 @@ program
424
533
  const build = async (name, chunks, blurb) => {
425
534
  if (!chunks.length)
426
535
  return void out(c.dim(`Nothing to index for ${name}.\n`));
427
- out(c.dim(`Embedding ${chunks.length} ${name} chunks with ${cfg.embedProvider}…\n`));
536
+ out(c.dim(`Indexing ${chunks.length} ${name} chunks with ${cfg.embedProvider}…\n`));
428
537
  try {
429
- const n = await buildIndex(name, chunks, embed, cwd, model);
430
- out(c.green(`Indexed ${n} chunks ${indexPath(name, cwd)}`) + c.dim(` (${blurb})`) + "\n");
538
+ const r = await buildIndex(name, chunks, embed, cwd, model);
539
+ const detail = r.reused ? `${r.embedded} embedded, ${r.reused} reused` : `${r.embedded} embedded`;
540
+ out(c.green(`Indexed ${r.total} chunks`) + c.dim(` (${detail}) → ${indexPath(name, cwd)} · ${blurb}`) + "\n");
431
541
  }
432
542
  catch (e) {
433
543
  out(c.red(`Indexing ${name} failed: ${e.message}\n`));
@@ -445,6 +555,111 @@ program
445
555
  .command("doctor")
446
556
  .description("check your hara setup (provider / auth / model / node / assets / roles)")
447
557
  .action(() => out(runDoctor(loadConfig()) + "\n"));
558
+ program
559
+ .command("review")
560
+ .description("review your uncommitted changes (git diff) for bugs, security, and missing tests")
561
+ .option("--staged", "review only staged changes")
562
+ .option("--base <ref>", "review against a base ref (e.g. main) instead of just the working tree")
563
+ .action(async (opts) => {
564
+ const cfg = loadConfig();
565
+ const provider = await buildProvider(cfg);
566
+ if (!provider) {
567
+ out(c.red(`Not authenticated for provider '${cfg.provider}'.\n`) + authHint(cfg) + "\n");
568
+ process.exit(1);
569
+ }
570
+ const cmd = opts.base ? `git diff ${opts.base}` : opts.staged ? "git diff --staged" : "git diff HEAD";
571
+ let diff = "";
572
+ try {
573
+ diff = (await runShell(cmd, cfg.cwd, "off", { timeout: 30_000, maxBuffer: 8_000_000 })).stdout;
574
+ }
575
+ catch (e) {
576
+ return void out(c.red(`\`${cmd}\` failed: ${e instanceof Error ? e.message : String(e)}\n`) + c.dim("(is this a git repo?)\n"));
577
+ }
578
+ if (!diff.trim())
579
+ return void out(c.dim(`No changes to review (${cmd}).\n`));
580
+ out(c.dim(`Reviewing \`${cmd}\` (${diff.split("\n").length} diff lines)…\n\n`));
581
+ const stats = { input: 0, output: 0, lastInput: 0 };
582
+ await runAgent([{ role: "user", content: `Review this diff:\n\n\`\`\`diff\n${diff.slice(0, 120_000)}\n\`\`\`` }], {
583
+ provider,
584
+ ctx: { cwd: cfg.cwd, sandbox: cfg.sandbox },
585
+ approval: "full-auto",
586
+ confirm: async () => true,
587
+ systemOverride: REVIEW_SYSTEM,
588
+ toolFilter: (n) => READONLY_TOOLS.has(n), // read-only: the reviewer can inspect, never edit
589
+ projectContext: loadAgentsMd(cfg.cwd) || undefined,
590
+ memory: memoryDigest(cfg.cwd),
591
+ stats,
592
+ });
593
+ if (stats.input || stats.output)
594
+ out("\n" + statusLine(cfg.model, stats.input, stats.output) + "\n");
595
+ });
596
+ program
597
+ .command("commit")
598
+ .description("generate a commit message from staged changes and commit (-y to skip the confirm)")
599
+ .option("-a, --all", "stage all tracked changes first (git add -u)")
600
+ .action(async (opts) => {
601
+ const skipConfirm = !!program.opts().yes; // reuse the global -y/--yes (auto-approve)
602
+ const cfg = loadConfig();
603
+ const provider = await buildProvider(cfg);
604
+ if (!provider) {
605
+ out(c.red(`Not authenticated for provider '${cfg.provider}'.\n`) + authHint(cfg) + "\n");
606
+ process.exit(1);
607
+ }
608
+ if (opts.all) {
609
+ try {
610
+ await runShell("git add -u", cfg.cwd, "off", { timeout: 30_000, maxBuffer: 1_000_000 });
611
+ }
612
+ catch {
613
+ /* report below if nothing is staged */
614
+ }
615
+ }
616
+ let diff = "";
617
+ try {
618
+ diff = (await runShell("git diff --staged", cfg.cwd, "off", { timeout: 30_000, maxBuffer: 8_000_000 })).stdout;
619
+ }
620
+ catch (e) {
621
+ return void out(c.red(`git diff failed: ${e instanceof Error ? e.message : String(e)}\n`) + c.dim("(is this a git repo?)\n"));
622
+ }
623
+ if (!diff.trim())
624
+ return void out(c.dim("Nothing staged. Stage changes with `git add`, or use `hara commit -a`.\n"));
625
+ out(c.dim("Writing a commit message…\n"));
626
+ const r = await provider.turn({
627
+ system: COMMIT_SYSTEM,
628
+ history: [{ role: "user", content: `Write a commit message for these staged changes:\n\n\`\`\`diff\n${diff.slice(0, 120_000)}\n\`\`\`` }],
629
+ tools: [],
630
+ onText: () => { },
631
+ });
632
+ if (r.stop === "error")
633
+ return void out(c.red(`message generation failed: ${r.errorMsg ?? "provider error"}\n`));
634
+ const msg = r.text.trim().replace(/^```[a-z]*\n?/i, "").replace(/\n?```$/i, "").trim();
635
+ if (!msg)
636
+ return void out(c.red("No commit message produced — commit manually or retry.\n"));
637
+ out("\n" + c.bold("Proposed commit message:\n") + c.dim("─".repeat(48) + "\n") + msg + "\n" + c.dim("─".repeat(48)) + "\n\n");
638
+ if (!skipConfirm) {
639
+ const rl = createInterface({ input: stdin, output: stdout });
640
+ const ans = (await rl.question(`Commit with this message? ${c.dim("[Y/n]")} `)).trim().toLowerCase();
641
+ rl.close();
642
+ if (ans === "n" || ans === "no")
643
+ return void out(c.dim("(cancelled — nothing committed)\n"));
644
+ }
645
+ const tmp = join(tmpdir(), `hara-commit-${process.pid}.txt`);
646
+ writeFileSync(tmp, msg + "\n", "utf8");
647
+ try {
648
+ const res = await runShell(`git commit -F ${JSON.stringify(tmp)}`, cfg.cwd, "off", { timeout: 30_000, maxBuffer: 1_000_000 });
649
+ out(c.green("✓ committed ") + c.dim(((res.stdout || "").trim().split("\n")[0] || "").slice(0, 100)) + "\n");
650
+ }
651
+ catch (e) {
652
+ out(c.red(`git commit failed: ${e instanceof Error ? e.message : String(e)}\n`));
653
+ }
654
+ finally {
655
+ try {
656
+ rmSync(tmp);
657
+ }
658
+ catch {
659
+ /* best-effort cleanup */
660
+ }
661
+ }
662
+ });
448
663
  const rolesCmd = program.command("roles").description("manage org roles (.hara/roles)");
449
664
  rolesCmd
450
665
  .command("init")
@@ -965,19 +1180,35 @@ program.action(async (opts) => {
965
1180
  visionProvider = await buildProvider({ ...cfg, model: cfg.visionModel, baseURL: cfg.visionBaseURL ?? cfg.baseURL, apiKey: cfg.visionApiKey ?? cfg.apiKey });
966
1181
  return visionProvider;
967
1182
  };
968
- // lets the computer tool return a screenshot as text (describe via the vision sidecar / a vision main model)
969
- const describeScreenshot = async (path) => {
1183
+ // lets the computer tool return a screenshot as text (describe via the vision sidecar / a vision main model).
1184
+ // Uses the screenshot-tuned prompt (actionable UI elements + positions) + an optional focus hint, so a
1185
+ // text-only main model gets something it can click on rather than a generic transcription.
1186
+ const describeScreenshot = async (path, hint) => {
970
1187
  const cap = classifyVision(cfg.provider, cfg.model, cfg.modelVision);
971
1188
  const vp = cfg.visionModel ? await getVisionProvider() : cap === "vision" ? provider : null;
972
1189
  if (!vp)
973
1190
  return "";
974
1191
  try {
975
- return await describeImages(vp, [{ path, mediaType: "image/png" }]);
1192
+ return await describeImages(vp, [{ path, mediaType: "image/png" }], { system: SCREENSHOT_SYSTEM, hint });
976
1193
  }
977
1194
  catch {
978
1195
  return "";
979
1196
  }
980
1197
  };
1198
+ // grounding for accurate RPA: ask the vision model WHERE an element is (0..1 fractions) so the computer
1199
+ // tool can click it precisely instead of guessing pixels from a text description.
1200
+ const locateScreenshot = async (path, target) => {
1201
+ const cap = classifyVision(cfg.provider, cfg.model, cfg.modelVision);
1202
+ const vp = cfg.visionModel ? await getVisionProvider() : cap === "vision" ? provider : null;
1203
+ if (!vp)
1204
+ return null;
1205
+ try {
1206
+ return await locateImage(vp, { path, mediaType: "image/png" }, target);
1207
+ }
1208
+ catch {
1209
+ return null;
1210
+ }
1211
+ };
981
1212
  const remindVision = (sink) => {
982
1213
  if (remindedVision)
983
1214
  return void sink.notice(`⚠ image skipped — ${cfg.model} is text-only. Add a vision model: /vision <model>`);
@@ -1196,6 +1427,22 @@ program.action(async (opts) => {
1196
1427
  }
1197
1428
  const ui = { text: h.sink.assistantDelta, reasoning: h.sink.reasoningDelta, tool: h.sink.tool, diff: h.sink.diff, notice: h.sink.notice };
1198
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)
1199
1446
  if (appr === "plan") {
1200
1447
  // PLAN MODE: read-only investigate → propose a plan → selectable proceed → execute.
1201
1448
  const planImg = await resolveImages(images, h);
@@ -1207,7 +1454,7 @@ program.action(async (opts) => {
1207
1454
  const pout = stats.output;
1208
1455
  await runAgent(history, {
1209
1456
  provider,
1210
- ctx: { cwd, sandbox, spawn, ui, describeImage: describeScreenshot },
1457
+ ctx: { cwd, sandbox, spawn, ui, describeImage: describeScreenshot, locate: locateScreenshot },
1211
1458
  approval: "suggest",
1212
1459
  confirm: h.confirm,
1213
1460
  toolFilter: (n) => READONLY_TOOLS.has(n),
@@ -1216,9 +1463,10 @@ program.action(async (opts) => {
1216
1463
  projectContext,
1217
1464
  stats,
1218
1465
  signal: h.signal,
1466
+ pendingInput,
1219
1467
  });
1220
1468
  if (!meta.title) {
1221
- meta.title = titleFrom(history);
1469
+ meta.title = await nameSession(provider, history);
1222
1470
  h.sink.session(meta.title);
1223
1471
  }
1224
1472
  h.sink.usage(stats.input - pin, stats.output - pout);
@@ -1235,7 +1483,7 @@ program.action(async (opts) => {
1235
1483
  const xout = stats.output;
1236
1484
  await runAgent(history, {
1237
1485
  provider,
1238
- ctx: { cwd, sandbox, spawn, ui, describeImage: describeScreenshot },
1486
+ ctx: { cwd, sandbox, spawn, ui, describeImage: describeScreenshot, locate: locateScreenshot },
1239
1487
  approval: choice,
1240
1488
  memory: buildMemory(),
1241
1489
  confirm: h.confirm,
@@ -1243,10 +1491,12 @@ program.action(async (opts) => {
1243
1491
  projectContext,
1244
1492
  stats,
1245
1493
  signal: h.signal,
1494
+ pendingInput,
1246
1495
  });
1247
1496
  h.sink.usage(stats.input - xin, stats.output - xout);
1248
1497
  saveSession(meta, history);
1249
1498
  }
1499
+ notifyDone(cfg.notify, { message: meta.title || "plan turn complete", elapsedMs: Date.now() - turnStart });
1250
1500
  return;
1251
1501
  }
1252
1502
  const ri = await resolveImages(images, h);
@@ -1259,7 +1509,7 @@ program.action(async (opts) => {
1259
1509
  const beforeOut = stats.output;
1260
1510
  await runAgent(history, {
1261
1511
  provider,
1262
- ctx: { cwd, sandbox, spawn, ui, describeImage: describeScreenshot },
1512
+ ctx: { cwd, sandbox, spawn, ui, describeImage: describeScreenshot, locate: locateScreenshot },
1263
1513
  approval: appr,
1264
1514
  memory: buildMemory(),
1265
1515
  confirm: h.confirm,
@@ -1267,12 +1517,14 @@ program.action(async (opts) => {
1267
1517
  projectContext,
1268
1518
  stats,
1269
1519
  signal: h.signal,
1520
+ pendingInput,
1270
1521
  });
1271
1522
  if (!meta.title) {
1272
- meta.title = titleFrom(history);
1523
+ meta.title = await nameSession(provider, history);
1273
1524
  h.sink.session(meta.title);
1274
1525
  }
1275
1526
  h.sink.usage(stats.input - beforeIn, stats.output - beforeOut);
1527
+ notifyDone(cfg.notify, { message: meta.title || "turn complete", elapsedMs: Date.now() - turnStart });
1276
1528
  saveSession(meta, history);
1277
1529
  },
1278
1530
  });
@@ -1319,6 +1571,7 @@ program.action(async (opts) => {
1319
1571
  recalledContext = "";
1320
1572
  history.push({ role: "user", content: userContent });
1321
1573
  currentTurn = new AbortController();
1574
+ const t0 = Date.now();
1322
1575
  try {
1323
1576
  await runAgent(history, { provider, ctx: { cwd, sandbox, spawn }, approval, confirm, autoApprove, projectContext, memory: buildMemory(), stats, signal: currentTurn.signal });
1324
1577
  }
@@ -1328,8 +1581,9 @@ program.action(async (opts) => {
1328
1581
  finally {
1329
1582
  currentTurn = null;
1330
1583
  }
1584
+ notifyDone(cfg.notify, { message: meta.title || "turn complete", elapsedMs: Date.now() - t0 });
1331
1585
  if (!meta.title)
1332
- meta.title = titleFrom(history);
1586
+ meta.title = await nameSession(provider, history);
1333
1587
  if (bar.isActive()) {
1334
1588
  bar.update({
1335
1589
  sessionName: meta.title,
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
+ }