@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.
Files changed (46) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.md +22 -8
  3. package/SECURITY.md +6 -4
  4. package/dist/agent/limits.js +2 -2
  5. package/dist/agent/loop.js +413 -77
  6. package/dist/config.js +172 -4
  7. package/dist/gateway/flows-pending.js +14 -36
  8. package/dist/gateway/wecom.js +9 -1
  9. package/dist/index.js +439 -227
  10. package/dist/mcp/client.js +73 -6
  11. package/dist/providers/bounded-turn.js +6 -0
  12. package/dist/providers/factory.js +54 -0
  13. package/dist/providers/models.js +39 -6
  14. package/dist/providers/openai.js +6 -1
  15. package/dist/providers/reasoning.js +12 -0
  16. package/dist/providers/registry.js +1 -0
  17. package/dist/providers/target.js +98 -0
  18. package/dist/security/guardian.js +6 -1
  19. package/dist/security/private-state.js +1 -1
  20. package/dist/security/secrets.js +19 -0
  21. package/dist/serve/protocol.js +7 -2
  22. package/dist/serve/server.js +157 -40
  23. package/dist/serve/sessions.js +11 -2
  24. package/dist/session/operation-drain.js +45 -0
  25. package/dist/session/task.js +78 -0
  26. package/dist/statusbar.js +15 -2
  27. package/dist/tools/agent.js +1 -0
  28. package/dist/tools/all.js +1 -0
  29. package/dist/tools/ask_user.js +8 -3
  30. package/dist/tools/builtin.js +22 -1
  31. package/dist/tools/codebase.js +1 -0
  32. package/dist/tools/computer.js +1 -0
  33. package/dist/tools/cron.js +10 -0
  34. package/dist/tools/external_agent.js +1 -0
  35. package/dist/tools/memory.js +2 -0
  36. package/dist/tools/registry.js +95 -4
  37. package/dist/tools/result-limit.js +172 -2
  38. package/dist/tools/runtime.js +67 -0
  39. package/dist/tools/search.js +3 -0
  40. package/dist/tools/skill.js +1 -0
  41. package/dist/tools/task.js +5 -0
  42. package/dist/tools/todo.js +3 -2
  43. package/dist/tools/web.js +4 -0
  44. package/dist/tui/App.js +49 -17
  45. package/dist/tui/model-picker.js +11 -8
  46. 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 === "waiting" ? `waiting for the model… ${elapsedSec}s · esc to interrupt` : spinnerVerb(todos, elapsedSec)) + bgTag;
293
- return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "yellow", children: frames[frame % frames.length] }), _jsx(Text, { dimColor: true, children: ` ${verb} · steers · /next queues${queued ? ` (${queued})` : ""}` })] }));
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 === "next");
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
- const behindBarrier = queueRef.current.some((item) => item.mode === "next");
509
- if (next || behindBarrier) {
510
- queueRef.current.push({ mode: "next", line: next?.[1]?.trim() || line, images });
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
- activeTurnRef.current = interaction.turnId;
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
- while (end < batch.length && batch[end].mode === mode)
673
- end++;
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 ? _jsx(ModeLine, { approval: status.approval }) : _jsx(StatusRow, { working: working, todos: todos, queued: pool.length }), _jsx(InputBox, { status: status, cwd: cwd, model: model, route: header?.routeHost, isActive: !prompt, vim: vim, onSubmit: handleSubmit, onClipboardImage: onClipboardImage })] }));
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
  }
@@ -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 === "none")
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(models[s.modelIdx] ?? current ?? "", s.effort);
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 = levelsFor(style).length > 0;
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.125.1",
3
+ "version": "0.126.0",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "runtime-bootstrap.cjs"