@nanhara/hara 0.125.1 → 0.126.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +76 -0
- package/README.md +22 -8
- package/SECURITY.md +6 -4
- package/dist/agent/limits.js +2 -2
- package/dist/agent/loop.js +413 -77
- package/dist/config.js +172 -4
- package/dist/gateway/flows-pending.js +14 -36
- package/dist/gateway/wecom.js +9 -1
- package/dist/index.js +439 -227
- package/dist/mcp/client.js +73 -6
- package/dist/providers/bounded-turn.js +6 -0
- package/dist/providers/factory.js +54 -0
- package/dist/providers/models.js +39 -6
- package/dist/providers/openai.js +6 -1
- package/dist/providers/reasoning.js +12 -0
- package/dist/providers/registry.js +1 -0
- package/dist/providers/target.js +98 -0
- package/dist/security/guardian.js +6 -1
- package/dist/security/private-state.js +1 -1
- package/dist/security/secrets.js +19 -0
- package/dist/serve/protocol.js +7 -2
- package/dist/serve/server.js +157 -40
- package/dist/serve/sessions.js +11 -2
- package/dist/session/operation-drain.js +45 -0
- package/dist/session/task.js +78 -0
- package/dist/statusbar.js +15 -2
- package/dist/tools/agent.js +1 -0
- package/dist/tools/all.js +1 -0
- package/dist/tools/ask_user.js +8 -3
- package/dist/tools/builtin.js +22 -1
- package/dist/tools/codebase.js +1 -0
- package/dist/tools/computer.js +1 -0
- package/dist/tools/cron.js +10 -0
- package/dist/tools/external_agent.js +1 -0
- package/dist/tools/memory.js +2 -0
- package/dist/tools/registry.js +95 -4
- package/dist/tools/result-limit.js +172 -2
- package/dist/tools/runtime.js +67 -0
- package/dist/tools/search.js +3 -0
- package/dist/tools/skill.js +1 -0
- package/dist/tools/task.js +5 -0
- package/dist/tools/todo.js +3 -2
- package/dist/tools/web.js +4 -0
- package/dist/tui/App.js +49 -17
- package/dist/tui/model-picker.js +11 -8
- package/package.json +1 -1
package/dist/tui/App.js
CHANGED
|
@@ -21,6 +21,16 @@ import { onTurnPhase, turnPhase } from "../agent/phase.js";
|
|
|
21
21
|
import { listJobs, onJobsChange } from "../exec/jobs.js";
|
|
22
22
|
import { ModelPicker } from "./model-picker.js";
|
|
23
23
|
import { newSteerInteraction, newTurnInteraction, requestsTaskContinuation } from "../session/task.js";
|
|
24
|
+
/** Composer work and executable task work are different states. Slash controls (for example `/model`)
|
|
25
|
+
* may keep the TUI busy while opening a picker or doing local bookkeeping, but there is no model task to
|
|
26
|
+
* steer. `/continue` and user-invocable skill commands are executable Agent turns. */
|
|
27
|
+
export function submissionCanBeSteered(line, agentSlashCommands = []) {
|
|
28
|
+
const value = line.trim();
|
|
29
|
+
const slash = /^\/([a-z][\w-]*)(?:\s|$)/i.exec(value);
|
|
30
|
+
if (!slash)
|
|
31
|
+
return true;
|
|
32
|
+
return slash[1].toLowerCase() === "continue" || agentSlashCommands.includes(slash[1]);
|
|
33
|
+
}
|
|
24
34
|
let _id = 0;
|
|
25
35
|
const nid = () => ++_id;
|
|
26
36
|
const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
|
|
@@ -265,7 +275,7 @@ export function spinnerVerb(list, elapsedSec) {
|
|
|
265
275
|
// wall-clock start, so the "Ns" text stays exact and stable.
|
|
266
276
|
const SPINNER_FRAME_MS = 125;
|
|
267
277
|
const IDLE_HINTS = "⏎ send · @ file · ctrl+v image · ctrl+t transcript · shift+tab mode";
|
|
268
|
-
function StatusRow({ working, todos, queued }) {
|
|
278
|
+
function StatusRow({ working, steerable, todos, queued }) {
|
|
269
279
|
const [frame, setFrame] = useState(0);
|
|
270
280
|
const [phase, setPhase] = useState(() => turnPhase());
|
|
271
281
|
const startRef = useRef(Date.now());
|
|
@@ -289,8 +299,12 @@ function StatusRow({ working, todos, queued }) {
|
|
|
289
299
|
const elapsedSec = Math.floor((Date.now() - startRef.current) / 1000);
|
|
290
300
|
// Pre-first-token honesty (codex-parity): "waiting for the model" reads very differently from a
|
|
291
301
|
// generic "working" when the network is slow — the user knows the request is out, not dead.
|
|
292
|
-
const verb = (phase === "
|
|
293
|
-
|
|
302
|
+
const verb = (phase === "awaiting_user"
|
|
303
|
+
? "waiting for your answer · task timer paused · esc to cancel"
|
|
304
|
+
: phase === "waiting"
|
|
305
|
+
? `waiting for the model… ${elapsedSec}s · esc to interrupt`
|
|
306
|
+
: spinnerVerb(todos, elapsedSec)) + bgTag;
|
|
307
|
+
return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "yellow", children: frames[frame % frames.length] }), _jsx(Text, { dimColor: true, children: ` ${verb} · ${steerable ? "⏎ steers · /next queues" : "⏎ queues next"}${queued ? ` (${queued})` : ""}` })] }));
|
|
294
308
|
}
|
|
295
309
|
// Short per-mode descriptions for the ONE-ROW mode line (the old two-row ModeBar's long sentences
|
|
296
310
|
// don't fit inline). Full behavior is documented in /help; this line is a switching aid, not a manual.
|
|
@@ -341,7 +355,7 @@ const TodoPanel = memo(function TodoPanel({ todos }) {
|
|
|
341
355
|
return (_jsx(Text, { color: inProg ? accent() : undefined, bold: inProg, dimColor: done, children: ` ${TODO_MARK[t.status]} ${t.text}` }, i));
|
|
342
356
|
}), hiddenSummary ? _jsx(Text, { dimColor: true, children: hiddenSummary }) : null] }));
|
|
343
357
|
});
|
|
344
|
-
export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval, onClipboardImage, vim, visionNotice }) {
|
|
358
|
+
export function App({ initialStatus, model, cwd, header, onSubmit, agentSlashCommands = [], cycleApproval, onClipboardImage, vim, visionNotice, }) {
|
|
345
359
|
const { exit } = useApp();
|
|
346
360
|
const { stdout: termOut } = useStdout();
|
|
347
361
|
// Live tail budget: terminal rows minus the rest of the dynamic chrome (todo panel ≤10, status slot,
|
|
@@ -462,12 +476,12 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
462
476
|
const drainQueue = useCallback(() => {
|
|
463
477
|
if (!queueRef.current.length)
|
|
464
478
|
return [];
|
|
465
|
-
const barrier = queueRef.current.findIndex((item) => item.mode
|
|
479
|
+
const barrier = queueRef.current.findIndex((item) => item.mode !== "steer");
|
|
466
480
|
const batch = (barrier < 0 ? queueRef.current : queueRef.current.slice(0, barrier));
|
|
467
481
|
if (!batch.length)
|
|
468
482
|
return [];
|
|
469
483
|
queueRef.current = barrier < 0 ? [] : queueRef.current.slice(barrier);
|
|
470
|
-
setPool(queueRef.current.map((item) => `${item.mode === "next" ? "next: " : ""}${item.line.trim() || "🖼 (image)"}`));
|
|
484
|
+
setPool(queueRef.current.map((item) => `${item.mode === "next" ? "next: " : item.mode === "control" ? "control: " : ""}${item.line.trim() || "🖼 (image)"}`));
|
|
471
485
|
if (batch.some((b) => b.images?.length))
|
|
472
486
|
noteVisionIfNeeded();
|
|
473
487
|
for (const b of batch)
|
|
@@ -498,25 +512,37 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
498
512
|
// Enter steers the live turn; `/next …` creates an explicit queue barrier for a separate next task.
|
|
499
513
|
// Once such a barrier exists, later type-ahead stays behind it instead of leaking backward.
|
|
500
514
|
const expectedTurnId = activeTurnRef.current;
|
|
501
|
-
if (!expectedTurnId)
|
|
502
|
-
return;
|
|
503
515
|
const next = /^\/(?:next|queue)(?:\s+([\s\S]+))?$/.exec(t);
|
|
504
516
|
if (next && !next[1]?.trim() && !images?.length) {
|
|
505
517
|
pushCurrent("notice", "usage: /next <message> (queues a separate task after the current turn)");
|
|
506
518
|
return;
|
|
507
519
|
}
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
520
|
+
// A control command can be "working" without owning a TaskExecution. Input typed during that UI
|
|
521
|
+
// operation is a real next turn, never a steer and never disposable. The same next barrier keeps
|
|
522
|
+
// later messages ordered behind it.
|
|
523
|
+
const queuedLine = next?.[1]?.trim() || line;
|
|
524
|
+
const queuedControl = !submissionCanBeSteered(queuedLine, agentSlashCommands);
|
|
525
|
+
const behindBarrier = !expectedTurnId ||
|
|
526
|
+
queueRef.current.some((item) => item.mode !== "steer");
|
|
527
|
+
if (queuedControl) {
|
|
528
|
+
queueRef.current.push({ mode: "control", line: queuedLine, images });
|
|
529
|
+
}
|
|
530
|
+
else if (next || behindBarrier) {
|
|
531
|
+
queueRef.current.push({ mode: "next", line: queuedLine, images });
|
|
511
532
|
}
|
|
512
533
|
else {
|
|
513
534
|
queueRef.current.push({ mode: "steer", line, images, expectedTurnId });
|
|
514
535
|
}
|
|
515
|
-
setPool(queueRef.current.map((item) => `${item.mode === "next" ? "next: " : ""}${item.line.trim() || "🖼 (image)"}`));
|
|
536
|
+
setPool(queueRef.current.map((item) => `${item.mode === "next" ? "next: " : item.mode === "control" ? "control: " : ""}${item.line.trim() || "🖼 (image)"}`));
|
|
516
537
|
return;
|
|
517
538
|
}
|
|
518
539
|
const interaction = forcedInteraction ?? newTurnInteraction();
|
|
519
|
-
|
|
540
|
+
// Only a model task may receive type-ahead steering. A slash control still sets `working` so its
|
|
541
|
+
// picker/output is serialized, but leaves this ref empty; submissions then queue as ordinary turns.
|
|
542
|
+
activeTurnRef.current =
|
|
543
|
+
interaction.kind === "steer" || submissionCanBeSteered(t, agentSlashCommands)
|
|
544
|
+
? interaction.turnId
|
|
545
|
+
: null;
|
|
520
546
|
// Fold the previous turn's checklist NOW, at the natural boundary (a new task begins). The old
|
|
521
547
|
// 30s-idle timer yanked the input box UP by the panel's height while the user was reading/typing
|
|
522
548
|
// (anti-bob); folding on submit means the shrink coincides with the user's own action.
|
|
@@ -655,7 +681,7 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
655
681
|
ctrlRef.current = null;
|
|
656
682
|
if (activeTurnRef.current === interaction.turnId)
|
|
657
683
|
activeTurnRef.current = null;
|
|
658
|
-
}, [working, prompt, askText, onSubmit, pushCurrent, model, exit, drainQueue, noteVisionIfNeeded]);
|
|
684
|
+
}, [working, prompt, askText, onSubmit, agentSlashCommands, pushCurrent, model, exit, drainQueue, noteVisionIfNeeded]);
|
|
659
685
|
// A message can arrive after runAgent's final pending-input drain but before the view flips to idle. Late
|
|
660
686
|
// steer groups retain expectedTurnId; explicit `/next` groups start ordinary task turns in FIFO order.
|
|
661
687
|
useEffect(() => {
|
|
@@ -669,8 +695,12 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
669
695
|
for (let at = 0; at < batch.length;) {
|
|
670
696
|
const mode = batch[at].mode;
|
|
671
697
|
let end = at + 1;
|
|
672
|
-
|
|
673
|
-
|
|
698
|
+
// Controls are barriers and always execute one-at-a-time. Task steering/next messages may still
|
|
699
|
+
// coalesce within their own adjacent group to preserve the established type-ahead behavior.
|
|
700
|
+
if (mode !== "control") {
|
|
701
|
+
while (end < batch.length && batch[end].mode === mode)
|
|
702
|
+
end++;
|
|
703
|
+
}
|
|
674
704
|
const group = batch.slice(at, end);
|
|
675
705
|
const line = group.map((item) => item.line).join("\n\n");
|
|
676
706
|
const images = group.flatMap((item) => item.images ?? []);
|
|
@@ -755,5 +785,7 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
755
785
|
const r = picker.resolve;
|
|
756
786
|
setPicker(null);
|
|
757
787
|
r(null);
|
|
758
|
-
} })), prompt && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: ` ${stripAnsi(prompt.title)}` }), prompt.options.map((o, i) => (_jsx(Text, { color: i === promptSel ? "cyan" : undefined, bold: i === promptSel, children: (i === promptSel ? " ❯ " : " ") + `${i + 1}. ` + o.label }, i))), _jsx(Text, { dimColor: true, children: ` ↑↓ or 1–${prompt.options.length} to choose · Enter · Esc cancels` })] })), askText && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: ` ? ${stripAnsi(askText.title)}` }), _jsx(Text, { dimColor: true, children: " type your answer below · Enter to send · Esc cancels" })] })), pool.length > 0 && !prompt && !askText && (_jsx(Box, { flexDirection: "column", children: pool.map((l, i) => (_jsx(Text, { color: accent(), children: ` › ${l.length > 72 ? l.slice(0, 72) + "…" : l}` }, i))) })), modeSelector
|
|
788
|
+
} })), prompt && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: ` ${stripAnsi(prompt.title)}` }), prompt.options.map((o, i) => (_jsx(Text, { color: i === promptSel ? "cyan" : undefined, bold: i === promptSel, children: (i === promptSel ? " ❯ " : " ") + `${i + 1}. ` + o.label }, i))), _jsx(Text, { dimColor: true, children: ` ↑↓ or 1–${prompt.options.length} to choose · Enter · Esc cancels` })] })), askText && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: ` ? ${stripAnsi(askText.title)}` }), _jsx(Text, { dimColor: true, children: " type your answer below · Enter to send · Esc cancels" })] })), pool.length > 0 && !prompt && !askText && (_jsx(Box, { flexDirection: "column", children: pool.map((l, i) => (_jsx(Text, { color: accent(), children: ` › ${l.length > 72 ? l.slice(0, 72) + "…" : l}` }, i))) })), modeSelector
|
|
789
|
+
? _jsx(ModeLine, { approval: status.approval })
|
|
790
|
+
: _jsx(StatusRow, { working: working, steerable: !!activeTurnRef.current, todos: todos, queued: pool.length }), _jsx(InputBox, { status: status, cwd: cwd, model: model, route: header?.routeHost, isActive: !prompt, vim: vim, onSubmit: handleSubmit, onClipboardImage: onClipboardImage })] }));
|
|
759
791
|
}
|
package/dist/tui/model-picker.js
CHANGED
|
@@ -5,10 +5,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
5
5
|
// thin ink shell over it (like the Transcript overlay).
|
|
6
6
|
import { Box, Text, useInput } from "ink";
|
|
7
7
|
import { useState } from "react";
|
|
8
|
+
import { supportsReasoningStyle } from "../providers/reasoning.js";
|
|
8
9
|
/** The levels ←→ cycles for a style. Binary thinking toggles (DashScope enable_thinking, Ollama think)
|
|
9
10
|
* show off/on; graded styles (OpenAI/Anthropic effort/budget) show the full dial; `none` → no control. */
|
|
10
|
-
export function levelsFor(style) {
|
|
11
|
-
if (style
|
|
11
|
+
export function levelsFor(style, model = "") {
|
|
12
|
+
if (!supportsReasoningStyle(style, model))
|
|
12
13
|
return [];
|
|
13
14
|
if (style === "enable_thinking" || style === "ollama_think")
|
|
14
15
|
return ["off", "high"]; // "high" renders as "on"
|
|
@@ -24,14 +25,14 @@ export function levelLabel(style, e) {
|
|
|
24
25
|
}
|
|
25
26
|
/** Pure navigation: ↑↓ moves through models (wrapping); ←→ cycles the thinking level for the endpoint's
|
|
26
27
|
* style. No-ops when there are no models / the style has no levels. Exported for tests. */
|
|
27
|
-
export function movePicker(s, key, modelCount, style) {
|
|
28
|
+
export function movePicker(s, key, modelCount, style, model = "") {
|
|
28
29
|
if (key === "up" || key === "down") {
|
|
29
30
|
if (modelCount <= 0)
|
|
30
31
|
return s;
|
|
31
32
|
const d = key === "down" ? 1 : -1;
|
|
32
33
|
return { ...s, modelIdx: (s.modelIdx + d + modelCount) % modelCount };
|
|
33
34
|
}
|
|
34
|
-
const levels = levelsFor(style);
|
|
35
|
+
const levels = levelsFor(style, model);
|
|
35
36
|
if (!levels.length)
|
|
36
37
|
return s;
|
|
37
38
|
const cur = Math.max(0, levels.indexOf(s.effort));
|
|
@@ -41,21 +42,23 @@ export function movePicker(s, key, modelCount, style) {
|
|
|
41
42
|
export function ModelPicker({ models, style, current, effort, onSelect, onCancel, }) {
|
|
42
43
|
const start = Math.max(0, models.indexOf(current ?? ""));
|
|
43
44
|
const [s, setS] = useState({ modelIdx: start, effort });
|
|
45
|
+
const selectedModel = models[s.modelIdx] ?? current ?? "";
|
|
46
|
+
const levels = levelsFor(style, selectedModel);
|
|
44
47
|
useInput((_input, key) => {
|
|
45
48
|
if (key.escape)
|
|
46
49
|
return onCancel();
|
|
47
50
|
if (key.return)
|
|
48
|
-
return onSelect(
|
|
51
|
+
return onSelect(selectedModel, levels.length ? s.effort : undefined);
|
|
49
52
|
if (key.upArrow)
|
|
50
53
|
setS((p) => movePicker(p, "up", models.length, style));
|
|
51
54
|
else if (key.downArrow)
|
|
52
55
|
setS((p) => movePicker(p, "down", models.length, style));
|
|
53
56
|
else if (key.leftArrow)
|
|
54
|
-
setS((p) => movePicker(p, "left", models.length, style));
|
|
57
|
+
setS((p) => movePicker(p, "left", models.length, style, selectedModel));
|
|
55
58
|
else if (key.rightArrow)
|
|
56
|
-
setS((p) => movePicker(p, "right", models.length, style));
|
|
59
|
+
setS((p) => movePicker(p, "right", models.length, style, selectedModel));
|
|
57
60
|
});
|
|
58
|
-
const hasLevels =
|
|
61
|
+
const hasLevels = levels.length > 0;
|
|
59
62
|
const dial = hasLevels ? `thinking ◀ ${levelLabel(style, s.effort)} ▶` : "";
|
|
60
63
|
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: ` pick a model · ↑↓ model · ${hasLevels ? "←→ thinking · " : ""}⏎ apply · esc` }), models.length === 0 ? (_jsx(Text, { dimColor: true, children: " (this endpoint doesn't list models — use /model <id> to set one directly)" })) : (models.map((m, i) => {
|
|
61
64
|
const on = i === s.modelIdx;
|