@nanhara/hara 0.100.0 → 0.101.1

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,25 @@ 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.101.1 — the input box stops running to the top
9
+
10
+ - **Live-region overflow guard.** A long streaming answer (or a big diff) used to grow the live region
11
+ past the terminal height — at which point ink's in-place repaint breaks and the input box "runs to
12
+ the top of the screen". Live blocks now render a bounded tail window (sized to your terminal, elided
13
+ lines counted in a dim header); the FULL text lands in scrollback the moment the block finalizes,
14
+ and ctrl+t shows it live. Same treatment reasoning got in 0.99.2, now for answers and diffs — the
15
+ dynamic region can no longer outgrow the viewport, which is the invariant that kept codex stable
16
+ (line-level commits) and pushed Claude Code to a fullscreen ScrollBox.
17
+
18
+ ## 0.101.0 — startup update check
19
+
20
+ - **`hara` tells you when it's out of date.** On launch (interactive TTY only), a one-line notice —
21
+ `⬆ Update available 0.100.0 → 0.101.0 · npm i -g @nanhara/hara` — driven by a daily background
22
+ probe with a 3s timeout that NEVER delays startup (the notice always comes from the previous
23
+ probe's cache, npm update-notifier style). npmjs first, npmmirror fallback for CN networks;
24
+ offline machines fail silent and back off to daily retries. Disable with
25
+ `hara config set updateCheck false` or `HARA_UPDATE_CHECK=0`.
26
+
8
27
  ## 0.100.0 — the agent keeps its own attention: system-reminders + anti-drift compaction
9
28
 
10
29
  Distilled from a source-level study of Claude Code v1.0.33's agent internals (the Ie1/WD5 reminder
package/dist/config.js CHANGED
@@ -30,7 +30,7 @@ const PROVIDER_DEFAULTS = {
30
30
  },
31
31
  "hara-gateway": { model: "", envKey: "HARA_GATEWAY_TOKEN" }, // B-end: enrolled device → token in ~/.hara/org.json, routed by the gateway
32
32
  };
33
- export const CONFIG_KEYS = ["provider", "apiKey", "model", "baseURL", "approval", "sandbox", "theme", "evolve", "assetCapture", "computerUse", "computerApps", "visionModel", "visionBaseURL", "visionApiKey", "embedProvider", "embedModel", "embedBaseURL", "embedApiKey", "routeModel", "routeBaseURL", "routeApiKey", "guardian", "notify", "vimMode", "autoCompact", "fileCheckpoints", "fallbackModel", "fallbackBaseURL", "fallbackApiKey", "reasoningEffort"];
33
+ export const CONFIG_KEYS = ["provider", "apiKey", "model", "baseURL", "approval", "sandbox", "theme", "evolve", "assetCapture", "computerUse", "computerApps", "visionModel", "visionBaseURL", "visionApiKey", "embedProvider", "embedModel", "embedBaseURL", "embedApiKey", "routeModel", "routeBaseURL", "routeApiKey", "guardian", "notify", "vimMode", "autoCompact", "fileCheckpoints", "updateCheck", "fallbackModel", "fallbackBaseURL", "fallbackApiKey", "reasoningEffort"];
34
34
  export const REASONING_EFFORTS = ["off", "low", "medium", "high"];
35
35
  export const APPROVAL_MODES = ["suggest", "auto-edit", "full-auto"];
36
36
  export const SANDBOX_MODES = ["off", "workspace-write", "read-only"];
@@ -158,6 +158,7 @@ export function loadConfig(opts = {}) {
158
158
  const vimMode = process.env.HARA_VIM === "1" || merged.vimMode === true || merged.vimMode === "true";
159
159
  const autoCompact = !(process.env.HARA_AUTO_COMPACT === "0" || merged.autoCompact === false || merged.autoCompact === "false"); // default ON
160
160
  const fileCheckpoints = !(process.env.HARA_CHECKPOINTS === "0" || merged.fileCheckpoints === false || merged.fileCheckpoints === "false"); // default ON
161
+ const updateCheck = !(process.env.HARA_UPDATE_CHECK === "0" || merged.updateCheck === false || merged.updateCheck === "false"); // default ON
161
162
  const fallbackModel = process.env.HARA_FALLBACK_MODEL ?? merged.fallbackModel;
162
163
  const fallbackBaseURL = process.env.HARA_FALLBACK_BASE_URL ?? merged.fallbackBaseURL;
163
164
  const fallbackApiKey = process.env.HARA_FALLBACK_API_KEY ?? merged.fallbackApiKey;
@@ -165,7 +166,7 @@ export function loadConfig(opts = {}) {
165
166
  const reasoningEffort = reasoningRaw && ["off", "low", "medium", "high"].includes(reasoningRaw)
166
167
  ? reasoningRaw
167
168
  : undefined;
168
- return { provider, apiKey, model, baseURL, approval, sandbox, theme, evolve, assetCapture, computerUse, computerApps, visionModel, visionBaseURL, visionApiKey, modelVision, embedProvider, embedModel, embedBaseURL, embedApiKey, routeModel, routeBaseURL, routeApiKey, guardian, hooks, notify, vimMode, autoCompact, fileCheckpoints, fallbackModel, fallbackBaseURL, fallbackApiKey, reasoningEffort, mcpServers, cwd: process.cwd() };
169
+ return { provider, apiKey, model, baseURL, approval, sandbox, theme, evolve, assetCapture, computerUse, computerApps, visionModel, visionBaseURL, visionApiKey, modelVision, embedProvider, embedModel, embedBaseURL, embedApiKey, routeModel, routeBaseURL, routeApiKey, guardian, hooks, notify, vimMode, autoCompact, fileCheckpoints, updateCheck, fallbackModel, fallbackBaseURL, fallbackApiKey, reasoningEffort, mcpServers, cwd: process.cwd() };
169
170
  }
170
171
  export function providerEnvKey(provider) {
171
172
  return (PROVIDER_DEFAULTS[provider] ?? PROVIDER_DEFAULTS.anthropic).envKey;
package/dist/index.js CHANGED
@@ -25,6 +25,7 @@ import { loadActiveProfile, listProfiles, useProfile, addProfile, upsertProfile,
25
25
  import { loadPermissionRules, scaffoldPermissions, globalPermissionsPath, projectPermissionsPath } from "./security/permissions.js";
26
26
  import { routingProvider } from "./agent/route.js";
27
27
  import { shouldAutoCompact, COMPACT_SYSTEM } from "./agent/compact.js";
28
+ import { checkForUpdate } from "./update-check.js";
28
29
  import { formatContextReport } from "./agent/context-report.js";
29
30
  import { userTurnPreviews, rewindTo } from "./agent/rewind.js";
30
31
  import { checkpoint, listCheckpoints, restoreCheckpoint } from "./checkpoints.js";
@@ -2187,6 +2188,13 @@ program.action(async (opts) => {
2187
2188
  // interactive REPL — ink TUI by default on a real terminal; HARA_TUI=0 forces the classic readline path
2188
2189
  const useTui = stdin.isTTY && stdout.isTTY && process.env.HARA_TUI !== "0";
2189
2190
  out(c.bold(`hara ${pkg.version}`) + c.dim(` · ${cfg.provider}:${cfg.model} · ${approval}${sandbox !== "off" ? ` · sandbox:${sandbox}` : ""} · ${cwd}\n`));
2191
+ // Startup update notice — cache-driven (a previous session's background probe), so it costs zero
2192
+ // latency; today's probe (if due) fires in the background for the NEXT launch. TTY sessions only.
2193
+ if (cfg.updateCheck && stdout.isTTY) {
2194
+ const upd = checkForUpdate(pkg.version);
2195
+ if (upd)
2196
+ out(c.yellow(`⬆ ${upd}`) + "\n");
2197
+ }
2190
2198
  const rl = createInterface({
2191
2199
  input: stdin,
2192
2200
  output: stdout,
package/dist/tui/App.js CHANGED
@@ -43,12 +43,33 @@ const MODE_SELECTOR_MS = 2500;
43
43
  // Memoized: a live block only re-renders when its own `item` (a fresh object when its text grows) or
44
44
  // `open` changes. So a spinner tick or an unrelated flush doesn't re-run `renderMarkdown` for every
45
45
  // live block, and non-tail live blocks stay put while only the streaming tail grows.
46
- const Block = memo(function Block({ item, open }) {
46
+ /** Tail-window a LIVE block's rendered lines so the dynamic region can never outgrow the terminal.
47
+ * ink repaints the whole dynamic region in place; once it's taller than the viewport the erase math
48
+ * breaks and the input box "runs to the top of the screen". Bounding the live view fixes the class:
49
+ * earlier lines are elided with a dim counter, and the FULL text lands in scrollback the moment the
50
+ * block finalizes (nothing is lost — ctrl+t shows it live too). `maxRows` comes from the terminal. */
51
+ function tailWindow(rendered, maxRows) {
52
+ const lines = rendered.replace(/\n+$/, "").split("\n");
53
+ if (lines.length <= maxRows)
54
+ return { header: null, body: rendered };
55
+ return {
56
+ header: `… +${lines.length - maxRows} earlier lines — full text lands above when this block finishes · ctrl+t to view now`,
57
+ body: lines.slice(-maxRows).join("\n"),
58
+ };
59
+ }
60
+ const Block = memo(function Block({ item, open, liveRows }) {
61
+ // Live streaming blocks get a bounded tail view (liveRows set); committed <Static> blocks render full.
62
+ const windowed = (rendered) => {
63
+ if (!liveRows)
64
+ return _jsx(Text, { children: rendered });
65
+ const w = tailWindow(rendered, liveRows);
66
+ return (_jsxs(Box, { flexDirection: "column", children: [w.header ? _jsx(Text, { dimColor: true, children: w.header }) : null, _jsx(Text, { children: w.body })] }));
67
+ };
47
68
  switch (item.kind) {
48
69
  case "user":
49
70
  return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "cyan", children: "\u203A " }), _jsx(Text, { children: item.text })] }));
50
71
  case "assistant":
51
- return _jsx(Text, { children: renderMarkdown(item.text) }); // headers/bold/inline-code/bullets + verbatim fences
72
+ return windowed(renderMarkdown(item.text)); // headers/bold/inline-code/bullets + verbatim fences
52
73
  case "reasoning": {
53
74
  // A streaming reasoning block lives in the dynamic region ABOVE the input box, and the instant the
54
75
  // model stops thinking it FOLDS to a single "✻ thought · N lines" notice in scrollback. If we streamed
@@ -66,7 +87,7 @@ const Block = memo(function Block({ item, open }) {
66
87
  case "tool":
67
88
  return _jsx(Text, { dimColor: true, children: " " + item.text });
68
89
  case "diff":
69
- return _jsx(Text, { children: item.text });
90
+ return windowed(item.text); // a big diff must not blow the live region either
70
91
  case "notice":
71
92
  return _jsx(Text, { dimColor: true, children: item.text });
72
93
  }
@@ -301,6 +322,11 @@ const TodoPanel = memo(function TodoPanel({ todos }) {
301
322
  });
302
323
  export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval, onClipboardImage, vim, visionNotice }) {
303
324
  const { exit } = useApp();
325
+ const { stdout: termOut } = useStdout();
326
+ // Live tail budget: terminal rows minus the rest of the dynamic chrome (todo panel ≤10, status slot,
327
+ // input box + footer, margins). Keeps the WHOLE dynamic region under the viewport — the invariant that
328
+ // stops ink's repaint from "running to the top" when a long answer/diff streams. Floor 8 for tiny panes.
329
+ const liveRows = Math.max(8, (termOut?.rows ?? 30) - 20);
304
330
  const [history, setHistory] = useState([]);
305
331
  const [current, setCurrent] = useState([]);
306
332
  const [working, setWorking] = useState(false);
@@ -587,5 +613,5 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
587
613
  });
588
614
  if (showTranscript)
589
615
  return _jsx(Transcript, { items: [...history, ...current], onClose: () => setShowTranscript(false) });
590
- 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 }, 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 })] }));
616
+ 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 })] }));
591
617
  }
@@ -0,0 +1,83 @@
1
+ // Startup update check (npm's update-notifier pattern): NEVER blocks or delays startup. The notice
2
+ // shown at launch comes from a CACHE written by a previous session's background probe; if the cache
3
+ // is stale (> a day) a fresh probe fires in the background with a hard timeout — its result shows on
4
+ // the NEXT launch. Offline / blocked registries fail silently. Interactive TTY sessions only (the
5
+ // caller gates); disable with `hara config set updateCheck false` or HARA_UPDATE_CHECK=0.
6
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
7
+ import { join, dirname } from "node:path";
8
+ import { homedir } from "node:os";
9
+ const cacheFile = () => join(homedir(), ".hara", "update-check.json");
10
+ /** Probe at most once a day — the check is a courtesy, not a heartbeat. */
11
+ export const CHECK_EVERY_MS = 24 * 60 * 60 * 1000;
12
+ /** npmjs first; the npmmirror fallback keeps the check working on CN networks where npmjs stalls. */
13
+ const REGISTRIES = ["https://registry.npmjs.org/@nanhara/hara/latest", "https://registry.npmmirror.com/@nanhara/hara/latest"];
14
+ /** Strict-ish semver compare on the numeric triple: is `latest` newer than `current`?
15
+ * Anything unparsable (tags, garbage, empty) → false — never nag on bad data. */
16
+ export function isNewer(latest, current) {
17
+ const parse = (v) => {
18
+ const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(v.trim());
19
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
20
+ };
21
+ const a = parse(latest);
22
+ const b = parse(current);
23
+ if (!a || !b)
24
+ return false;
25
+ for (let i = 0; i < 3; i++) {
26
+ if (a[i] !== b[i])
27
+ return a[i] > b[i];
28
+ }
29
+ return false;
30
+ }
31
+ export function readCache(file = cacheFile()) {
32
+ try {
33
+ const j = JSON.parse(readFileSync(file, "utf8"));
34
+ return typeof j?.checkedAt === "number" && typeof j?.latest === "string" ? j : null;
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ }
40
+ export function writeCache(c, file = cacheFile()) {
41
+ try {
42
+ mkdirSync(dirname(file), { recursive: true });
43
+ writeFileSync(file, JSON.stringify(c));
44
+ }
45
+ catch {
46
+ /* a failed cache write must never surface */
47
+ }
48
+ }
49
+ /** The startup notice, decided purely from cache state (testable without I/O). */
50
+ export function updateNotice(current, cache) {
51
+ if (!cache || !isNewer(cache.latest, current))
52
+ return null;
53
+ return `Update available ${current} → ${cache.latest} · npm i -g @nanhara/hara`;
54
+ }
55
+ /** Background probe: first registry that answers wins; 3s hard timeout each; silent on total failure
56
+ * (checkedAt still stamps so an offline machine backs off to daily retries, not every launch). */
57
+ export async function refreshLatest(file = cacheFile(), fetchFn = fetch) {
58
+ for (const url of REGISTRIES) {
59
+ try {
60
+ const r = await fetchFn(url, { signal: AbortSignal.timeout(3000) });
61
+ if (!r.ok)
62
+ continue;
63
+ const j = (await r.json());
64
+ if (j?.version) {
65
+ writeCache({ checkedAt: Date.now(), latest: j.version }, file);
66
+ return;
67
+ }
68
+ }
69
+ catch {
70
+ /* offline / blocked / slow → try the next registry, then give up quietly */
71
+ }
72
+ }
73
+ const prev = readCache(file);
74
+ writeCache({ checkedAt: Date.now(), latest: prev?.latest ?? "" }, file);
75
+ }
76
+ /** Startup entry: return the notice to print (or null), and — when the cache is stale — fire the
77
+ * daily background probe (fire-and-forget; the caller never awaits it). */
78
+ export function checkForUpdate(current, file = cacheFile(), now = Date.now()) {
79
+ const cache = readCache(file);
80
+ if (!cache || now - cache.checkedAt > CHECK_EVERY_MS)
81
+ void refreshLatest(file).catch(() => { });
82
+ return updateNotice(current, cache);
83
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.100.0",
3
+ "version": "0.101.1",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "dist/index.js"