@nanhara/hara 0.98.1 → 0.98.3

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/dist/index.js CHANGED
@@ -3118,7 +3118,11 @@ program.action(async (opts) => {
3118
3118
  await maybeAutoCompact(provider, history, meta, stats, cfg, (m) => h.sink.notice(m));
3119
3119
  },
3120
3120
  });
3121
- out("\n" + c.dim("Session ") + c.bold(shortId(meta.id)) + c.dim(" saved · resume: ") + c.cyan(`hara resume ${shortId(meta.id)}`) + "\n");
3121
+ // Only claim "saved · resume" if a turn actually persisted the session. A zero-turn session (opened,
3122
+ // then exited without submitting anything) is never written by saveSession — so printing the resume
3123
+ // hint would mislead and `hara resume <id>` would fail with "no session matching".
3124
+ if (loadSession(meta.id))
3125
+ out("\n" + c.dim("Session ") + c.bold(shortId(meta.id)) + c.dim(" saved · resume: ") + c.cyan(`hara resume ${shortId(meta.id)}`) + "\n");
3122
3126
  await closeMcp();
3123
3127
  process.exit(0); // TUI done — exit cleanly (ink can leave stdin referenced)
3124
3128
  }
package/dist/tui/App.js CHANGED
@@ -8,7 +8,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
8
8
  // The agent machinery is injected via `onSubmit` (a turn runner) so this view is testable with
9
9
  // ink-testing-library against a fake runner — no provider/network needed.
10
10
  import { Box, Static, Text, useApp, useInput, useStdout } from "ink";
11
- import { useCallback, useEffect, useRef, useState } from "react";
11
+ import { memo, useCallback, useEffect, useRef, useState } from "react";
12
12
  import { InputBox } from "./InputBox.js";
13
13
  import { activity } from "../activity.js";
14
14
  import { ctxPctFor } from "../statusbar.js";
@@ -34,7 +34,10 @@ function foldForHistory(it) {
34
34
  * block on every token, which over a laggy link leaves stale duplicate lines + jitter. 33ms clamps
35
35
  * the live re-render rate while state itself stays exact (nothing is dropped, only coalesced). */
36
36
  const LIVE_FRAME_MS = 33;
37
- function Block({ item, open }) {
37
+ // Memoized: a live block only re-renders when its own `item` (a fresh object when its text grows) or
38
+ // `open` changes. So a spinner tick or an unrelated flush doesn't re-run `renderMarkdown` for every
39
+ // live block, and non-tail live blocks stay put while only the streaming tail grows.
40
+ const Block = memo(function Block({ item, open }) {
38
41
  switch (item.kind) {
39
42
  case "user":
40
43
  return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "cyan", children: "\u203A " }), _jsx(Text, { children: item.text })] }));
@@ -58,7 +61,7 @@ function Block({ item, open }) {
58
61
  case "notice":
59
62
  return _jsx(Text, { dimColor: true, children: item.text });
60
63
  }
61
- }
64
+ });
62
65
  function flattenTranscript(items) {
63
66
  const out = [];
64
67
  for (const it of items) {
@@ -183,21 +186,33 @@ export function spinnerVerb(list, elapsedSec) {
183
186
  }
184
187
  return `working ${elapsedSec}s · esc to interrupt`;
185
188
  }
189
+ // The spinner is the only element that animates continuously while a turn runs — and because ink
190
+ // redraws the WHOLE dynamic region (input box + mode bar included) on any change, its tick rate sets
191
+ // a floor on full-frame redraws over the life of a turn. At ~8fps (125ms) the braille glyph still
192
+ // reads as smooth motion but cuts those forced redraws ~20% vs 100ms — meaningfully calmer over a
193
+ // slow/remote link. Elapsed seconds come from a wall-clock start, so the "Ns" text stays exact and
194
+ // stable (it only changes once per second, not coupled to the glyph frame).
195
+ const SPINNER_FRAME_MS = 125;
186
196
  function Working({ todos }) {
187
- const [n, setN] = useState(0);
197
+ const [frame, setFrame] = useState(0);
198
+ const startRef = useRef(Date.now());
188
199
  useEffect(() => {
189
- const id = setInterval(() => setN((x) => x + 1), 100);
200
+ startRef.current = Date.now();
201
+ const id = setInterval(() => setFrame((x) => x + 1), SPINNER_FRAME_MS);
190
202
  return () => clearInterval(id);
191
203
  }, []);
192
204
  const frames = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏";
193
- return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "yellow", children: frames[n % frames.length] }), _jsx(Text, { dimColor: true, children: ` ${spinnerVerb(todos, Math.floor(n / 10))}` })] }));
205
+ const elapsedSec = Math.floor((Date.now() - startRef.current) / 1000);
206
+ return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "yellow", children: frames[frame % frames.length] }), _jsx(Text, { dimColor: true, children: ` ${spinnerVerb(todos, elapsedSec)}` })] }));
194
207
  }
195
208
  // Live task panel: renders the current todo_write checklist between the in-progress turn output
196
209
  // and the input box. Highlights the in_progress item; caps at 8 rows and folds the rest into
197
210
  // `… +N pending/done`. Hidden when the list is empty.
198
211
  const PANEL_MAX_ROWS = 8;
199
212
  const TODO_MARK = { pending: "☐", in_progress: "▶", done: "☑" };
200
- function TodoPanel({ todos }) {
213
+ // Memoized on the `todos` array reference (stable between todo_write updates), so spinner ticks and
214
+ // streaming flushes don't re-run the sort/slice every frame.
215
+ const TodoPanel = memo(function TodoPanel({ todos }) {
201
216
  if (!todos.length)
202
217
  return null;
203
218
  const doneCount = todos.filter((t) => t.status === "done").length;
@@ -224,7 +239,7 @@ function TodoPanel({ todos }) {
224
239
  const done = t.status === "done";
225
240
  return (_jsx(Text, { color: inProg ? accent() : undefined, bold: inProg, dimColor: done, children: ` ${TODO_MARK[t.status]} ${t.text}` }, i));
226
241
  }), hiddenSummary ? _jsx(Text, { dimColor: true, children: hiddenSummary }) : null] }));
227
- }
242
+ });
228
243
  export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval, onClipboardImage, vim, visionNotice }) {
229
244
  const { exit } = useApp();
230
245
  const [history, setHistory] = useState([]);
@@ -3,26 +3,39 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
3
3
  // prompt line in the middle, and a bottom border carrying the approval modes + token usage +
4
4
  // concurrency. Composed from <Text> rows (no ink border fork needed) so the title sits exactly
5
5
  // where we want it. Pure-ish: pass `width` to make rendering deterministic in tests.
6
+ //
7
+ // Render-stability principles (codex-style, for slow/remote terminals): ink erases and rewrites the
8
+ // ENTIRE dynamic region on every frame, so the box's cost scales with (a) how many lines it occupies
9
+ // and (b) how often any of them change. Two levers here:
10
+ // 1. The static chrome (borders, mode bar, mention popup) is memoized so a keystroke that only
11
+ // changes the prompt text doesn't force React to reconcile the unchanged rows.
12
+ // 2. Line-wrapping + cursor are computed deterministically from (value, cursor, width) in one memo,
13
+ // so long input wraps under a stable continuation indent and the cursor never drifts — instead
14
+ // of leaning on ink's soft-wrap of a single <Text>, which mis-aligns wrapped rows against the
15
+ // "› " prompt gutter and reflows unpredictably as you type.
6
16
  import { Box, Text, useInput, useStdout } from "ink";
7
- import { useMemo, useState } from "react";
17
+ import { memo, useMemo, useState } from "react";
8
18
  import { fileCandidates } from "../context/mentions.js";
9
19
  import { imagePathFromPaste } from "../images.js";
10
20
  import { vimNormal } from "./vim.js";
11
21
  export const MODES = ["suggest", "auto-edit", "full-auto", "plan"];
12
22
  export const nextMode = (m) => MODES[(MODES.indexOf(m) + 1) % MODES.length];
13
23
  const tok = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`);
14
- function TopBorder({ name, width }) {
24
+ // The prompt gutter ("› " / "◆ ") is 2 cells wide; wrapped continuation lines indent by the same
25
+ // amount so the text column is stable across visual rows.
26
+ const GUTTER = 2;
27
+ const TopBorder = memo(function TopBorder({ name, width }) {
15
28
  const labelLen = name.length + 2; // "⏺ " + name
16
29
  const left = Math.max(2, width - labelLen - 3);
17
30
  return (_jsxs(Box, { children: [_jsxs(Text, { dimColor: true, children: ["─".repeat(left), " "] }), _jsx(Text, { color: "cyan", children: "\u23FA" }), _jsxs(Text, { bold: true, children: [" ", name] }), _jsx(Text, { dimColor: true, children: " \u2500" })] }));
18
- }
31
+ });
19
32
  // Bottom border carries token usage + concurrency at the right corner (modes moved to ModeBar below).
20
- function BottomBorder({ s, width }) {
33
+ const BottomBorder = memo(function BottomBorder({ s, width }) {
21
34
  const usage = `↑${tok(s.input)} ↓${tok(s.output)}${s.ctxPct > 0 ? ` · ctx ${s.ctxPct}%` : ""}`;
22
35
  const label = s.agents > 0 ? `${usage} · ⛁${s.agents}` : `${usage} · ⛁ idle`;
23
36
  const left = Math.max(2, width - label.length - 3);
24
37
  return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: `${"─".repeat(left)} ${label} ─` }) }));
25
- }
38
+ });
26
39
  const MODE_DESC = {
27
40
  suggest: "confirms edits & commands",
28
41
  "auto-edit": "auto-applies edits · asks before commands",
@@ -31,23 +44,22 @@ const MODE_DESC = {
31
44
  };
32
45
  // Prominent approval-mode selector below the box: all three listed, the active one highlighted (red
33
46
  // for the dangerous full-auto) with a one-line description and the shift+tab hint.
34
- function ModeBar({ approval }) {
47
+ const ModeBar = memo(function ModeBar({ approval }) {
35
48
  const warn = approval === "full-auto";
36
49
  return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { children: MODES.map((m, i) => (_jsxs(Text, { children: [i > 0 ? " " : " ", m === approval ? _jsx(Text, { color: warn ? "red" : m === "plan" ? "cyan" : "green", bold: true, children: `◆ ${m}` }) : _jsx(Text, { dimColor: true, children: m })] }, m))) }), _jsx(Text, { dimColor: true, children: ` ${MODE_DESC[approval]} · shift+tab ⇄` })] }));
37
- }
50
+ });
38
51
  /** The active `@mention` token immediately left of the cursor (for the file popup), or null. */
39
52
  function activeMention(value, cursor) {
40
53
  const m = /(?:^|\s)@([^\s@]*)$/.exec(value.slice(0, cursor));
41
54
  return m ? { query: m[1], start: cursor - m[1].length } : null;
42
55
  }
43
56
  // Dropdown of fuzzy @path matches, shown above the input as you type `@…` (codex / Claude-Code style).
44
- function MentionPopup({ items, selected, query }) {
57
+ const MentionPopup = memo(function MentionPopup({ items, selected, query }) {
45
58
  return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: ` @${query} · ${items.length} match${items.length === 1 ? "" : "es"} — ↑↓ select · Tab/Enter insert · Esc dismiss` }), items.map((it, i) => (_jsxs(Text, { children: [i === selected ? _jsx(Text, { color: "cyan", children: " ▸ " }) : _jsx(Text, { children: " " }), _jsx(Text, { color: it.endsWith("/") ? "blue" : undefined, dimColor: i !== selected, bold: i === selected, children: it })] }, it)))] }));
46
- }
59
+ });
47
60
  const TOKEN_RE = /\[Image #\d+\]/g;
48
- /** Render the prompt line: plain text + the cursor, with any `[Image #N]` attachment tokens highlighted
49
- * (codex / Claude-Code style — the image lives inline in the text, visibly distinct from what you typed). */
50
- function InputLine({ value, cursor }) {
61
+ /** Split the value into text/image-token segments (image tokens render highlighted, codex-style). */
62
+ function segmentize(value) {
51
63
  const parts = [];
52
64
  let last = 0;
53
65
  let m;
@@ -60,30 +72,131 @@ function InputLine({ value, cursor }) {
60
72
  }
61
73
  if (last < value.length)
62
74
  parts.push({ text: value.slice(last), token: false });
75
+ return parts;
76
+ }
77
+ /** Wrap `value` into rows that each fit within `cols` cells, breaking on spaces where possible but
78
+ * never inside an `[Image #N]` token. Deterministic (no reliance on ink's soft-wrap) so wrapped rows
79
+ * align under a stable gutter and the cursor position is exact. Always returns at least one row. */
80
+ export function wrapRows(value, cols) {
81
+ const width = Math.max(1, cols);
82
+ const rows = [];
83
+ const parts = segmentize(value);
84
+ // Flatten to atomic units: a run of text is breakable per-char/word; an image token is atomic.
85
+ const atoms = [];
86
+ let pos = 0;
87
+ for (const p of parts) {
88
+ if (p.token) {
89
+ atoms.push({ text: p.text, start: pos, atomic: true });
90
+ pos += p.text.length;
91
+ }
92
+ else {
93
+ // Break the text run into word chunks (keep trailing space with the word) so wraps land on spaces.
94
+ const re = /\S+\s*|\s+/g;
95
+ let m;
96
+ let base = pos;
97
+ while ((m = re.exec(p.text))) {
98
+ atoms.push({ text: m[0], start: base + m.index, atomic: false });
99
+ }
100
+ pos += p.text.length;
101
+ }
102
+ }
103
+ if (atoms.length === 0)
104
+ return [{ start: 0, end: value.length }];
105
+ let rowStart = 0;
106
+ let col = 0;
107
+ const flush = (end) => {
108
+ rows.push({ start: rowStart, end });
109
+ rowStart = end;
110
+ col = 0;
111
+ };
112
+ for (const a of atoms) {
113
+ const aEnd = a.start + a.text.length;
114
+ if (a.atomic || a.text.length <= width) {
115
+ // A whole unit (image token OR a word chunk that fits within a full row): if it doesn't fit in
116
+ // the remaining room and the row already has content, wrap to a fresh row FIRST — never split it.
117
+ if (a.text.length > width - col && col > 0)
118
+ flush(a.start);
119
+ col += a.text.length; // an oversized atomic token may exceed width — acceptable (rare, kept whole)
120
+ if (col >= width)
121
+ flush(aEnd);
122
+ }
123
+ else {
124
+ // A single word longer than the whole width: hard-break it across rows.
125
+ let s = a.start;
126
+ if (col > 0)
127
+ flush(s); // start the long word on a fresh row
128
+ while (s < aEnd) {
129
+ const room = width - col;
130
+ const take = Math.min(room, aEnd - s);
131
+ col += take;
132
+ s += take;
133
+ if (col >= width)
134
+ flush(s);
135
+ }
136
+ }
137
+ }
138
+ // Flush the tail. If content exactly filled the last flushed row (rowStart === value.length), we add
139
+ // an EMPTY trailing row so an end-of-line cursor doesn't overflow the full row (would wrap oddly).
140
+ if (rowStart < value.length) {
141
+ rows.push({ start: rowStart, end: value.length });
142
+ }
143
+ else if (rows.length === 0) {
144
+ rows.push({ start: 0, end: value.length }); // whole value fit on one (un-flushed) row
145
+ }
146
+ else if (rowStart === value.length && col === 0) {
147
+ rows.push({ start: value.length, end: value.length }); // exactly-full last row → empty tail for the cursor
148
+ }
149
+ return rows;
150
+ }
151
+ /** Render a single visual row of the prompt: text + highlighted image tokens, drawing the block
152
+ * cursor (inverse cell) when it falls on this row. Splitting happens on precomputed rows so the
153
+ * whole prompt never reflows as a single <Text> — stable under wrapping and quick to diff. */
154
+ function renderRow(value, row, cursor, showCursor, isLastRow, keyPrefix) {
63
155
  const seg = (token, text, k) => token ? (_jsx(Text, { backgroundColor: "magenta", color: "white", children: text }, k)) : (_jsx(Text, { children: text }, k));
156
+ // Segments intersected with this row's [start,end) range.
157
+ const parts = segmentize(value);
64
158
  const nodes = [];
65
- let pos = 0;
66
159
  let ki = 0;
160
+ let pos = 0;
161
+ const cursorOnRow = showCursor && cursor >= row.start && cursor < row.end;
67
162
  for (const p of parts) {
68
- const start = pos;
69
- const end = pos + p.text.length;
70
- if (cursor >= start && cursor < end) {
71
- const rel = cursor - start;
163
+ const pStart = pos;
164
+ const pEnd = pos + p.text.length;
165
+ pos = pEnd;
166
+ const from = Math.max(pStart, row.start);
167
+ const to = Math.min(pEnd, row.end);
168
+ if (from >= to)
169
+ continue; // segment not on this row
170
+ const slice = p.text.slice(from - pStart, to - pStart);
171
+ if (cursorOnRow && cursor >= from && cursor < to) {
172
+ const rel = cursor - from;
72
173
  if (rel > 0)
73
- nodes.push(seg(p.token, p.text.slice(0, rel), `s${ki++}`));
74
- nodes.push(_jsx(Text, { inverse: true, children: p.text[rel] }, `c${ki++}`));
75
- if (rel + 1 < p.text.length)
76
- nodes.push(seg(p.token, p.text.slice(rel + 1), `e${ki++}`));
174
+ nodes.push(seg(p.token, slice.slice(0, rel), `${keyPrefix}s${ki++}`));
175
+ nodes.push(_jsx(Text, { inverse: true, children: slice[rel] }, `${keyPrefix}c${ki++}`));
176
+ if (rel + 1 < slice.length)
177
+ nodes.push(seg(p.token, slice.slice(rel + 1), `${keyPrefix}e${ki++}`));
77
178
  }
78
179
  else {
79
- nodes.push(seg(p.token, p.text, `p${ki++}`));
180
+ nodes.push(seg(p.token, slice, `${keyPrefix}p${ki++}`));
80
181
  }
81
- pos = end;
82
182
  }
83
- if (cursor >= value.length)
84
- nodes.push(_jsx(Text, { inverse: true, children: " " }, "end"));
85
- return _jsx(Text, { children: nodes });
183
+ // End-of-value cursor: a trailing inverse space, only ever on the LAST row (so it's drawn once).
184
+ if (showCursor && isLastRow && cursor >= value.length && cursor >= row.start) {
185
+ nodes.push(_jsx(Text, { inverse: true, children: " " }, `${keyPrefix}end`));
186
+ }
187
+ return nodes;
86
188
  }
189
+ /** The prompt: gutter + wrapped input rows (or a placeholder when empty). Each wrapped continuation
190
+ * row is indented under the gutter so the text column is stable. Memoized so it only re-renders when
191
+ * the value/cursor/width/gutter actually change (not when unrelated status ticks over). */
192
+ const InputLine = memo(function InputLine({ value, cursor, width, gutter, gutterColor, placeholder, }) {
193
+ const cols = Math.max(1, width - GUTTER); // text column width (after the 2-cell gutter)
194
+ const rows = useMemo(() => (value.length ? wrapRows(value, cols) : [{ start: 0, end: 0 }]), [value, cols]);
195
+ if (value.length === 0) {
196
+ return (_jsxs(Box, { children: [_jsx(Text, { color: gutterColor, children: gutter }), _jsxs(Text, { children: [_jsx(Text, { inverse: true, children: " " }), _jsx(Text, { dimColor: true, children: placeholder })] })] }));
197
+ }
198
+ return (_jsx(Box, { flexDirection: "column", children: rows.map((row, i) => (_jsxs(Box, { children: [_jsx(Text, { color: gutterColor, children: i === 0 ? gutter : " " }), _jsx(Text, { children: renderRow(value, row, cursor, true, i === rows.length - 1, `r${i}_`) })] }, i))) }));
199
+ });
87
200
  /** Top border (session) + prompt line + bottom border (usage) + ModeBar, with an @path popup. */
88
201
  export function InputBox({ status, cwd, width, onSubmit, onClipboardImage, isActive = true, working = false, queued = 0, vim = false, placeholder = "Type a task · /help · @file · Ctrl+V paste image · shift+tab mode · Esc interrupts", }) {
89
202
  const { stdout } = useStdout();
@@ -124,7 +237,10 @@ export function InputBox({ status, cwd, width, onSubmit, onClipboardImage, isAct
124
237
  setPending("");
125
238
  };
126
239
  const mention = activeMention(value, cursor);
127
- const candidates = useMemo(() => (isActive && mention && !dismissed ? fileCandidates(cwd, mention.query, 8) : []), [cwd, isActive, dismissed, mention?.query, mention?.start]);
240
+ // FS scan only when the mention QUERY changes not on every cursor move / unrelated keystroke.
241
+ // (`mention.start` moves whenever the cursor does; keying off it re-scanned the disk needlessly.)
242
+ const mentionQuery = isActive && mention && !dismissed ? mention.query : null;
243
+ const candidates = useMemo(() => (mentionQuery !== null ? fileCandidates(cwd, mentionQuery, 8) : []), [cwd, mentionQuery]);
128
244
  const popupOpen = candidates.length > 0;
129
245
  const selIdx = popupOpen ? Math.min(sel, candidates.length - 1) : 0;
130
246
  const complete = (cand) => {
@@ -238,5 +354,7 @@ export function InputBox({ status, cwd, width, onSubmit, onClipboardImage, isAct
238
354
  set(value.slice(0, cursor) + input + value.slice(cursor), cursor + input.length);
239
355
  }
240
356
  }, { isActive });
241
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(TopBorder, { name: status.sessionName || "session", width: w }), _jsxs(Box, { children: [_jsx(Text, { color: vim ? (mode === "normal" ? "yellow" : "green") : "cyan", children: vim && mode === "normal" ? "◆ " : "› " }), value.length === 0 ? (_jsxs(Text, { children: [_jsx(Text, { inverse: true, children: " " }), _jsx(Text, { dimColor: true, children: placeholder })] })) : (_jsx(InputLine, { value: value, cursor: cursor }))] }), vim ? _jsx(Text, { dimColor: true, children: mode === "normal" ? " -- NORMAL -- i/a insert · h l 0 $ w b e move · x dd D cw p edit" : " -- INSERT -- Esc → normal" }) : null, _jsx(BottomBorder, { s: status, width: w }), working ? _jsx(Text, { dimColor: true, children: ` ⌨ working — Enter queues your message${queued ? ` · ${queued} queued` : ""} · Esc interrupts` }) : null, popupOpen ? _jsx(MentionPopup, { items: candidates, selected: selIdx, query: mention.query }) : null, _jsx(ModeBar, { approval: status.approval })] }));
357
+ const gutter = vim && mode === "normal" ? "◆ " : "› ";
358
+ const gutterColor = vim ? (mode === "normal" ? "yellow" : "green") : "cyan";
359
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(TopBorder, { name: status.sessionName || "session", width: w }), _jsx(InputLine, { value: value, cursor: cursor, width: w, gutter: gutter, gutterColor: gutterColor, placeholder: placeholder }), vim ? _jsx(Text, { dimColor: true, children: mode === "normal" ? " -- NORMAL -- i/a insert · h l 0 $ w b e move · x dd D cw p edit" : " -- INSERT -- Esc → normal" }) : null, _jsx(BottomBorder, { s: status, width: w }), working ? _jsx(Text, { dimColor: true, children: ` ⌨ working — Enter queues your message${queued ? ` · ${queued} queued` : ""} · Esc interrupts` }) : null, popupOpen ? _jsx(MentionPopup, { items: candidates, selected: selIdx, query: mention.query }) : null, _jsx(ModeBar, { approval: status.approval })] }));
242
360
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.98.1",
3
+ "version": "0.98.3",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "dist/index.js"