@nanhara/hara 0.110.0 → 0.112.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 CHANGED
@@ -5,6 +5,30 @@ All notable changes to `@nanhara/hara`.
5
5
  > Versioning (pre-1.0, SemVer-style): the **minor** (middle) number bumps for a **new feature**; the
6
6
  > **patch** (last) number bumps for **optimizations/fixes of existing features**.
7
7
 
8
+ ## 0.112.0 — /jobs: see (and manage) what's running in the background
9
+
10
+ - **`/jobs` — a user-facing view of the agent's background shell jobs** (dev servers, watchers, long
11
+ builds started via `bash {background:true}`). hara already tracked these for the *agent* (the `job`
12
+ tool), but the *user* had no way to glance and see "what's running back there" — the way codex and
13
+ Claude Code surface it. Now: `/jobs` lists them (id · status · age · command), `/jobs tail <id>`
14
+ shows recent output, `/jobs kill <id>` stops one. Works in the TUI and the readline REPL.
15
+ - **Status row shows a `⚙ N bg` indicator** when background jobs are running, so a preview server /
16
+ watcher humming along is visible at a glance (live while working; `/jobs` is the on-demand truth).
17
+
18
+ ## 0.111.0 — an interactive /model picker: ↑↓ a model, ←→ its thinking
19
+
20
+ - **`/model` (no argument) now opens an interactive picker** built on the provider registry — the
21
+ "one key, many models" flow. It pulls the endpoint's **live model list** (`GET /models`; a coding-plan
22
+ key exposes ~10: Qwen / GLM / Kimi / MiniMax / …), and you drive it with the arrow keys:
23
+ - **↑↓** move through the models,
24
+ - **←→** set the **thinking level** for this endpoint — the levels come from the registry's reasoning
25
+ style, so a DashScope/Ollama endpoint shows `off / on` (the real speedup toggle) while an
26
+ OpenAI/Anthropic one shows `off / low / medium / high`,
27
+ - **⏎** applies (switches the model, sets the dial, rebuilds the provider, persists to the session),
28
+ **esc** cancels.
29
+ Endpoints that don't enumerate models still let you set the thinking level (and `/model <id>` still
30
+ switches directly, as before). TUI only; the readline REPL keeps the text form.
31
+
8
32
  ## 0.110.0 — a provider registry: one key, many platforms, the right wire + thinking control for each
9
33
 
10
34
  - **hara now speaks each platform its own way, chosen from a data-driven registry (a dictionary), not
package/dist/index.js CHANGED
@@ -43,6 +43,22 @@ import { EXPLORE_SYSTEM } from "./tools/agent.js";
43
43
  import { createAnthropicProvider } from "./providers/anthropic.js";
44
44
  import { createOpenAIProvider } from "./providers/openai.js";
45
45
  import { resolvePlatform } from "./providers/registry.js";
46
+ import { listModels } from "./providers/models.js";
47
+ import { listJobs, tailJob, killJob } from "./exec/jobs.js";
48
+ /** Render the background-job list for /jobs (user-facing view of what the agent has running in the
49
+ * background — dev servers, watchers, long tasks). Mirrors codex/Claude-Code process visibility. */
50
+ function renderBgJobs() {
51
+ const js = listJobs();
52
+ if (!js.length)
53
+ return "(no background jobs — the agent starts them with bash {background:true})";
54
+ const age = (ms) => (ms < 60_000 ? `${Math.round(ms / 1000)}s` : `${Math.round(ms / 60_000)}m`);
55
+ const rows = js.map((j) => {
56
+ const st = j.status === "running" ? "▶ running" : j.status === "killed" ? "✕ killed" : `● exited${j.code != null ? ` (${j.code})` : ""}`;
57
+ const cmd = j.command.length > 64 ? j.command.slice(0, 64) + "…" : j.command;
58
+ return ` ${j.id} ${st} ${age(j.ageMs).padStart(4)} ${cmd}`;
59
+ });
60
+ return `Background jobs — /jobs tail <id> · /jobs kill <id>:\n${rows.join("\n")}`;
61
+ }
46
62
  import { qwenDeviceLogin, getValidQwenAuth } from "./providers/qwen-oauth.js";
47
63
  import { loadAgentsMd, hasAgentsMd, INIT_PROMPT, findProjectRoot } from "./context/agents-md.js";
48
64
  import { getEmbedder } from "./search/embed.js";
@@ -2516,6 +2532,14 @@ program.action(async (opts) => {
2516
2532
  },
2517
2533
  },
2518
2534
  { name: "usage", desc: "show token usage this session", run: () => void out(statusLine(cfg.model, stats.input, stats.output) + "\n") },
2535
+ { name: "jobs", desc: "list/tail/kill background shell jobs (dev servers, watchers)", run: (a) => {
2536
+ const [sub, jid] = (a || "").trim().split(/\s+/);
2537
+ if (sub === "kill" && jid)
2538
+ return void out((killJob(jid) ? `✕ killed ${jid}` : `no running job ${jid}`) + "\n");
2539
+ if (sub === "tail" && jid)
2540
+ return void out((tailJob(jid) ?? `no job ${jid}`) + "\n");
2541
+ out(renderBgJobs() + "\n");
2542
+ } },
2519
2543
  { name: "doctor", desc: "check your hara setup", run: () => void out(runDoctor(cfg) + "\n") },
2520
2544
  {
2521
2545
  name: "roles",
@@ -2877,24 +2901,28 @@ program.action(async (opts) => {
2877
2901
  const force = parts.some((p) => p === "--force" || p === "all" || p === "-f");
2878
2902
  const id = parts.find((p) => p !== "--force" && p !== "all" && p !== "-f");
2879
2903
  if (!id) {
2880
- const __force = isSessionForceModel();
2881
- const __lines = [`model: ${cfg.provider}:${cfg.model}`];
2882
- if (meta.model && meta.model !== cfg.model) {
2883
- __lines.push(`session pinned: ${meta.model} (cfg drift — /model ${meta.model} to re-pin)`);
2884
- }
2885
- else {
2886
- __lines.push(`session pinned: ${meta.model || "(none)"}${__force ? " · forced (all roles use session model)" : ""}`);
2904
+ // Bare /model → the interactive picker: the endpoint's live model list (↑↓) + its thinking
2905
+ // level (←→, per the registry's reasoning style). Falls back to typing an id if the endpoint
2906
+ // doesn't enumerate models.
2907
+ const bURL = cfg.baseURL ?? providerDefaultBaseURL(cfg.provider);
2908
+ const models = await listModels(bURL, cfg.apiKey ?? "");
2909
+ const style = resolvePlatform(cfg.provider, bURL).reasoning;
2910
+ const chosen = await h.pickModel({ models, style, current: cfg.model, effort: cfg.reasoningEffort });
2911
+ if (!chosen)
2912
+ return; // esc — no change
2913
+ if (chosen.model) {
2914
+ cfg.model = chosen.model;
2915
+ meta.model = chosen.model;
2887
2916
  }
2888
- const __roles = loadRoles(cwd);
2889
- if (__roles.length) {
2890
- __lines.push("roles:");
2891
- for (const r of __roles) {
2892
- const eff = __force ? cfg.model : (r.model || cfg.model);
2893
- const tag = __force && r.model && r.model !== cfg.model ? " (overridden by --force)" : r.model ? " (role pin)" : " (session)";
2894
- __lines.push(` ${r.id}: ${eff}${tag}`);
2895
- }
2896
- }
2897
- return void h.sink.notice(__lines.join("\n"));
2917
+ cfg.reasoningEffort = chosen.effort;
2918
+ visionProvider = undefined;
2919
+ remindedVision = false;
2920
+ const p2 = await buildProvider(cfg);
2921
+ if (!p2)
2922
+ return void h.sink.notice("(could not rebuild provider)");
2923
+ provider = p2;
2924
+ saveSession(meta, history);
2925
+ return void h.sink.notice(`(model → ${cfg.provider}:${cfg.model} · thinking ${chosen.effort ?? "default"})`);
2898
2926
  }
2899
2927
  cfg.model = id;
2900
2928
  meta.model = id;
@@ -2985,6 +3013,14 @@ program.action(async (opts) => {
2985
3013
  }
2986
3014
  if (nm === "usage")
2987
3015
  return void h.sink.notice(`tokens — ↑${stats.input} ↓${stats.output}`);
3016
+ if (nm === "jobs") {
3017
+ const [sub, jid] = (arg || "").trim().split(/\s+/);
3018
+ if (sub === "kill" && jid)
3019
+ return void h.sink.notice(killJob(jid) ? `✕ killed ${jid}` : `no running job ${jid}`);
3020
+ if (sub === "tail" && jid)
3021
+ return void h.sink.notice(tailJob(jid) ?? `no job ${jid}`);
3022
+ return void h.sink.notice(renderBgJobs());
3023
+ }
2988
3024
  if (nm === "doctor")
2989
3025
  return void h.sink.notice(runDoctor(cfg).replace(/\[[0-9;]*m/g, ""));
2990
3026
  if (nm === "vision")
@@ -0,0 +1,21 @@
1
+ // Model discovery — "what can this key run?" A coding-plan / OpenAI-compatible key usually exposes many
2
+ // models (Qwen, GLM, Kimi, …) via `GET {baseURL}/models`; the /model picker lists them so you switch by
3
+ // arrow keys, not by memorizing ids. Best-effort: many endpoints don't implement it → [] and the picker
4
+ // falls back to typing an id. `fetchImpl` is injected so this stays pure/testable.
5
+ export async function listModels(baseURL, apiKey, fetchImpl = fetch) {
6
+ if (!baseURL)
7
+ return []; // SDK-default hosts (anthropic/openai) — no custom endpoint to enumerate
8
+ try {
9
+ const url = baseURL.replace(/\/+$/, "") + "/models";
10
+ const r = await fetchImpl(url, { headers: { Authorization: `Bearer ${apiKey}` } });
11
+ if (!r.ok)
12
+ return [];
13
+ const j = (await r.json());
14
+ const ids = (j?.data ?? []).map((m) => m?.id).filter((x) => typeof x === "string" && x.length > 0);
15
+ // Stable order + de-dup so the picker list doesn't jump around between opens.
16
+ return [...new Set(ids)].sort((a, b) => a.localeCompare(b));
17
+ }
18
+ catch {
19
+ return [];
20
+ }
21
+ }
package/dist/tui/App.js CHANGED
@@ -18,6 +18,8 @@ import { accent } from "./theme.js";
18
18
  import { renderMarkdown } from "../md.js";
19
19
  import { clearTodos, currentTodos, onTodosChange } from "../tools/todo.js";
20
20
  import { onTurnPhase, turnPhase } from "../agent/phase.js";
21
+ import { listJobs } from "../exec/jobs.js";
22
+ import { ModelPicker } from "./model-picker.js";
21
23
  let _id = 0;
22
24
  const nid = () => ++_id;
23
25
  const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
@@ -267,14 +269,19 @@ function StatusRow({ working, todos, queued }) {
267
269
  const id = setInterval(() => setFrame((x) => x + 1), SPINNER_FRAME_MS);
268
270
  return () => clearInterval(id);
269
271
  }, [working]);
272
+ // Background-job indicator — so the user can SEE what's running in the background (a preview server, a
273
+ // watcher) without asking. Live while working (spinner ticks re-render); best-effort at idle (/jobs is
274
+ // the authoritative on-demand view). Re-read each render.
275
+ const bg = listJobs().filter((j) => j.status === "running").length;
276
+ const bgTag = bg ? ` · ⚙ ${bg} bg (/jobs)` : "";
270
277
  if (!working) {
271
- return (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: ` ${IDLE_HINTS}` }) }));
278
+ return (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: ` ${IDLE_HINTS}${bgTag}` }) }));
272
279
  }
273
280
  const frames = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏";
274
281
  const elapsedSec = Math.floor((Date.now() - startRef.current) / 1000);
275
282
  // Pre-first-token honesty (codex-parity): "waiting for the model" reads very differently from a
276
283
  // generic "working" when the network is slow — the user knows the request is out, not dead.
277
- const verb = phase === "waiting" ? `waiting for the model… ${elapsedSec}s · esc to interrupt` : spinnerVerb(todos, elapsedSec);
284
+ const verb = (phase === "waiting" ? `waiting for the model… ${elapsedSec}s · esc to interrupt` : spinnerVerb(todos, elapsedSec)) + bgTag;
278
285
  return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "yellow", children: frames[frame % frames.length] }), _jsx(Text, { dimColor: true, children: ` ${verb} · ⏎ queues${queued ? ` (${queued})` : ""}` })] }));
279
286
  }
280
287
  // Short per-mode descriptions for the ONE-ROW mode line (the old two-row ModeBar's long sentences
@@ -344,6 +351,7 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
344
351
  const [askText, setAskText] = useState(null);
345
352
  const [reasoningOpen, setReasoningOpen] = useState(false);
346
353
  const [showTranscript, setShowTranscript] = useState(false); // Ctrl+T full-transcript overlay
354
+ const [picker, setPicker] = useState(null); // /model picker overlay
347
355
  const [modeSelector, setModeSelector] = useState(false); // transient approval selector: shift+tab pops it, auto-hides
348
356
  const modeSelectorTimerRef = useRef(null);
349
357
  // Live checklist mirror: TodoPanel reads this, and `Working` derives its spinner verb from the
@@ -515,13 +523,14 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
515
523
  return askTextFn(question);
516
524
  };
517
525
  const setApprovalFn = (m) => setStatus((s) => ({ ...s, approval: m }));
526
+ const pickModelFn = (o) => new Promise((resolve) => setPicker({ ...o, resolve }));
518
527
  // Enter the conversation flow INSTANTLY: yield one macrotask so ink paints the committed message +
519
528
  // cleared input + spinner BEFORE the turn's synchronous prep runs (reading @-files, base64-encoding
520
529
  // images) and before the model's slow first token. Without this, that sync prep blocks ink's flush,
521
530
  // so pressing Enter leaves the message stuck in the input box for seconds ("回车一直不动"). One tick.
522
531
  await new Promise((resolve) => setTimeout(resolve, 0));
523
532
  try {
524
- await onSubmit(t, { sink, confirm: confirmFn, select: selectFn, ask: askFn, setApproval: setApprovalFn, signal: ctrl.signal, exit, approval: statusRef.current.approval, drainQueue }, images);
533
+ await onSubmit(t, { sink, confirm: confirmFn, select: selectFn, ask: askFn, pickModel: pickModelFn, setApproval: setApprovalFn, signal: ctrl.signal, exit, approval: statusRef.current.approval, drainQueue }, images);
525
534
  }
526
535
  catch (e) {
527
536
  pushCurrent("notice", `error: ${e instanceof Error ? e.message : String(e)}`);
@@ -563,6 +572,8 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
563
572
  return setShowTranscript((x) => !x); // open/close the full-transcript overlay
564
573
  if (showTranscript)
565
574
  return; // while open, the overlay's own useInput owns every key (scroll / esc)
575
+ if (picker)
576
+ return; // the /model picker overlay owns input (↑↓ model, ←→ thinking, ⏎, esc) while open
566
577
  // Free-text question awaiting an answer: Esc cancels (empty answer); all other keys belong to the InputBox.
567
578
  if (askText) {
568
579
  if (key.escape) {
@@ -624,5 +635,13 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
624
635
  });
625
636
  if (showTranscript)
626
637
  return _jsx(Transcript, { items: [...history, ...current], onClose: () => setShowTranscript(false) });
627
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: header ? [{ id: -1, kind: "notice", text: "" }, ...history] : history, children: (item) => (item.id === -1 ? _jsx(HeaderCard, { ...header }, "hdr") : _jsx(Block, { item: item }, item.id)) }), current.map((item) => (_jsx(Block, { item: item, open: reasoningOpen, liveRows: liveRows }, item.id))), _jsx(TodoPanel, { todos: todos }), 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 })] }));
638
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: header ? [{ id: -1, kind: "notice", text: "" }, ...history] : history, children: (item) => (item.id === -1 ? _jsx(HeaderCard, { ...header }, "hdr") : _jsx(Block, { item: item }, item.id)) }), current.map((item) => (_jsx(Block, { item: item, open: reasoningOpen, liveRows: liveRows }, item.id))), _jsx(TodoPanel, { todos: todos }), picker && (_jsx(ModelPicker, { models: picker.models, style: picker.style, current: picker.current, effort: picker.effort, onSelect: (model, effort) => {
639
+ const r = picker.resolve;
640
+ setPicker(null);
641
+ r({ model, effort });
642
+ }, onCancel: () => {
643
+ const r = picker.resolve;
644
+ setPicker(null);
645
+ r(null);
646
+ } })), 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 })] }));
628
647
  }
@@ -0,0 +1,62 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // The /model picker: ↑↓ choose a model (from the key's live /models list), ←→ set the thinking level for
3
+ // this endpoint, ⏎ apply, esc cancel. Built on the provider registry — the reasoning STYLE (from the
4
+ // endpoint) decides which levels ←→ offers. The nav math is pure + exported for tests; the component is a
5
+ // thin ink shell over it (like the Transcript overlay).
6
+ import { Box, Text, useInput } from "ink";
7
+ import { useState } from "react";
8
+ /** The levels ←→ cycles for a style. Binary thinking toggles (DashScope enable_thinking, Ollama think)
9
+ * 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")
12
+ return [];
13
+ if (style === "enable_thinking" || style === "ollama_think")
14
+ return ["off", "high"]; // "high" renders as "on"
15
+ return ["off", "low", "medium", "high"];
16
+ }
17
+ /** Label a level for display — binary styles read as on/off, graded ones as the level name. */
18
+ export function levelLabel(style, e) {
19
+ if (style === "enable_thinking" || style === "ollama_think")
20
+ return e === "off" ? "off" : "on";
21
+ return String(e);
22
+ }
23
+ /** Pure navigation: ↑↓ moves through models (wrapping); ←→ cycles the thinking level for the endpoint's
24
+ * style. No-ops when there are no models / the style has no levels. Exported for tests. */
25
+ export function movePicker(s, key, modelCount, style) {
26
+ if (key === "up" || key === "down") {
27
+ if (modelCount <= 0)
28
+ return s;
29
+ const d = key === "down" ? 1 : -1;
30
+ return { ...s, modelIdx: (s.modelIdx + d + modelCount) % modelCount };
31
+ }
32
+ const levels = levelsFor(style);
33
+ if (!levels.length)
34
+ return s;
35
+ const cur = Math.max(0, levels.indexOf(s.effort));
36
+ const d = key === "right" ? 1 : -1;
37
+ return { ...s, effort: levels[(cur + d + levels.length) % levels.length] };
38
+ }
39
+ export function ModelPicker({ models, style, current, effort, onSelect, onCancel, }) {
40
+ const start = Math.max(0, models.indexOf(current ?? ""));
41
+ const [s, setS] = useState({ modelIdx: start, effort });
42
+ useInput((_input, key) => {
43
+ if (key.escape)
44
+ return onCancel();
45
+ if (key.return)
46
+ return onSelect(models[s.modelIdx] ?? current ?? "", s.effort);
47
+ if (key.upArrow)
48
+ setS((p) => movePicker(p, "up", models.length, style));
49
+ else if (key.downArrow)
50
+ setS((p) => movePicker(p, "down", models.length, style));
51
+ else if (key.leftArrow)
52
+ setS((p) => movePicker(p, "left", models.length, style));
53
+ else if (key.rightArrow)
54
+ setS((p) => movePicker(p, "right", models.length, style));
55
+ });
56
+ const hasLevels = levelsFor(style).length > 0;
57
+ const dial = hasLevels ? `thinking ◀ ${levelLabel(style, s.effort)} ▶` : "";
58
+ 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) => {
59
+ const on = i === s.modelIdx;
60
+ return (_jsxs(Box, { children: [_jsx(Text, { color: on ? "cyan" : undefined, bold: on, children: (on ? " ❯ " : " ") + m }), on && hasLevels ? _jsx(Text, { dimColor: true, children: " " + dial }) : null] }, m));
61
+ }))] }));
62
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.110.0",
3
+ "version": "0.112.0",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "dist/index.js"