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