@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.
- package/CHANGELOG.md +108 -0
- package/README.md +15 -2
- package/dist/agent/limits.js +51 -0
- package/dist/agent/loop.js +367 -112
- package/dist/agent/repeat-guard.js +49 -12
- package/dist/checkpoints.js +9 -0
- package/dist/cli.js +2 -1
- package/dist/config.js +10 -2
- package/dist/context/agents-md.js +21 -2
- package/dist/context/mentions.js +84 -1
- package/dist/context/workspace-scope.js +49 -0
- package/dist/cron/deliver.js +61 -38
- package/dist/cron/runner.js +423 -65
- package/dist/cron/schedule.js +23 -7
- package/dist/cron/store.js +709 -43
- package/dist/fs-walk.js +279 -7
- package/dist/fs-write.js +9 -0
- package/dist/gateway/feishu.js +351 -64
- package/dist/gateway/flows-pending.js +31 -17
- package/dist/gateway/flows.js +306 -31
- package/dist/gateway/outbound-files.js +20 -6
- package/dist/gateway/runtime-state.js +1485 -0
- package/dist/gateway/serve.js +550 -242
- package/dist/gateway/sessions.js +3 -3
- package/dist/gateway/telegram.js +279 -29
- package/dist/gateway/tts.js +182 -38
- package/dist/hooks.js +22 -22
- package/dist/index.js +466 -158
- package/dist/memory/store.js +8 -5
- package/dist/org/planner.js +11 -6
- package/dist/org/projects.js +3 -3
- package/dist/process-identity.js +52 -0
- package/dist/providers/bounded-turn.js +42 -0
- package/dist/recall.js +69 -1
- package/dist/runtime.js +24 -0
- package/dist/sandbox.js +54 -4
- package/dist/search/embed.js +13 -2
- package/dist/search/hybrid.js +6 -3
- package/dist/search/semindex.js +87 -5
- package/dist/security/guardian.js +3 -15
- package/dist/security/permissions.js +11 -0
- package/dist/serve/server.js +70 -42
- package/dist/serve/sessions.js +4 -3
- package/dist/sync-sleep.js +46 -0
- package/dist/tools/agent.js +1 -1
- package/dist/tools/ask_user.js +5 -1
- package/dist/tools/builtin.js +5 -2
- package/dist/tools/codebase.js +67 -18
- package/dist/tools/computer.js +149 -68
- package/dist/tools/cron.js +72 -18
- package/dist/tools/edit.js +3 -2
- package/dist/tools/external_agent.js +66 -12
- package/dist/tools/memory.js +25 -7
- package/dist/tools/patch.js +11 -2
- package/dist/tools/registry.js +16 -1
- package/dist/tools/search.js +68 -9
- package/dist/tools/send.js +1 -1
- package/dist/tools/skill.js +1 -1
- package/dist/tools/task.js +3 -3
- package/dist/tools/web.js +43 -13
- package/dist/tui/App.js +93 -25
- package/dist/vision.js +5 -6
- package/package.json +2 -2
- package/runtime-bootstrap.cjs +22 -0
package/dist/tools/skill.js
CHANGED
|
@@ -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
|
},
|
package/dist/tools/task.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
353
|
-
|
|
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
|
|
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
|
-
|
|
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({
|
|
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) =>
|
|
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
|
|
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
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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.
|
|
604
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
},
|
package/runtime-bootstrap.cjs
CHANGED
|
@@ -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();
|