@nanhara/hara 0.101.1 → 0.103.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,40 @@ 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.103.0 — the project-analysis SOP (why hara felt slow on "analyze this repo")
9
+
10
+ The execution layer could always parallelize reads and fan out read-only sub-agents — but nothing
11
+ TAUGHT the model, so it explored one call per turn. Distilled from codex's prompt discipline and
12
+ Claude Code's Explore-agent pattern:
13
+
14
+ - **System prompt playbook**: batch independent tool calls in one response (reads execute in
15
+ parallel); analyzing a project starts with a ONE-batch wide sweep (manifest + README + build/CI
16
+ config) then narrow grep/glob; more than ~3 searches → fan out `agent` sub-agents, several in one
17
+ response.
18
+ - **`agent` tool grows WHEN-TO-USE / WHEN-NOT-TO-USE guidance** (CC's heuristic): narrow lookups go
19
+ to direct tools; open-ended "how does X work across the codebase" goes to sub-agents.
20
+ - **Built-in `explore` persona** — `agent(role:"explore")` works with zero setup: read-only, parallel
21
+ searches, excerpts not whole files, returns conclusions with path:line refs, never dumps. A
22
+ user-defined explore role still wins.
23
+
24
+ ## 0.102.0 — a slow network never feels dead
25
+
26
+ Jeff + a designer colleague both hit the same thing: press Enter on a slow connection and hara
27
+ "looks stuck — thought it failed". Studied codex's handling (15s connect timeout, 2–9s stream-idle
28
+ timeout, Working[Xs·Esc] status machine) and closed the gaps:
29
+
30
+ - **Stall watchdog.** A model attempt that streams NOTHING for 120s (HARA_STALL_TIMEOUT to tune) is
31
+ aborted and routed through the normal error→failover path — `fallbackModel` picks it up
32
+ automatically, or you get a clear "model stream timeout — no output for 120s" instead of an
33
+ infinite spinner. A real Esc stays an interrupt (never rewritten).
34
+ - **"waiting for the model… Ns".** The status row now distinguishes the pre-first-token stretch from
35
+ actual work (a new turn-phase channel published by the loop) — on a slow route you can SEE the
36
+ request is out, ticking, interruptible.
37
+ - **Big pastes fold to a token.** Pasting a long/multi-line text used to flood the box AND could
38
+ auto-submit at the first newline mid-paste. Now ≥3 lines or ≥600 chars folds to a highlighted
39
+ `[Paste #1 +N lines]` token (Claude-Code style): the box stays small, typing stays smooth,
40
+ backspace deletes it whole, and the FULL text expands into the message on submit.
41
+
8
42
  ## 0.101.1 — the input box stops running to the top
9
43
 
10
44
  - **Live-region overflow guard.** A long streaming answer (or a big diff) used to grow the live region
@@ -12,6 +12,16 @@ import { subdirHint } from "../context/subdir-hints.js";
12
12
  import { classifyError, failoverAction, errorHint } from "./failover.js";
13
13
  import { currentTodos, renderTodos } from "../tools/todo.js";
14
14
  import { drainReminders, wrapReminders, pushReminder, todoStaleReminder, TODO_STALE_ROUNDS } from "./reminders.js";
15
+ import { setTurnPhase } from "./phase.js";
16
+ /** Stall watchdog ceiling: a model attempt that streams NOTHING for this long is treated as a dead /
17
+ * stalled connection and aborted into the normal error→failover path — instead of hanging on
18
+ * "working Ns" forever (the "pressed Enter, thought it failed" report). Generous default because
19
+ * hidden-reasoning models can legitimately go quiet for a while; HARA_STALL_TIMEOUT (ms) tunes it,
20
+ * floor 1s (tests). codex's equivalent is its 2–9s stream-idle timeout. */
21
+ export function stallMs() {
22
+ const raw = Number(process.env.HARA_STALL_TIMEOUT ?? 120_000);
23
+ return Math.max(1_000, Number.isFinite(raw) && raw > 0 ? raw : 120_000);
24
+ }
15
25
  /** Spinner verb (terminal mode + reused by TUI tests): when the agent has an in_progress todo,
16
26
  * surface its activeForm/text so the bottom-of-screen line reads concretely ("▶ updating tests… 3s")
17
27
  * instead of "working 3s". Pure: takes a snapshot + elapsed seconds. */
@@ -39,7 +49,13 @@ const HARA_SYSTEM = (cwd) => `You are hara, a coding agent running in the user's
39
49
  Working directory: ${cwd}
40
50
  Be concise and direct. Use the provided tools to read files, edit/write files, and run shell
41
51
  commands. Prefer small, verifiable steps; edit existing files with edit_file rather than rewriting
42
- them whole. For a multi-step task, call \`todo_write\` to plan a short checklist and keep it updated as
52
+ them whole. Batch INDEPENDENT tool calls in a single response especially reads (read_file / grep /
53
+ glob / ls run in PARALLEL when requested together); one-call-per-turn exploration is the slowest thing
54
+ you can do. When analyzing a project, start wide in ONE batch — manifest (package.json / Cargo.toml /
55
+ pyproject.toml / go.mod), README, build/CI config — then chase only what the task needs with narrow
56
+ grep/glob; don't read whole large files when a targeted search answers the question. For broad,
57
+ open-ended exploration (more than ~3 searches), spawn \`agent\` sub-agents — several in one response for
58
+ independent questions (role "explore") — each returns conclusions, not dumps. For a multi-step task, call \`todo_write\` to plan a short checklist and keep it updated as
43
59
  you go (one item in_progress at a time) — skip it for trivial one-step tasks. You have a persistent
44
60
  memory: use memory_search before answering about prior decisions,
45
61
  conventions, or the user's preferences, and memory_write to proactively save durable facts you learn.
@@ -141,53 +157,89 @@ export async function runAgent(history, opts) {
141
157
  out(`\r\x1b[K${c.dim(`${frames[fi++ % frames.length]} ${verb}`)}`);
142
158
  }, 100);
143
159
  }
144
- const r = await activeProvider.turn({
145
- system: composeSystem(ctx.cwd, opts.projectContext, opts.systemOverride, opts.memory),
146
- history,
147
- tools: specs,
148
- onText: (d) => {
149
- if (opts.quiet)
150
- return;
151
- if (sink) {
152
- sink.text(d);
153
- return;
154
- }
155
- stopSpin();
156
- flushReasoningTail();
157
- if (md)
158
- md.push(d);
159
- else
160
- out(d);
161
- },
162
- onReasoning: sink || tty
163
- ? (d) => {
160
+ // Stall watchdog: any stream event resets the clock; STALL_MS of silence aborts THIS attempt via
161
+ // its own controller (the user's opts.signal chains into it, so Esc still interrupts). The abort
162
+ // is then rewritten from "interrupted" to a timeout-class error so failover can take over.
163
+ const STALL_MS = stallMs();
164
+ const attempt = new AbortController();
165
+ const onUserAbort = () => attempt.abort();
166
+ opts.signal?.addEventListener("abort", onUserAbort, { once: true });
167
+ let lastEvent = Date.now();
168
+ let stalled = false;
169
+ const stallTimer = setInterval(() => {
170
+ if (Date.now() - lastEvent > STALL_MS) {
171
+ stalled = true;
172
+ attempt.abort();
173
+ }
174
+ }, Math.min(2_000, Math.max(250, STALL_MS / 4)));
175
+ const alive = () => {
176
+ lastEvent = Date.now();
177
+ if (!opts.quiet)
178
+ setTurnPhase("streaming");
179
+ };
180
+ if (!opts.quiet)
181
+ setTurnPhase("waiting"); // request sent, nothing streamed yet — the status row shows it
182
+ let r;
183
+ try {
184
+ r = await activeProvider.turn({
185
+ system: composeSystem(ctx.cwd, opts.projectContext, opts.systemOverride, opts.memory),
186
+ history,
187
+ tools: specs,
188
+ onText: (d) => {
189
+ alive();
164
190
  if (opts.quiet)
165
191
  return;
166
192
  if (sink) {
167
- sink.reasoning(d);
193
+ sink.text(d);
168
194
  return;
169
195
  }
170
- // Terminal mode: render reasoning on its own dim lines (prefix `│ ` per line). Each
171
- // line is committed once and never overwritten — so a subsequent spinner tick can't
172
- // clobber it (the old `out(c.dim(d))` bug). Multi-line deltas split cleanly; the
173
- // current line resumes mid-output when the next delta arrives.
174
196
  stopSpin();
175
- const lines = d.split("\n");
176
- for (let i = 0; i < lines.length; i++) {
177
- if (!reasoningOpen) {
178
- out(c.dim("│ "));
179
- reasoningOpen = true;
197
+ flushReasoningTail();
198
+ if (md)
199
+ md.push(d);
200
+ else
201
+ out(d);
202
+ },
203
+ onReasoning: sink || tty
204
+ ? (d) => {
205
+ alive();
206
+ if (opts.quiet)
207
+ return;
208
+ if (sink) {
209
+ sink.reasoning(d);
210
+ return;
180
211
  }
181
- out(c.dim(lines[i]));
182
- if (i < lines.length - 1) {
183
- out("\n");
184
- reasoningOpen = false;
212
+ // Terminal mode: render reasoning on its own dim lines (prefix `│ ` per line). Each
213
+ // line is committed once and never overwritten — so a subsequent spinner tick can't
214
+ // clobber it (the old `out(c.dim(d))` bug). Multi-line deltas split cleanly; the
215
+ // current line resumes mid-output when the next delta arrives.
216
+ stopSpin();
217
+ const lines = d.split("\n");
218
+ for (let i = 0; i < lines.length; i++) {
219
+ if (!reasoningOpen) {
220
+ out(c.dim("│ "));
221
+ reasoningOpen = true;
222
+ }
223
+ out(c.dim(lines[i]));
224
+ if (i < lines.length - 1) {
225
+ out("\n");
226
+ reasoningOpen = false;
227
+ }
185
228
  }
186
229
  }
187
- }
188
- : undefined,
189
- signal: opts.signal,
190
- });
230
+ : (d) => alive(), // quiet runs still feed the watchdog (reasoning-only stretches are progress)
231
+ signal: attempt.signal,
232
+ });
233
+ }
234
+ finally {
235
+ clearInterval(stallTimer);
236
+ opts.signal?.removeEventListener("abort", onUserAbort);
237
+ }
238
+ // A watchdog abort surfaces from the provider as "interrupted" — rewrite it to a timeout-class
239
+ // error (unless the USER really did interrupt) so classifyError → failover/fallback handles it.
240
+ if (stalled && r.stop === "error" && !opts.signal?.aborted) {
241
+ r = { ...r, errorMsg: `model stream timeout — no output for ${Math.round(STALL_MS / 1000)}s (stalled connection?)` };
242
+ }
191
243
  stopSpin();
192
244
  flushReasoningTail();
193
245
  md?.end();
@@ -0,0 +1,29 @@
1
+ // Turn phase for the status line (codex-parity): between "request sent" and "first stream event"
2
+ // the user should see *waiting for the model* — not a generic "working" that reads the same whether
3
+ // the model is thinking or the connection is dead. The MAIN loop publishes (quiet/sub-agent runs
4
+ // don't — they'd stomp the shared channel); the TUI status row subscribes like it does for todos.
5
+ let phase = "idle";
6
+ const listeners = new Set();
7
+ export function turnPhase() {
8
+ return phase;
9
+ }
10
+ export function setTurnPhase(p) {
11
+ if (p === phase)
12
+ return;
13
+ phase = p;
14
+ for (const fn of listeners) {
15
+ try {
16
+ fn(phase);
17
+ }
18
+ catch {
19
+ /* a listener must not break the loop */
20
+ }
21
+ }
22
+ }
23
+ /** Subscribe to phase changes. Returns an unsubscribe fn. */
24
+ export function onTurnPhase(fn) {
25
+ listeners.add(fn);
26
+ return () => {
27
+ listeners.delete(fn);
28
+ };
29
+ }
package/dist/index.js CHANGED
@@ -36,6 +36,7 @@ import { addJob, removeJob, setEnabled, resolveJob, loadJobs, recordRun, logPath
36
36
  import { runTick, runJobOnce, selfArgv } from "./cron/runner.js";
37
37
  import { installScheduler, uninstallScheduler, isInstalled } from "./cron/install.js";
38
38
  import { getTools } from "./tools/registry.js";
39
+ import { EXPLORE_SYSTEM } from "./tools/agent.js";
39
40
  import { createAnthropicProvider } from "./providers/anthropic.js";
40
41
  import { createOpenAIProvider } from "./providers/openai.js";
41
42
  import { qwenDeviceLogin, getValidQwenAuth } from "./providers/qwen-oauth.js";
@@ -765,6 +766,9 @@ async function maybeAutoCompact(provider, history, meta, stats, cfg, notify) {
765
766
  async function runSubagent(cfg, baseProvider, cwd, sandbox, projectContext, stats, task, roleId) {
766
767
  const roles = loadRoles(cwd);
767
768
  const role = roleId ? roles.find((r) => r.id === roleId) : undefined;
769
+ // Built-in explore persona: `agent(role:"explore")` works with ZERO user setup (a user-defined
770
+ // explore role still wins — it was found above and carries its own system).
771
+ const builtinSystem = !role && roleId === "explore" ? EXPLORE_SYSTEM : undefined;
768
772
  const __subModel = effectiveRoleModel(role?.model, cfg.model);
769
773
  const provider = __subModel ? ((await buildProvider({ ...cfg, model: __subModel })) ?? baseProvider) : baseProvider;
770
774
  // A sub-agent runs full-auto + UNCONFIRMED + parallel, so it is ALWAYS read-only — a role may narrow
@@ -780,7 +784,7 @@ async function runSubagent(cfg, baseProvider, cwd, sandbox, projectContext, stat
780
784
  projectContext,
781
785
  memory: memoryDigest(cwd),
782
786
  stats,
783
- systemOverride: role?.system,
787
+ systemOverride: role?.system ?? builtinSystem,
784
788
  toolFilter,
785
789
  quiet: true,
786
790
  });
@@ -2,11 +2,24 @@
2
2
  // turn run in PARALLEL (kind "read" → concurrent), making the footer's ⛁ count real. Sub-agents are
3
3
  // read-only by default (safe to parallelize); the actual spawn is provided via ctx.spawn.
4
4
  import { registerTool } from "./registry.js";
5
+ /** Built-in persona for `role: "explore"` (no setup needed — index.ts falls back to this when the
6
+ * user hasn't defined an explore role). Claude-Code's Explore-agent playbook: read-only, parallel,
7
+ * excerpts-not-files, conclusions-not-dumps. */
8
+ export const EXPLORE_SYSTEM = "You are a fast, READ-ONLY codebase explorer. Navigate with grep/glob/ls/read_file and be quick: " +
9
+ "issue your searches and file reads as MULTIPLE PARALLEL tool calls in one round whenever they are " +
10
+ "independent — never one-per-turn. Read targeted excerpts, not whole files. You cannot modify anything. " +
11
+ "Answer with CONCLUSIONS: the finding, the relevant paths with line references, and what they mean for " +
12
+ "the question — never dump raw file contents. Match your depth to the task: a quick lookup stays quick; " +
13
+ "an architecture question deserves a thorough sweep across naming conventions and directories.";
5
14
  registerTool({
6
15
  name: "agent",
7
- description: "Delegate an independent sub-task to a fresh sub-agent and get its result. Spawn SEVERAL in one " +
8
- "turn to run them in parallel (e.g. analyze/search/review N things at once). Sub-agents are " +
9
- "read-only by default; pass a `role` id to use that role's persona + tools. Not for edits.",
16
+ description: "Delegate an independent sub-task to a fresh READ-ONLY sub-agent and get its conclusions. " +
17
+ "WHEN TO USE: open-ended exploration ('how does X work across the codebase', 'find everything that touches Y') " +
18
+ "that would take more than ~3 searches pass role \"explore\" for a fast search specialist; and spawning " +
19
+ "SEVERAL agents in ONE response for independent questions (they run in parallel). " +
20
+ "WHEN NOT TO USE: reading a specific known file (read_file), finding one symbol (grep), or searching " +
21
+ "within 2-3 known files — direct tools are faster. Never for edits. " +
22
+ "Pass a `role` id to use that role's persona + tools.",
10
23
  input_schema: {
11
24
  type: "object",
12
25
  properties: {
package/dist/tui/App.js CHANGED
@@ -17,6 +17,7 @@ import { ctxPctFor } from "../statusbar.js";
17
17
  import { accent } from "./theme.js";
18
18
  import { renderMarkdown } from "../md.js";
19
19
  import { clearTodos, currentTodos, onTodosChange } from "../tools/todo.js";
20
+ import { onTurnPhase, turnPhase } from "../agent/phase.js";
20
21
  let _id = 0;
21
22
  const nid = () => ++_id;
22
23
  const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
@@ -256,7 +257,9 @@ const SPINNER_FRAME_MS = 125;
256
257
  const IDLE_HINTS = "⏎ send · @ file · ctrl+v image · ctrl+t transcript · shift+tab mode";
257
258
  function StatusRow({ working, todos, queued }) {
258
259
  const [frame, setFrame] = useState(0);
260
+ const [phase, setPhase] = useState(() => turnPhase());
259
261
  const startRef = useRef(Date.now());
262
+ useEffect(() => onTurnPhase(setPhase), []); // waiting → streaming, published by the agent loop
260
263
  useEffect(() => {
261
264
  if (!working)
262
265
  return;
@@ -269,7 +272,10 @@ function StatusRow({ working, todos, queued }) {
269
272
  }
270
273
  const frames = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏";
271
274
  const elapsedSec = Math.floor((Date.now() - startRef.current) / 1000);
272
- return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "yellow", children: frames[frame % frames.length] }), _jsx(Text, { dimColor: true, children: ` ${spinnerVerb(todos, elapsedSec)} · ⏎ queues${queued ? ` (${queued})` : ""}` })] }));
275
+ // Pre-first-token honesty (codex-parity): "waiting for the model" reads very differently from a
276
+ // 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);
278
+ return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "yellow", children: frames[frame % frames.length] }), _jsx(Text, { dimColor: true, children: ` ${verb} · ⏎ queues${queued ? ` (${queued})` : ""}` })] }));
273
279
  }
274
280
  // Short per-mode descriptions for the ONE-ROW mode line (the old two-row ModeBar's long sentences
275
281
  // don't fit inline). Full behavior is documented in /help; this line is a switching aid, not a manual.
@@ -89,7 +89,7 @@ function activeMention(value, cursor) {
89
89
  const MentionPopup = memo(function MentionPopup({ items, selected, query }) {
90
90
  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)))] }));
91
91
  });
92
- const TOKEN_RE = /\[Image #\d+\]/g;
92
+ const TOKEN_RE = /\[Image #\d+\]|\[Paste #\d+ \+\d+ lines\]/g;
93
93
  /** Split the value into text/image-token segments (image tokens render highlighted, codex-style). */
94
94
  function segmentize(value) {
95
95
  const parts = [];
@@ -239,6 +239,7 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
239
239
  const [sel, setSel] = useState(0);
240
240
  const [dismissed, setDismissed] = useState(false);
241
241
  const [images, setImages] = useState([]);
242
+ const [pastes, setPastes] = useState([]); // full text behind each [Paste #N] token
242
243
  const [mode, setMode] = useState("insert"); // vim only
243
244
  const [pending, setPending] = useState(""); // vim operator-pending (d/c/g)
244
245
  const [register, setRegister] = useState(""); // vim yank/delete register
@@ -260,12 +261,28 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
260
261
  setSel(0);
261
262
  setDismissed(false);
262
263
  };
264
+ // A big paste folds into a [Paste #N +L lines] token (Claude-Code/codex style) instead of flooding
265
+ // the box: typing stays smooth (the VALUE stays short), the box stays small, and a multi-line paste
266
+ // can no longer fire the newline-submit path mid-paste. Expanded back to the full text on submit.
267
+ const addPaste = (text) => {
268
+ const lines = text.split("\n").length;
269
+ const tok = `[Paste #${pastes.length + 1} +${lines} lines]`;
270
+ const before = value.slice(0, cursor);
271
+ const ins = (before && !/\s$/.test(before) ? " " : "") + tok + " ";
272
+ setValue(before + ins + value.slice(cursor));
273
+ setCursor((before + ins).length);
274
+ setPastes((xs) => [...xs, text]);
275
+ setSel(0);
276
+ setDismissed(false);
277
+ };
278
+ const expandPastes = (text) => text.replace(/\[Paste #(\d+) \+\d+ lines\]/g, (m, d) => pastes[Number(d) - 1] ?? m);
263
279
  const submit = (text) => {
264
280
  if (!text.trim() && images.length === 0)
265
281
  return; // nothing to send
266
- onSubmit?.(text, images.length ? images : undefined);
282
+ onSubmit?.(expandPastes(text), images.length ? images : undefined);
267
283
  set("", 0);
268
284
  setImages([]);
285
+ setPastes([]);
269
286
  setMode("insert"); // a fresh prompt starts in insert
270
287
  setPending("");
271
288
  };
@@ -354,6 +371,18 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
354
371
  if (key.backspace || key.delete) {
355
372
  if (cursor > 0) {
356
373
  const head = value.slice(0, cursor);
374
+ const pm = /\[Paste #(\d+) \+\d+ lines\]\s?$/.exec(head); // paste token deletes whole + renumbers
375
+ if (pm) {
376
+ const n = Number(pm[1]);
377
+ const kept = head.slice(0, pm.index) + value.slice(cursor);
378
+ const renumbered = kept.replace(/\[Paste #(\d+)( \+\d+ lines\])/g, (m2, d, tail) => (Number(d) > n ? `[Paste #${Number(d) - 1}${tail}` : m2));
379
+ setPastes((xs) => xs.filter((_, i) => i !== n - 1));
380
+ setValue(renumbered);
381
+ setCursor(pm.index);
382
+ setSel(0);
383
+ setDismissed(false);
384
+ return;
385
+ }
357
386
  const tm = /\[Image #(\d+)\]\s?$/.exec(head); // backspacing over an attachment token removes it whole
358
387
  if (tm) {
359
388
  const n = Number(tm[1]);
@@ -371,6 +400,12 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
371
400
  return;
372
401
  }
373
402
  if (input && !key.ctrl && !key.meta) {
403
+ // A LARGE paste (many lines or lots of text in one chunk) folds to a token — never submits,
404
+ // never floods the box. Small chunks keep the existing paste-to-run newline behavior.
405
+ if (input.length >= 600 || (input.match(/\n/g)?.length ?? 0) >= 3) {
406
+ addPaste(input);
407
+ return;
408
+ }
374
409
  const nl = input.search(/[\r\n]/); // a chunk carrying a newline (paste / fed input) submits
375
410
  if (nl >= 0) {
376
411
  submit(value.slice(0, cursor) + input.slice(0, nl) + value.slice(cursor));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.101.1",
3
+ "version": "0.103.0",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "dist/index.js"