@nanhara/hara 0.122.2 → 0.122.4

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.
Files changed (64) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/README.md +15 -2
  3. package/dist/agent/limits.js +51 -0
  4. package/dist/agent/loop.js +367 -112
  5. package/dist/agent/repeat-guard.js +49 -12
  6. package/dist/checkpoints.js +9 -0
  7. package/dist/cli.js +2 -1
  8. package/dist/config.js +10 -2
  9. package/dist/context/agents-md.js +21 -2
  10. package/dist/context/mentions.js +84 -1
  11. package/dist/context/workspace-scope.js +49 -0
  12. package/dist/cron/deliver.js +61 -38
  13. package/dist/cron/runner.js +423 -65
  14. package/dist/cron/schedule.js +23 -7
  15. package/dist/cron/store.js +709 -43
  16. package/dist/fs-walk.js +279 -7
  17. package/dist/fs-write.js +9 -0
  18. package/dist/gateway/feishu.js +351 -64
  19. package/dist/gateway/flows-pending.js +31 -17
  20. package/dist/gateway/flows.js +306 -31
  21. package/dist/gateway/outbound-files.js +20 -6
  22. package/dist/gateway/runtime-state.js +1485 -0
  23. package/dist/gateway/serve.js +550 -242
  24. package/dist/gateway/sessions.js +3 -3
  25. package/dist/gateway/telegram.js +279 -29
  26. package/dist/gateway/tts.js +182 -38
  27. package/dist/hooks.js +22 -22
  28. package/dist/index.js +466 -158
  29. package/dist/memory/store.js +8 -5
  30. package/dist/org/planner.js +11 -6
  31. package/dist/org/projects.js +3 -3
  32. package/dist/process-identity.js +52 -0
  33. package/dist/providers/bounded-turn.js +42 -0
  34. package/dist/recall.js +69 -1
  35. package/dist/runtime.js +24 -0
  36. package/dist/sandbox.js +54 -4
  37. package/dist/search/embed.js +13 -2
  38. package/dist/search/hybrid.js +6 -3
  39. package/dist/search/semindex.js +87 -5
  40. package/dist/security/guardian.js +3 -15
  41. package/dist/security/permissions.js +11 -0
  42. package/dist/serve/server.js +70 -42
  43. package/dist/serve/sessions.js +4 -3
  44. package/dist/sync-sleep.js +46 -0
  45. package/dist/tools/agent.js +1 -1
  46. package/dist/tools/ask_user.js +5 -1
  47. package/dist/tools/builtin.js +5 -2
  48. package/dist/tools/codebase.js +67 -18
  49. package/dist/tools/computer.js +149 -68
  50. package/dist/tools/cron.js +72 -18
  51. package/dist/tools/edit.js +3 -2
  52. package/dist/tools/external_agent.js +66 -12
  53. package/dist/tools/memory.js +25 -7
  54. package/dist/tools/patch.js +11 -2
  55. package/dist/tools/registry.js +16 -1
  56. package/dist/tools/search.js +68 -9
  57. package/dist/tools/send.js +1 -1
  58. package/dist/tools/skill.js +1 -1
  59. package/dist/tools/task.js +3 -3
  60. package/dist/tools/web.js +43 -13
  61. package/dist/tui/App.js +93 -25
  62. package/dist/vision.js +5 -6
  63. package/package.json +2 -2
  64. package/runtime-bootstrap.cjs +22 -0
@@ -27,7 +27,7 @@ registerTool({
27
27
  const located = `Skill directory (absolute): ${dirname(sk.file)}\nRead any sibling files this skill mentions (e.g. references/…, assets/…) from under that directory.\n\n${body}`;
28
28
  if (sk.context === "fork" && ctx.spawn) {
29
29
  // fork: run the skill as a delegated sub-agent rather than inlining it into this turn
30
- return await ctx.spawn(`Follow this skill to complete the current task:\n\n${located}`);
30
+ return await ctx.spawn(`Follow this skill to complete the current task:\n\n${located}`, undefined, ctx.signal);
31
31
  }
32
32
  return located; // inline (default): the body enters the conversation as this tool's result
33
33
  },
@@ -6,9 +6,9 @@ import { basename, join, resolve } from "node:path";
6
6
  import { homedir } from "node:os";
7
7
  import { createHash, randomUUID } from "node:crypto";
8
8
  import { registerTool } from "./registry.js";
9
+ import { sleepSync } from "../sync-sleep.js";
9
10
  const TASK_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
10
11
  const TASK_STATUSES = new Set(["pending", "in_progress", "done"]);
11
- const sleepCell = new Int32Array(new SharedArrayBuffer(4));
12
12
  const LOCK_ATTEMPTS = 500;
13
13
  const LOCK_WAIT_MS = 10;
14
14
  /** Resolve aliases/symlinks before deriving the key. A missing path still gets a stable absolute key so
@@ -113,7 +113,7 @@ function withTaskLock(cwd, fn) {
113
113
  continue;
114
114
  }
115
115
  }
116
- Atomics.wait(sleepCell, 0, 0, LOCK_WAIT_MS);
116
+ sleepSync(LOCK_WAIT_MS);
117
117
  continue;
118
118
  }
119
119
  const candidate = { pid: process.pid, token: randomUUID() };
@@ -144,7 +144,7 @@ function withTaskLock(cwd, fn) {
144
144
  rmSync(reclaim, { force: true });
145
145
  }
146
146
  }
147
- Atomics.wait(sleepCell, 0, 0, LOCK_WAIT_MS);
147
+ sleepSync(LOCK_WAIT_MS);
148
148
  }
149
149
  if (!claim)
150
150
  throw new Error("task store is busy; retry the operation");
package/dist/tools/web.js CHANGED
@@ -349,21 +349,36 @@ export function looksLikeJsRenderedShell(html, readable) {
349
349
  const shellText = /(?:enable javascript|javascript is required|loading[.…]*|正在加载|请启用\s*javascript)/i.test(readable || html);
350
350
  return (hasRoot && scripts > 0) || (scripts >= 2 && readable.trim().length < 40) || shellText;
351
351
  }
352
- async function searchFetch(url, init) {
353
- return fetch(url, { ...init, signal: AbortSignal.timeout(SEARCH_ATTEMPT_MS) });
352
+ function interrupted(label) {
353
+ const error = new Error(`${label} interrupted by agent run deadline or cancellation`);
354
+ error.name = "AbortError";
355
+ return error;
354
356
  }
355
- async function firstSuccessfulSearch(attempts, failures) {
357
+ async function searchFetch(url, init, parentSignal) {
358
+ if (parentSignal?.aborted)
359
+ throw interrupted("web search");
360
+ const attemptSignal = AbortSignal.timeout(SEARCH_ATTEMPT_MS);
361
+ const signal = parentSignal ? AbortSignal.any([parentSignal, attemptSignal]) : attemptSignal;
362
+ return fetch(url, { ...init, signal });
363
+ }
364
+ async function firstSuccessfulSearch(attempts, failures, signal) {
356
365
  // Try one provider at a time. Search terms can be sensitive; a successful request must not be mirrored
357
366
  // to unrelated providers merely to save a few seconds, and sequential fallback also avoids leaving
358
367
  // losing requests running after the tool has already returned.
359
368
  for (const attempt of attempts) {
369
+ if (signal?.aborted)
370
+ throw interrupted("web search");
360
371
  try {
361
372
  const results = await attempt.run();
373
+ if (signal?.aborted)
374
+ throw interrupted("web search");
362
375
  if (results.length)
363
376
  return results;
364
377
  failures.push(`${attempt.name} no results`);
365
378
  }
366
379
  catch (e) {
380
+ if (signal?.aborted)
381
+ throw interrupted("web search");
367
382
  const reason = e?.name === "TimeoutError" || e?.name === "AbortError" ? "timeout" : (e?.message ?? String(e));
368
383
  failures.push(`${attempt.name} ${reason}`);
369
384
  }
@@ -384,7 +399,9 @@ registerTool({
384
399
  required: ["query"],
385
400
  },
386
401
  kind: "read",
387
- async run(input) {
402
+ async run(input, ctx) {
403
+ if (ctx.signal?.aborted)
404
+ throw interrupted("web search");
388
405
  const q = String(input.query ?? "").trim();
389
406
  if (!q)
390
407
  return "(empty query)";
@@ -403,7 +420,7 @@ registerTool({
403
420
  method: "POST",
404
421
  headers: { "content-type": "application/json" },
405
422
  body: JSON.stringify({ api_key: key, query: q, max_results: limit }),
406
- });
423
+ }, ctx.signal);
407
424
  if (!res.ok)
408
425
  throw new Error(`HTTP ${res.status}`);
409
426
  const j = (await res.json());
@@ -418,13 +435,13 @@ registerTool({
418
435
  method: "GET",
419
436
  redirect: "follow",
420
437
  headers: { "user-agent": SEARCH_UA, accept: "text/html" },
421
- });
438
+ }, ctx.signal);
422
439
  if (!res.ok)
423
440
  throw new Error(`HTTP ${res.status}`);
424
441
  return parseBingSearchResults(await res.text(), limit);
425
442
  },
426
443
  });
427
- const primary = await firstSuccessfulSearch(primaryAttempts, failures);
444
+ const primary = await firstSuccessfulSearch(primaryAttempts, failures, ctx.signal);
428
445
  if (primary)
429
446
  return wrapUntrusted(fmt(primary), `web_search: ${q}`);
430
447
  // Secondary sources are ordered fallbacks. Google is included where reachable, but is never the sole
@@ -437,7 +454,7 @@ registerTool({
437
454
  method: "GET",
438
455
  redirect: "follow",
439
456
  headers: { "user-agent": SEARCH_UA, accept: "text/html" },
440
- });
457
+ }, ctx.signal);
441
458
  if (!res.ok)
442
459
  throw new Error(`HTTP ${res.status}`);
443
460
  return parseBaiduSearchResults(await res.text(), limit);
@@ -450,7 +467,7 @@ registerTool({
450
467
  method: "GET",
451
468
  redirect: "follow",
452
469
  headers: { "user-agent": SEARCH_UA, accept: "text/html" },
453
- });
470
+ }, ctx.signal);
454
471
  if (!res.ok)
455
472
  throw new Error(`HTTP ${res.status}`);
456
473
  return parseGoogleSearchResults(await res.text(), limit);
@@ -464,13 +481,13 @@ registerTool({
464
481
  redirect: "follow",
465
482
  headers: { "user-agent": SEARCH_UA, "content-type": "application/x-www-form-urlencoded", accept: "text/html" },
466
483
  body: `q=${encodeURIComponent(q)}`,
467
- });
484
+ }, ctx.signal);
468
485
  if (!res.ok)
469
486
  throw new Error(`HTTP ${res.status}`);
470
487
  return parseSearchResults(await res.text(), limit);
471
488
  },
472
489
  },
473
- ], failures);
490
+ ], failures, ctx.signal);
474
491
  if (secondary)
475
492
  return wrapUntrusted(fmt(secondary), `web_search: ${q}`);
476
493
  return `Search failed across available providers (${failures.join("; ")}). Check connectivity/proxy, configure HARA_SEARCH_API_KEY, or web_fetch a known URL.`;
@@ -489,7 +506,9 @@ registerTool({
489
506
  required: ["url"],
490
507
  },
491
508
  kind: "read",
492
- async run(input) {
509
+ async run(input, ctx) {
510
+ if (ctx.signal?.aborted)
511
+ throw interrupted("web fetch");
493
512
  let url;
494
513
  try {
495
514
  url = new URL(input.url);
@@ -502,13 +521,18 @@ registerTool({
502
521
  const cap = Math.min(Math.max(1000, input.max_chars ?? MAX), 200_000);
503
522
  const ctrl = new AbortController();
504
523
  const timer = setTimeout(() => ctrl.abort(), 30_000);
524
+ const signal = ctx.signal ? AbortSignal.any([ctx.signal, ctrl.signal]) : ctrl.signal;
505
525
  try {
506
526
  // Follow redirects manually so the SSRF guard runs on EVERY hop (a public URL can 30x to 169.254…).
507
527
  let current = url;
508
528
  let res;
509
529
  for (let hop = 0;; hop++) {
530
+ if (ctx.signal?.aborted)
531
+ throw interrupted("web fetch");
510
532
  const pinned = await resolvePublicHost(current.hostname);
511
- res = await requestPinned(current, pinned, ctrl.signal);
533
+ if (ctx.signal?.aborted)
534
+ throw interrupted("web fetch");
535
+ res = await requestPinned(current, pinned, signal);
512
536
  const loc = res.status >= 300 && res.status < 400 ? res.headers.get("location") : null;
513
537
  if (!loc || hop >= 5)
514
538
  break;
@@ -520,10 +544,14 @@ registerTool({
520
544
  // We never consume redirect bodies. Destroy the socket before following the next pinned hop so a
521
545
  // server cannot accumulate idle response streams across a redirect chain.
522
546
  res.body.destroy();
547
+ if (ctx.signal?.aborted)
548
+ throw interrupted("web fetch");
523
549
  current = next;
524
550
  }
525
551
  const ct = res.headers.get("content-type") ?? "";
526
552
  const raw = await readPinnedCapped(res.body, cap * 4); // byte ceiling (HTML→text shrinks; cap*4 leaves headroom)
553
+ if (ctx.signal?.aborted)
554
+ throw interrupted("web fetch");
527
555
  let text = /html/i.test(ct) ? htmlToText(raw) : raw;
528
556
  if (text.length > cap)
529
557
  text = text.slice(0, cap) + `\n…[truncated ${text.length - cap} chars]`;
@@ -535,6 +563,8 @@ registerTool({
535
563
  return `# ${current.href} (HTTP ${res.status})\n\n${wrapUntrusted(text || "(empty body)", current.href)}`;
536
564
  }
537
565
  catch (e) {
566
+ if (ctx.signal?.aborted)
567
+ throw interrupted("web fetch");
538
568
  return `Error fetching ${url.href}: ${e?.name === "AbortError" ? "timed out (30s)" : (e?.message ?? e)}`;
539
569
  }
540
570
  finally {
package/dist/tui/App.js CHANGED
@@ -23,6 +23,13 @@ import { ModelPicker } from "./model-picker.js";
23
23
  let _id = 0;
24
24
  const nid = () => ++_id;
25
25
  const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
26
+ function promptAbortReason(signal) {
27
+ if (signal.reason instanceof Error)
28
+ return signal.reason;
29
+ const error = new Error("interactive prompt cancelled");
30
+ error.name = "AbortError";
31
+ return error;
32
+ }
26
33
  /** Prepare a finalized turn item for the append-only `<Static>` scrollback. A completed reasoning
27
34
  * block collapses to a single-line "✻ thought · N lines" notice (its full text preserved in `full`
28
35
  * for the Ctrl+T overlay) — mirroring codex writing finalized rows to scrollback ONCE. Every other
@@ -231,7 +238,7 @@ function HeaderCard(props) {
231
238
  : row("profile", _jsx(Text, { children: props.profileId ? `personal:${props.profileId}` : "personal" }));
232
239
  return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "gray", borderDimColor: true, paddingX: 1, alignSelf: "flex-start", marginBottom: 1, children: [_jsxs(Text, { children: [_jsx(Text, { color: accent(), bold: true, children: "◆ hara" }), _jsx(Text, { dimColor: true, children: ` v${version} · the agent that runs like an org` })] }), _jsx(Text, { children: " " }), identityRow, row("model", (_jsxs(Text, { children: [_jsx(Text, { children: modelLabel }), isOrg
233
240
  ? props.modelSource ? _jsx(Text, { dimColor: true, children: ` · from ${props.modelSource}` }) : null
234
- : props.visionModel ? _jsx(Text, { dimColor: true, children: modelLineSuffix(props.visionModel) }) : null, _jsx(Text, { children: " " }), _jsx(Text, { color: "green", children: "/model ↹" })] }))), row("cwd", (_jsxs(Text, { children: [_jsx(Text, { children: cwdShort }), agentsMdLoaded ? _jsx(Text, { dimColor: true, children: " · AGENTS.md" }) : null] }))), sessionShort ? row("session", _jsx(Text, { children: sessionShort })) : null] }), _jsx(Text, { dimColor: true, children: " Tip: @ attach file · ctrl+t transcript · ctrl+r reasoning · shift+tab approval · esc interrupt" }), props.updateNotice ? _jsx(Text, { color: "yellow", children: ` ⬆ ${props.updateNotice}` }) : null] }));
241
+ : props.visionModel ? _jsx(Text, { dimColor: true, children: modelLineSuffix(props.visionModel) }) : null, _jsx(Text, { children: " " }), _jsx(Text, { color: "green", children: "/model ↹" })] }))), row("cwd", (_jsxs(Text, { children: [_jsx(Text, { children: cwdShort }), agentsMdLoaded ? _jsx(Text, { dimColor: true, children: " · AGENTS.md" }) : null] }))), sessionShort ? row("session", _jsx(Text, { children: sessionShort })) : null] }), _jsx(Text, { dimColor: true, children: " Tip: @ attach file · ctrl+t transcript · ctrl+r reasoning · shift+tab approval · esc interrupt" }), props.workspaceNotice ? _jsx(Text, { color: "yellow", children: ` ⚠ ${props.workspaceNotice}` }) : null, props.updateNotice ? _jsx(Text, { color: "yellow", children: ` ⬆ ${props.updateNotice}` }) : null] }));
235
242
  }
236
243
  // Spinner verb: while a turn is running, prefer the in_progress todo's activeForm (or its text),
237
244
  // so the bottom line reads "▶ updating tests…" instead of an abstract "working". Falls back to
@@ -381,6 +388,8 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
381
388
  clearTimeout(flushTimerRef.current);
382
389
  if (modeSelectorTimerRef.current)
383
390
  clearTimeout(modeSelectorTimerRef.current);
391
+ queueRef.current = [];
392
+ ctrlRef.current?.abort();
384
393
  }, []);
385
394
  // Subscribe to todo_write updates so the panel re-renders when the agent edits the checklist.
386
395
  useEffect(() => {
@@ -460,12 +469,20 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
460
469
  pushCurrent("user", b.line.trim() || "🖼 (image)");
461
470
  return batch;
462
471
  }, [pushCurrent, noteVisionIfNeeded]);
472
+ // One stop primitive for every live-turn surface. A queued type-ahead belongs to the turn being stopped;
473
+ // retaining it while Esc dismisses an approval/ask prompt would auto-submit it as a surprise second turn.
474
+ const abortCurrentTurn = useCallback(() => {
475
+ if (queueRef.current.length) {
476
+ queueRef.current = [];
477
+ setPool([]);
478
+ }
479
+ ctrlRef.current?.abort();
480
+ }, []);
463
481
  const handleSubmit = useCallback(async (line, images) => {
464
482
  const t = line.trim();
465
483
  // A free-text question (ask_user) is awaiting an answer: this submission IS the answer, not a new turn.
466
484
  if (askText) {
467
485
  const r = askText.resolve;
468
- setAskText(null);
469
486
  setHistory((h) => [...h, { id: nid(), kind: "user", text: t }]);
470
487
  r(t);
471
488
  return;
@@ -502,31 +519,87 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
502
519
  usage: (input, output) => setStatus((s) => ({ ...s, input: s.input + input, output: s.output + output, ctxPct: ctxPctFor(model, input) })),
503
520
  session: (name) => setStatus((s) => ({ ...s, sessionName: name })),
504
521
  };
505
- const openPrompt = (title, options) => new Promise((resolve) => {
522
+ const openPrompt = (title, options, signal = ctrl.signal, abortTurnOnEscape = false) => new Promise((resolve, reject) => {
523
+ if (signal.aborted) {
524
+ reject(promptAbortReason(signal));
525
+ return;
526
+ }
527
+ const token = Symbol("prompt");
528
+ let settled = false;
529
+ const cleanup = () => signal.removeEventListener("abort", onAbort);
530
+ const finish = (value) => {
531
+ if (settled)
532
+ return;
533
+ settled = true;
534
+ cleanup();
535
+ setPrompt((current) => current?.token === token ? null : current);
536
+ resolve(value);
537
+ };
538
+ const onAbort = () => {
539
+ if (settled)
540
+ return;
541
+ settled = true;
542
+ cleanup();
543
+ setPrompt((current) => current?.token === token ? null : current);
544
+ reject(promptAbortReason(signal));
545
+ };
546
+ signal.addEventListener("abort", onAbort, { once: true });
506
547
  setPromptSel(0);
507
- setPrompt({ title, options: options, resolve: resolve });
548
+ setPrompt({
549
+ token,
550
+ title,
551
+ options: options,
552
+ resolve: finish,
553
+ abortTurnOnEscape,
554
+ });
508
555
  });
509
- const confirmFn = (q) => openPrompt(q, [
556
+ const confirmFn = (q, signal = ctrl.signal) => openPrompt(q, [
510
557
  { label: "Yes", value: true, key: "y" },
511
558
  { label: "Yes, and don't ask again this session", value: "always", key: "a" },
512
559
  { label: "No (esc)", value: false, key: "n" },
513
- ]);
560
+ ], signal, true);
514
561
  const selectFn = (title, options) => openPrompt(title, options);
515
562
  // Free-text question: re-enable the InputBox to read one line (resolves via handleSubmit's askText branch).
516
- const askTextFn = (title) => new Promise((resolve) => setAskText({ title, resolve }));
563
+ const askTextFn = (title, signal = ctrl.signal) => new Promise((resolve, reject) => {
564
+ if (signal.aborted) {
565
+ reject(promptAbortReason(signal));
566
+ return;
567
+ }
568
+ const token = Symbol("ask-text");
569
+ let settled = false;
570
+ const cleanup = () => signal.removeEventListener("abort", onAbort);
571
+ const finish = (value) => {
572
+ if (settled)
573
+ return;
574
+ settled = true;
575
+ cleanup();
576
+ setAskText((current) => current?.token === token ? null : current);
577
+ resolve(value);
578
+ };
579
+ const onAbort = () => {
580
+ if (settled)
581
+ return;
582
+ settled = true;
583
+ cleanup();
584
+ setAskText((current) => current?.token === token ? null : current);
585
+ reject(promptAbortReason(signal));
586
+ };
587
+ signal.addEventListener("abort", onAbort, { once: true });
588
+ setAskText({ token, title, resolve: finish });
589
+ });
517
590
  // ask_user: when options are given, offer them as a select + a "type my own" escape hatch; otherwise (or
518
591
  // when the user chooses to type their own) capture a free-text line. Returns the chosen/typed answer.
519
592
  const OTHER = "\\0__ask_other__"; // escaped sentinel keeps this TypeScript file text-only
520
- const askFn = async (question, options) => {
593
+ const askFn = async (question, options, signal = ctrl.signal) => {
521
594
  if (options && options.length) {
522
595
  const choice = await openPrompt(question, [
523
596
  ...options.map((o) => ({ label: o, value: o })),
524
597
  { label: "✎ Type my own answer", value: OTHER },
525
- ]);
598
+ ], signal, true);
526
599
  if (choice !== OTHER)
527
600
  return choice;
528
601
  }
529
- return askTextFn(question);
602
+ return askTextFn(question, signal);
530
603
  };
531
604
  const setApprovalFn = (m) => setStatus((s) => ({ ...s, approval: m }));
532
605
  const pickModelFn = (o) => new Promise((resolve) => setPicker({ ...o, resolve }));
@@ -580,12 +653,13 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
580
653
  return; // while open, the overlay's own useInput owns every key (scroll / esc)
581
654
  if (picker)
582
655
  return; // the /model picker overlay owns input (↑↓ model, ←→ thinking, ⏎, esc) while open
583
- // Free-text question awaiting an answer: Esc cancels (empty answer); all other keys belong to the InputBox.
656
+ // Free-text question awaiting an answer: Esc aborts the owning turn; all other keys belong to InputBox.
584
657
  if (askText) {
585
658
  if (key.escape) {
586
- const r = askText.resolve;
587
- setAskText(null);
588
- r("");
659
+ if (working && ctrlRef.current)
660
+ abortCurrentTurn();
661
+ else
662
+ askText.resolve("");
589
663
  }
590
664
  return;
591
665
  }
@@ -597,21 +671,20 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
597
671
  setPromptSel((s) => (s + 1) % opts.length);
598
672
  else if (key.return) {
599
673
  prompt.resolve(opts[Math.min(promptSel, opts.length - 1)].value);
600
- setPrompt(null);
601
674
  }
602
675
  else if (key.escape) {
603
- prompt.resolve(opts[opts.length - 1].value); // last option = cancel/no
604
- setPrompt(null);
676
+ if (prompt.abortTurnOnEscape && working && ctrlRef.current)
677
+ abortCurrentTurn();
678
+ else
679
+ prompt.resolve(opts[opts.length - 1].value); // select-only prompt: last option = cancel/no
605
680
  }
606
681
  else if (/^[1-9]$/.test(input) && Number(input) <= opts.length) {
607
682
  prompt.resolve(opts[Number(input) - 1].value); // type a number to pick directly
608
- setPrompt(null);
609
683
  }
610
684
  else if (input) {
611
685
  const hit = opts.find((o) => o.key && o.key === input.toLowerCase());
612
686
  if (hit) {
613
687
  prompt.resolve(hit.value);
614
- setPrompt(null);
615
688
  }
616
689
  }
617
690
  return;
@@ -619,12 +692,7 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
619
692
  if (key.ctrl && input === "r")
620
693
  return setReasoningOpen((x) => !x);
621
694
  if (key.escape && working) {
622
- // Esc = stop everything: abort the turn AND drop any type-ahead (a stopped turn shouldn't fire queued msgs)
623
- if (queueRef.current.length) {
624
- queueRef.current = [];
625
- setPool([]);
626
- }
627
- ctrlRef.current?.abort();
695
+ abortCurrentTurn();
628
696
  }
629
697
  else if (key.tab && key.shift && cycleApproval) {
630
698
  setStatus((s) => ({ ...s, approval: cycleApproval(s.approval) }));
package/dist/vision.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { boundedProviderTurn } from "./providers/bounded-turn.js";
1
2
  // Built-in capability map for the major model families. First matching rule wins, so each family's
2
3
  // vision pattern is listed BEFORE its text catch-all. Anything that matches nothing → "unknown"
3
4
  // (we ask the user once and remember). Easy to extend — add a rule near the right family.
@@ -101,13 +102,12 @@ export function parseLocate(text) {
101
102
  }
102
103
  /** Send a screenshot to a (grounding-capable) vision model and get the target's center as 0..1 fractions. */
103
104
  export async function locateImage(provider, image, target, opts = {}) {
104
- const r = await provider.turn({
105
+ const r = await boundedProviderTurn(provider, {
105
106
  system: LOCATE_SYSTEM,
106
107
  history: [{ role: "user", content: `Locate this element: ${target}`, images: [image] }],
107
108
  tools: [],
108
109
  onText: () => { },
109
- signal: opts.signal,
110
- });
110
+ }, { timeoutMs: opts.timeoutMs ?? 30_000, signal: opts.signal, label: "image element location" });
111
111
  if (r.stop === "error")
112
112
  return null;
113
113
  return parseLocate(r.text);
@@ -117,13 +117,12 @@ const PROMPT = "Describe the attached image(s) per your instructions.";
117
117
  * `system` overrides the default prompt (e.g. SCREENSHOT_SYSTEM); `hint` focuses it on a specific goal. */
118
118
  export async function describeImages(provider, images, opts = {}) {
119
119
  const content = opts.hint ? `${PROMPT}\nFocus especially on: ${opts.hint}` : PROMPT;
120
- const r = await provider.turn({
120
+ const r = await boundedProviderTurn(provider, {
121
121
  system: opts.system ?? DESCRIBE_SYSTEM,
122
122
  history: [{ role: "user", content, images }],
123
123
  tools: [],
124
124
  onText: () => { },
125
- signal: opts.signal,
126
- });
125
+ }, { timeoutMs: opts.timeoutMs ?? 90_000, signal: opts.signal, label: "image description" });
127
126
  if (r.stop === "error")
128
127
  throw new Error(r.errorMsg || "vision provider error");
129
128
  return r.text.trim();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.122.2",
3
+ "version": "0.122.4",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "runtime-bootstrap.cjs"
@@ -43,7 +43,7 @@
43
43
  "prepare": "npm run build",
44
44
  "dev": "tsx src/cli.ts",
45
45
  "start": "node runtime-bootstrap.cjs",
46
- "test": "npm run build && node --test test/*.test.mjs",
46
+ "test": "npm run build && node --test --test-timeout=120000 test/*.test.mjs",
47
47
  "build:binary": "npm run build && bun scripts/build-binary.ts dist/bin/hara",
48
48
  "build:binaries": "npm run build && bun scripts/build-binary.ts dist/bin/hara-darwin-arm64 bun-darwin-arm64 && bun scripts/build-binary.ts dist/bin/hara-darwin-x64 bun-darwin-x64 && bun scripts/build-binary.ts dist/bin/hara-linux-x64 bun-linux-x64 && bun scripts/build-binary.ts dist/bin/hara-linux-arm64 bun-linux-arm64"
49
49
  },
@@ -37,6 +37,25 @@ function unsupportedNodeMessage(versions) {
37
37
  ].join("\n");
38
38
  }
39
39
 
40
+ function normalizePortableWindowsHome(value) {
41
+ var home = String(value || "").trim();
42
+ var drive = /^\/([a-zA-Z])(?:\/(.*))?$/.exec(home);
43
+ if (drive) return drive[1].toUpperCase() + ":\\" + String(drive[2] || "").replace(/\//g, "\\");
44
+ if (/^\/\/[^/]/.test(home)) return "\\\\" + home.slice(2).replace(/\//g, "\\");
45
+ if (/^[a-zA-Z]:[\\/]/.test(home)) return home.charAt(0).toUpperCase() + home.slice(1).replace(/\//g, "\\");
46
+ return home;
47
+ }
48
+
49
+ function applyPortableHomeEnv(env, runtimePlatform) {
50
+ env = env || process.env;
51
+ runtimePlatform = runtimePlatform || process.platform;
52
+ if (runtimePlatform !== "win32") return false;
53
+ var home = normalizePortableWindowsHome(env.HOME || "");
54
+ if (!home || env.USERPROFILE === home) return false;
55
+ env.USERPROFILE = home;
56
+ return true;
57
+ }
58
+
40
59
  function failStart(error) {
41
60
  var message = error && error.message ? error.message : String(error);
42
61
  process.stderr.write("hara: failed to start: " + message + "\n");
@@ -52,6 +71,7 @@ function main() {
52
71
  }
53
72
 
54
73
  try {
74
+ applyPortableHomeEnv(process.env, process.platform);
55
75
  // Keeping import() inside a string prevents legacy parsers from seeing unsupported ESM syntax. This
56
76
  // branch is reached only on the supported Node floor (or Bun when used as a script runtime).
57
77
  var load = Function("specifier", "return import(specifier)");
@@ -67,6 +87,8 @@ module.exports = {
67
87
  MIN_NODE_VERSION: MIN_NODE_VERSION,
68
88
  supportedNodeVersion: supportedNodeVersion,
69
89
  unsupportedNodeMessage: unsupportedNodeMessage,
90
+ applyPortableHomeEnv: applyPortableHomeEnv,
91
+ normalizePortableWindowsHome: normalizePortableWindowsHome,
70
92
  };
71
93
 
72
94
  if (require.main === module) main();