@nanhara/hara 0.112.5 → 0.114.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,54 @@ 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.114.0 — long files read in slices · repeat-guard anti-spinning · `hara serve` (the desktop/IDE backbone)
9
+
10
+ - **Long files no longer flood the context.** `read_file` now returns cat-n numbered lines with
11
+ `offset`/`limit` slicing (2000-line default window, per-line truncation, a header that says how to
12
+ continue). The old behavior dumped the whole file (100K-char cap) — ~25k tokens per read, tail
13
+ unreachable. Paired prompt rules: grep-then-slice on long files, and **no whole-file re-read after a
14
+ successful edit** (the slowest habit an agent can have). This is the "hara is slow on long files" fix.
15
+ - **Repeat-guard — the anti-spinning tripwire.** When the EXACT same tool call (same tool, same
16
+ arguments) fails twice in a row, the result now carries an explicit "repeating this unchanged will
17
+ fail again — change something or ask the user" note. The guardian breaker covers DENIED actions;
18
+ this covers FAILED ones (observed: 4× the same `git pull` into a dead network). Successes reset the
19
+ streak; `/reset` clears it. Plus prompt rules: diagnose before retrying, two failed variants → step
20
+ back and re-plan.
21
+ - **`hara serve` — a persistent local server (WebSocket JSON-RPC v1)** that desktop shells, ACP and
22
+ IDE clients drive: `initialize` (token auth) · `session.create/resume/list/send/interrupt` ·
23
+ streamed `event.text/reasoning/tool/diff/notice/turn_end` · **approval round-trips**
24
+ (`approval.request` ⇄ `approval.reply`, 5-min deny-timeout, deny-on-disconnect) · sessions are the
25
+ SAME `~/.hara/sessions` store the CLI uses (single-writer lock respected) · `~/.hara/serve.json`
26
+ discovery file. This is the backbone the new hara desktop app (Tauri) speaks to.
27
+ - `tools/all.ts` — library entries (serve, embedders) now register the full built-in toolset; an
28
+ unregistered tool was silently unplannable.
29
+
30
+ ## 0.113.0 — DeepSeek reasoning control (thinking + effort, incl. `max`) · host-unreachable memory
31
+
32
+ - **DeepSeek reasoning is now a real dial.** DeepSeek's V4 models (`deepseek-v4-pro` / `deepseek-v4-flash`)
33
+ added a per-request thinking switch on the OpenAI-compatible chat path — `thinking: {type}` plus
34
+ `reasoning_effort` (native `high`|`max`; `low`/`medium` map → high server-side). hara now sends both via a
35
+ new `deepseek` reasoning style: **`off` → `thinking:{type:"disabled"}`** (reasoning_effort has no "off"),
36
+ any level → `thinking:{type:"enabled"}` + the effort. The `/model` picker's ←→ thinking dial now lights up
37
+ for DeepSeek. Verified against the live API: `off` emits no `reasoning_content`, `high`/`max` stream it
38
+ (`max` thinks measurably harder than `high`), and tool-calls work with reasoning on.
39
+ - **New `max` reasoning level.** `hara config set reasoningEffort max` (and the picker's ←→) now accept
40
+ **`max`**, the top of the dial. On DeepSeek it becomes `reasoning_effort:"max"`; on OpenAI reasoning models
41
+ it clamps to `high` (OpenAI has no `max`, so it never 400s); on Anthropic it takes the largest thinking
42
+ budget. Existing `off`/`low`/`medium`/`high` are unchanged.
43
+ - **Host-unreachable memory — stop re-hanging on a dead host.** When a network command (git clone/pull/fetch,
44
+ curl, …) fails to CONNECT — a TCP connect timeout or DNS failure (macOS's ~75s SYN timeout), *not* an
45
+ auth/404/connection-refused — hara now remembers that host for the session and **fast-fails later network
46
+ ops to it instantly** instead of eating another ~75s per retry. The failure output also flags that git
47
+ ignores the macOS system / Clash proxy unless configured (`git config --global http.proxy`). Cleared by
48
+ `/reset`. Paired with a system-prompt rule: reuse a local checkout before cloning, don't swap in a public
49
+ mirror for a private repo, and verify connectivity yourself rather than trusting "the network is fine".
50
+
51
+ ## 0.112.5 — single-writer session lock
52
+
53
+ - **A double-resume can no longer corrupt session history.** A single-writer lock serializes session writes
54
+ so two processes resuming the same session don't interleave and clobber the transcript.
55
+
8
56
  ## 0.112.4 — reasoning models don't false-timeout · cross-provider fallback routes correctly
9
57
 
10
58
  - **A reasoning model (qwen3.7-plus / GLM / DeepSeek) thinking on a long context no longer false-times-out.**
package/README.md CHANGED
@@ -127,6 +127,14 @@ hara config set visionModel qwen-vl-max # a vision model on the same plan/key
127
127
  If a model's capability is unknown, hara **asks once and remembers**. In the TUI, `/vision <model>` sets the
128
128
  describer and `/vision main yes|no|auto` corrects a model's detected capability.
129
129
 
130
+ **Reasoning effort** — dial how hard a thinking model reasons: `off` · `low` · `medium` · `high` · `max`.
131
+ ```bash
132
+ hara config set reasoningEffort high # or off / low / medium / max
133
+ ```
134
+ hara expresses it the way each endpoint wants (OpenAI `reasoning_effort`, Anthropic thinking budget,
135
+ DashScope `enable_thinking`, **DeepSeek** V4 `thinking` + `reasoning_effort` where `max` genuinely raises the
136
+ effort). In the TUI, bare `/model` opens a picker — ↑↓ pick a model, **←→ set the thinking level**.
137
+
130
138
  Config lives in `~/.hara/config.json`. Env vars override it: `HARA_PROVIDER`, `HARA_MODEL`,
131
139
  `HARA_BASE_URL`, `HARA_API_KEY`, or the provider key (`ANTHROPIC_API_KEY` / `DASHSCOPE_API_KEY`).
132
140
 
@@ -8,6 +8,7 @@ import { runHooks } from "../hooks.js";
8
8
  import { mapLimit, maxParallel } from "../concurrency.js";
9
9
  import { decideCommand, loadPermissionRules } from "../security/permissions.js";
10
10
  import { classifyRisk, guardianVeto, guardianEnabled, newBreaker, recordBlock } from "../security/guardian.js";
11
+ import { recordCall } from "./repeat-guard.js";
11
12
  import { subdirHint } from "../context/subdir-hints.js";
12
13
  import { classifyError, failoverAction, errorHint } from "./failover.js";
13
14
  import { currentTodos, renderTodos } from "../tools/todo.js";
@@ -57,7 +58,14 @@ them whole. Batch INDEPENDENT tool calls in a single response — especially rea
57
58
  glob / ls run in PARALLEL when requested together); one-call-per-turn exploration is the slowest thing
58
59
  you can do. When analyzing a project, start wide in ONE batch — manifest (package.json / Cargo.toml /
59
60
  pyproject.toml / go.mod), README, build/CI config — then chase only what the task needs with narrow
60
- grep/glob; don't read whole large files when a targeted search answers the question. For broad,
61
+ grep/glob; don't read whole large files when a targeted search answers the question. For a long file,
62
+ grep to locate then read_file just that region with offset/limit — not the whole file. After a successful
63
+ edit_file/write_file do NOT re-read the file to verify — the tool already applied and diffed the change;
64
+ re-reading a big file after every edit is the slowest habit an agent can have.
65
+ When an attempt FAILS, never repeat it unchanged — read the error, form a hypothesis about the cause, and
66
+ change something (arguments / approach / tool) before trying again. After two failed variants of the same
67
+ approach, stop: re-plan from what you learned, or ask the user, stating concisely what you tried and what
68
+ the errors said. Repeating a failed action hoping for a different result is how sessions die. For broad,
61
69
  open-ended exploration (more than ~3 searches), spawn \`agent\` sub-agents — several in one response for
62
70
  independent questions (role "explore") — each returns conclusions, not dumps. Messages the user sends
63
71
  mid-task arrive marked as interjections — triage them (refine current / queue as todo / urgent-switch)
@@ -67,8 +75,15 @@ memory: use memory_search before answering about prior decisions,
67
75
  conventions, or the user's preferences, and memory_write to proactively save durable facts you learn.
68
76
  When a task matches one of the Skills listed below, call the \`skill\` tool to load its full instructions
69
77
  before acting; save a reusable how-to as a new skill with skill_create. If you discover a durable project
70
- convention, you may propose an edit to AGENTS.md via edit_file (the user reviews the diff). After completing
71
- a task, give a one-line summary.`;
78
+ convention, you may propose an edit to AGENTS.md via edit_file (the user reviews the diff).
79
+ Network resilience: before \`git clone\`, check the target dir isn't already present (ls / test -d) and
80
+ reuse a local checkout instead of re-cloning. If a network command fails to CONNECT (timeout or DNS — not
81
+ auth/404), treat that host as down for the session: don't retry it, don't swap in a public mirror (mirrors
82
+ can't serve private repos), don't switch protocols — hara already fast-fails repeats to a dead host, so
83
+ diagnose instead. git ignores the macOS system / Clash proxy unless configured (git config --global
84
+ http.proxy), so a browser that reaches a site doesn't mean the terminal does — verify connectivity yourself
85
+ rather than trusting "the network is fine". If a step's output artifact already exists and is newer than its
86
+ inputs, skip re-running it. After completing a task, give a one-line summary.`;
72
87
  /** When running inside `hara gateway`, tell the agent it's in a chat — so it delivers files via send_file
73
88
  * (the only channel that reaches the peer) and never reaches for the desktop client / computer tool. */
74
89
  function gatewayNote() {
@@ -430,11 +445,13 @@ export async function runAgent(history, opts) {
430
445
  }
431
446
  const res = await p.tool.run(p.tu.input, ctx);
432
447
  // append any not-yet-seen subdirectory AGENTS.md/CLAUDE.md this call touched (monorepo-local conventions)
433
- results[idx] = { id: p.tu.id, name: p.tu.name, content: res + subdirHint(p.tu.input, ctx.cwd) };
448
+ // + the repeat-guard's anti-spinning note when this exact call keeps failing (repeat-guard.ts)
449
+ results[idx] = { id: p.tu.id, name: p.tu.name, content: res + subdirHint(p.tu.input, ctx.cwd) + recordCall(p.tu.name, p.tu.input, res) };
434
450
  runHooks("PostToolUse", p.tu.name, { input: p.tu.input, result: res }, ctx.cwd); // observe-only
435
451
  }
436
452
  catch (e) {
437
- results[idx] = { id: p.tu.id, name: p.tu.name, content: `Error: ${e.message}`, isError: true };
453
+ const msg = `Error: ${e.message}`;
454
+ results[idx] = { id: p.tu.id, name: p.tu.name, content: msg + recordCall(p.tu.name, p.tu.input, msg, true), isError: true };
438
455
  }
439
456
  finally {
440
457
  activity.dec();
@@ -0,0 +1,42 @@
1
+ // Repeat guard — the anti-spinning tripwire. The classic way an agent wastes a session is repeating the
2
+ // EXACT same failing tool call, unchanged, expecting a different result (observed: 4x `git pull` into the
3
+ // same wall; Nx the same failing build command). The guardian breaker only covers DENIED actions; this
4
+ // covers FAILED ones. Deterministic and session-scoped (module state, same pattern as net-reachability):
5
+ // when an identical (tool, args) call fails twice in a row, the tool result gets an explicit "stop
6
+ // repeating this" note the model can't miss. Successful repeats are NOT flagged — a re-read after an edit
7
+ // or a re-run after a fix is legitimate, and a success resets the failure streak.
8
+ const seen = new Map();
9
+ /** Identity of a call = tool name + exact JSON of its arguments (tool names contain no spaces,
10
+ * so a space separator is unambiguous). */
11
+ export function keyOf(name, input) {
12
+ try {
13
+ return name + " " + JSON.stringify(input ?? {});
14
+ }
15
+ catch {
16
+ return name + " <unserializable>";
17
+ }
18
+ }
19
+ /** Does a tool RESULT string look like a failure? hara tools report failures as ordinary strings
20
+ * (bash -> "Command failed: ...", file tools -> "Error: ...", net guard -> "Skipped without running: ..."),
21
+ * so the loop's isError flag alone misses them. Pure — exported for tests. */
22
+ export function looksFailed(content) {
23
+ return /^\s*(Command failed|Error\b|Skipped without running)/.test(content);
24
+ }
25
+ /** Record a completed call; returns a warning to APPEND to the tool result when the same call has now
26
+ * failed >=2x in a row (empty string otherwise). Pure aside from the session-scoped map. */
27
+ export function recordCall(name, input, content, isError = false) {
28
+ const k = keyOf(name, input);
29
+ const failed = isError || looksFailed(content);
30
+ const s = seen.get(k) ?? { fails: 0 };
31
+ s.fails = failed ? s.fails + 1 : 0; // success resets the streak
32
+ seen.set(k, s);
33
+ if (s.fails < 2)
34
+ return "";
35
+ return (`\n\n⟳ hara: this exact ${name} call has now FAILED ${s.fails}× with identical arguments — ` +
36
+ `repeating it unchanged will fail again. Read the error above, change something (arguments / approach / tool), ` +
37
+ `or step back and re-plan; if you're out of ideas, ask the user and say what you tried.`);
38
+ }
39
+ /** Clear the streaks — /reset (fresh start) and tests. */
40
+ export function resetRepeatGuard() {
41
+ seen.clear();
42
+ }
package/dist/config.js CHANGED
@@ -31,7 +31,7 @@ const PROVIDER_DEFAULTS = {
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
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", "fallbackProvider", "fallbackBaseURL", "fallbackApiKey", "reasoningEffort"];
34
- export const REASONING_EFFORTS = ["off", "low", "medium", "high"];
34
+ export const REASONING_EFFORTS = ["off", "low", "medium", "high", "max"];
35
35
  export const APPROVAL_MODES = ["suggest", "auto-edit", "full-auto"];
36
36
  export const SANDBOX_MODES = ["off", "workspace-write", "read-only"];
37
37
  const PROJECT_ROOT_MARKERS = [".git", "package.json", "Cargo.toml", "go.mod", "pyproject.toml", ".hg"];
@@ -164,7 +164,7 @@ export function loadConfig(opts = {}) {
164
164
  const fallbackBaseURL = process.env.HARA_FALLBACK_BASE_URL ?? merged.fallbackBaseURL;
165
165
  const fallbackApiKey = process.env.HARA_FALLBACK_API_KEY ?? merged.fallbackApiKey;
166
166
  const reasoningRaw = process.env.HARA_REASONING_EFFORT ?? merged.reasoningEffort;
167
- const reasoningEffort = reasoningRaw && ["off", "low", "medium", "high"].includes(reasoningRaw)
167
+ const reasoningEffort = reasoningRaw && ["off", "low", "medium", "high", "max"].includes(reasoningRaw)
168
168
  ? reasoningRaw
169
169
  : undefined;
170
170
  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, fallbackProvider, fallbackBaseURL, fallbackApiKey, reasoningEffort, mcpServers, cwd: process.cwd() };
package/dist/index.js CHANGED
@@ -39,6 +39,8 @@ import { addJob, removeJob, setEnabled, resolveJob, loadJobs, recordRun, logPath
39
39
  import { runTick, runJobOnce, selfArgv } from "./cron/runner.js";
40
40
  import { installScheduler, uninstallScheduler, isInstalled } from "./cron/install.js";
41
41
  import { getTools } from "./tools/registry.js";
42
+ import { resetReachability } from "./tools/net-reachability.js";
43
+ import { resetRepeatGuard } from "./agent/repeat-guard.js";
42
44
  import { EXPLORE_SYSTEM } from "./tools/agent.js";
43
45
  import { createAnthropicProvider } from "./providers/anthropic.js";
44
46
  import { createOpenAIProvider } from "./providers/openai.js";
@@ -1552,6 +1554,44 @@ program
1552
1554
  const cwd = opts.cwd ? (await import("node:path")).resolve(opts.cwd) : undefined; // undefined → ~/.hara/workspace
1553
1555
  await mod.runGateway({ cwd, platform: opts.platform });
1554
1556
  });
1557
+ program
1558
+ .command("serve")
1559
+ .description("run the local hara server (WebSocket JSON-RPC) that desktop shells / IDE clients drive — persistent sessions, streaming events, approval round-trips")
1560
+ .option("--host <host>", "bind address — keep it loopback unless you know what you're doing", "127.0.0.1")
1561
+ .option("--port <n>", "port to listen on", "8790")
1562
+ .option("--token <token>", "auth token (default: generated; written to ~/.hara/serve.json for clients to discover)")
1563
+ .option("--cwd <dir>", "default working directory for new sessions (default: current directory)")
1564
+ .option("--approval <mode>", "default approval mode for sessions: suggest | auto-edit | full-auto", "auto-edit")
1565
+ .action(async (o) => {
1566
+ const cfg = loadConfig();
1567
+ const provider0 = await withRouting(await buildProvider(cfg), cfg);
1568
+ if (!provider0) {
1569
+ out(c.red(`Not authenticated for '${cfg.provider}' — run \`hara setup\` first.\n`));
1570
+ process.exit(1);
1571
+ }
1572
+ const guardianOpt = await buildGuardian(cfg, provider0);
1573
+ const cwd = o.cwd ? (await import("node:path")).resolve(o.cwd) : process.cwd();
1574
+ const sandbox = (process.env.HARA_SANDBOX ?? cfg.sandbox ?? "off");
1575
+ const approval = APPROVAL_MODES.includes(o.approval) ? o.approval : "auto-edit";
1576
+ const { startServe } = await import("./serve/server.js");
1577
+ const handle = await startServe({ host: o.host, port: Number(o.port) || 8790, token: o.token, cwd }, {
1578
+ version: pkg.version,
1579
+ providerId: cfg.provider,
1580
+ model: cfg.model,
1581
+ buildSessionProvider: async () => withRouting(await buildProvider(cfg), cfg),
1582
+ spawnSubagent: (provider, scwd, projectContext, stats, task, role) => runSubagent(cfg, provider, scwd, sandbox, projectContext, stats, task, role),
1583
+ guardian: guardianOpt,
1584
+ sandbox,
1585
+ approval,
1586
+ });
1587
+ out(c.bold("hara serve") + c.dim(` · ws://${o.host}:${handle.port} · ${cfg.provider}:${cfg.model} · approval ${approval} · token → ~/.hara/serve.json\n`));
1588
+ const bye = async () => {
1589
+ await handle.close();
1590
+ process.exit(0);
1591
+ };
1592
+ process.on("SIGINT", bye);
1593
+ process.on("SIGTERM", bye);
1594
+ });
1555
1595
  program
1556
1596
  .command("remote [action] [text]")
1557
1597
  .description("drive THIS tmux session from chat: register the pane so WeChat replies inject back into it. actions: ask \"<q>\" | bind | back | status")
@@ -2926,6 +2966,8 @@ program.action(async (opts) => {
2926
2966
  if (nm === "reset" || nm === "clear") {
2927
2967
  history.length = 0;
2928
2968
  recalledContext = "";
2969
+ resetReachability(); // fresh start — drop any "host unreachable" marks (network may be fixed)
2970
+ resetRepeatGuard(); // …and the repeated-failure streaks (the user may have fixed the cause)
2929
2971
  return void h.sink.notice("(context cleared)");
2930
2972
  }
2931
2973
  if (nm === "undo") {
@@ -106,6 +106,8 @@ export function buildThinkingParam(model, effort) {
106
106
  return { type: "enabled", budget_tokens: 4096 };
107
107
  if (effort === "medium")
108
108
  return { type: "adaptive" };
109
+ if (effort === "max")
110
+ return { type: "enabled", budget_tokens: 32000 }; // top of the dial — biggest budget
109
111
  // high
110
112
  return { type: "enabled", budget_tokens: 24000 };
111
113
  }
@@ -26,11 +26,19 @@ export function reasoningParams(style, effort, model = "") {
26
26
  case "reasoning_effort":
27
27
  if (!isReasoningModel(model))
28
28
  return {};
29
- return { reasoning_effort: effort === "off" ? "minimal" : effort };
29
+ // OpenAI's ceiling is "high"; there's no "max" clamp so the global `max` dial never 400s here.
30
+ return { reasoning_effort: effort === "off" ? "minimal" : effort === "max" ? "high" : effort };
30
31
  case "reasoning_object":
31
32
  if (!isReasoningModel(model))
32
33
  return {};
33
- return { reasoning: { effort: effort === "off" ? "minimal" : effort } };
34
+ return { reasoning: { effort: effort === "off" ? "minimal" : effort === "max" ? "high" : effort } };
35
+ case "deepseek":
36
+ // off → turn thinking OFF via the object (reasoning_effort can't say "off"). Any level → thinking ON
37
+ // + the effort enum; DeepSeek natively honors high|max and maps low/medium→high server-side, so we
38
+ // pass the dial through as-is (forward-compatible if DeepSeek later distinguishes low/medium).
39
+ if (effort === "off")
40
+ return { thinking: { type: "disabled" } };
41
+ return { thinking: { type: "enabled" }, reasoning_effort: effort };
34
42
  case "thinking_budget": // Anthropic — applied by anthropic.ts, not on a chat/responses merge body
35
43
  case "none":
36
44
  default:
@@ -20,9 +20,9 @@ const BY_BASEURL = [
20
20
  // GLM, MiniMax, Aliyun apps/anthropic … all expose `.../anthropic` with thinking + explicit cache. One
21
21
  // row covers the whole ecosystem — talk to it with the anthropic transport.
22
22
  { test: /\/anthropic\/?($|\?)/i, caps: { wireApi: "anthropic", reasoning: "thinking_budget", cache: "cache_control" } },
23
- // DeepSeek OpenAI-compatible (chat): the model id picks reasoning (deepseek-reasoner reasons, -chat
24
- // doesn't); there's no per-request toggle on this path leave it alone.
25
- { test: /api\.deepseek\.com/i, caps: { wireApi: "chat", reasoning: "none", cache: "auto" } },
23
+ // DeepSeek OpenAI-compatible (chat): DeepSeek V4 (v4-pro/v4-flash) exposes a per-request thinking switch
24
+ // (`thinking: {type}`) + `reasoning_effort` (high|max) on this path the `deepseek` style sends both.
25
+ { test: /api\.deepseek\.com/i, caps: { wireApi: "chat", reasoning: "deepseek", cache: "auto" } },
26
26
  ];
27
27
  /** Per-provider-id overrides (built-in providers whose id alone fixes the shape). */
28
28
  const BY_PROVIDER = {
@@ -30,7 +30,7 @@ const BY_PROVIDER = {
30
30
  qwen: { wireApi: "chat", reasoning: "enable_thinking", cache: "auto" }, // DashScope
31
31
  "qwen-oauth": { wireApi: "chat", reasoning: "enable_thinking", cache: "auto" },
32
32
  glm: { wireApi: "chat", reasoning: "none", cache: "auto" }, // Zhipu native /paas/v4 — different thinking param; leave alone (its /anthropic endpoint resolves via baseURL)
33
- deepseek: { wireApi: "chat", reasoning: "none", cache: "auto" },
33
+ deepseek: { wireApi: "chat", reasoning: "deepseek", cache: "auto" }, // V4: thinking:{type} + reasoning_effort(high|max)
34
34
  ollama: { wireApi: "chat", reasoning: "ollama_think", cache: "none" }, // local; `think` toggles reasoning
35
35
  openai: { wireApi: "chat", reasoning: "reasoning_effort", cache: "auto" },
36
36
  openrouter: { wireApi: "chat", reasoning: "none", cache: "auto" },
@@ -0,0 +1,58 @@
1
+ // hara serve protocol v1 — JSON-RPC 2.0 over WebSocket text frames. This is the contract the desktop
2
+ // shell (and any ACP/IDE client) speaks; the transport lives in server.ts, sessions in sessions.ts.
3
+ // Everything here is PURE (parse + frame builders + error codes) and unit-tested.
4
+ //
5
+ // Client → server requests:
6
+ // initialize {token} → {name,version,protocol,cwd,provider,model}
7
+ // session.list {cwd?} → {sessions:[{id,title,cwd,model,updatedAt}]}
8
+ // session.create {cwd?,approval?} → {sessionId,model}
9
+ // session.resume {sessionId} → {sessionId,model,history:[{role,text}]}
10
+ // session.send {sessionId,text} → (streams events, then) {reply,usage}
11
+ // session.interrupt {sessionId} → {}
12
+ // approval.reply {approvalId,allow,always?} → {}
13
+ // Server → client notifications (all carry sessionId):
14
+ // event.text / event.reasoning {delta} · event.tool {name,preview} · event.diff {text}
15
+ // event.notice {text} · event.turn_end {reply,usage,error?} · approval.request {approvalId,question}
16
+ export const PROTOCOL_VERSION = 1;
17
+ /** JSON-RPC error codes: standard ones plus hara-specific (-320xx). */
18
+ export const ERR = {
19
+ PARSE: -32700,
20
+ INVALID: -32600,
21
+ METHOD: -32601,
22
+ PARAMS: -32602,
23
+ INTERNAL: -32603,
24
+ UNAUTHORIZED: -32001, // initialize first (or bad token)
25
+ BUSY: -32002, // session already has a turn in flight
26
+ NO_SESSION: -32003, // unknown/expired sessionId
27
+ LOCKED: -32004, // session held by another live hara process (single-writer lock)
28
+ };
29
+ /** Parse one inbound text frame into a request. Returns {error} (never throws) on malformed input —
30
+ * the transport turns that into a PARSE/INVALID error response. */
31
+ export function parseFrame(raw) {
32
+ let v;
33
+ try {
34
+ v = JSON.parse(raw);
35
+ }
36
+ catch {
37
+ return { error: "not JSON" };
38
+ }
39
+ const o = v;
40
+ if (!o || typeof o !== "object" || o.jsonrpc !== "2.0" || typeof o.method !== "string" || !o.method) {
41
+ return { error: "not a JSON-RPC 2.0 request" };
42
+ }
43
+ if (o.id !== undefined && typeof o.id !== "number" && typeof o.id !== "string")
44
+ return { error: "bad id" };
45
+ if (o.params !== undefined && (typeof o.params !== "object" || o.params === null || Array.isArray(o.params))) {
46
+ return { error: "params must be an object" };
47
+ }
48
+ return { req: o };
49
+ }
50
+ export function rpcResult(id, result) {
51
+ return JSON.stringify({ jsonrpc: "2.0", id, result });
52
+ }
53
+ export function rpcError(id, code, message) {
54
+ return JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } });
55
+ }
56
+ export function rpcNotify(method, params) {
57
+ return JSON.stringify({ jsonrpc: "2.0", method, params });
58
+ }
@@ -0,0 +1,228 @@
1
+ // hara serve — the persistent local server (WebSocket JSON-RPC, protocol.ts) that desktop shells, ACP
2
+ // clients, and IDE plugins drive. codex's app-server layering in TypeScript: shell ↔ protocol ↔ agent
3
+ // core, with the agent core (runAgent + plugins + skills + memory) running IN-PROCESS — plugins need no
4
+ // bridging. Provider building / subagent spawn / guardian stay in index.ts and are injected as ServeDeps
5
+ // (no import cycle back into the CLI entry).
6
+ import { WebSocketServer } from "ws";
7
+ import { randomBytes, randomUUID, timingSafeEqual, createHash } from "node:crypto";
8
+ import { writeFileSync, unlinkSync, mkdirSync } from "node:fs";
9
+ import { homedir } from "node:os";
10
+ import { join } from "node:path";
11
+ import "../tools/all.js"; // register the full built-in toolset — serve must work as a standalone entry
12
+ import { runAgent } from "../agent/loop.js";
13
+ import { loadAgentsMd } from "../context/agents-md.js";
14
+ import { memoryDigest } from "../memory/store.js";
15
+ import { SessionHub, realStore } from "./sessions.js";
16
+ import { parseFrame, rpcResult, rpcError, rpcNotify, ERR, PROTOCOL_VERSION } from "./protocol.js";
17
+ const APPROVAL_TIMEOUT_MS = 300_000; // an unanswered approval denies after 5 min (never hangs a turn)
18
+ const sameToken = (a, b) => {
19
+ // constant-time compare over digests (inputs differ in length)
20
+ const ha = createHash("sha256").update(a).digest();
21
+ const hb = createHash("sha256").update(b).digest();
22
+ return timingSafeEqual(ha, hb);
23
+ };
24
+ /** Last assistant text in a history — the turn's "reply" for request/response clients. */
25
+ export function lastAssistantText(history) {
26
+ for (let i = history.length - 1; i >= 0; i--) {
27
+ const m = history[i];
28
+ if (m.role === "assistant")
29
+ return m.text ?? "";
30
+ }
31
+ return "";
32
+ }
33
+ /** Compact history for session.resume — enough for a client to render the transcript. */
34
+ export function historyForClient(history) {
35
+ const out = [];
36
+ for (const m of history) {
37
+ if (m.role === "user")
38
+ out.push({ role: "user", text: m.content });
39
+ else if (m.role === "assistant" && m.text)
40
+ out.push({ role: "assistant", text: m.text });
41
+ // tool results are omitted — clients see live tool events; persisted detail stays in the store
42
+ }
43
+ return out;
44
+ }
45
+ export async function startServe(opts, deps) {
46
+ const token = opts.token ?? randomBytes(16).toString("hex");
47
+ const hub = new SessionHub(deps.store ?? realStore);
48
+ const wss = new WebSocketServer({ host: opts.host, port: opts.port, maxPayload: 10 * 1024 * 1024 });
49
+ await new Promise((res, rej) => {
50
+ wss.once("listening", res);
51
+ wss.once("error", rej);
52
+ });
53
+ const port = wss.address().port;
54
+ const authed = new Set();
55
+ const pendingApprovals = new Map();
56
+ const broadcast = (method, params) => {
57
+ const frame = rpcNotify(method, params);
58
+ for (const ws of authed)
59
+ if (ws.readyState === ws.OPEN)
60
+ ws.send(frame);
61
+ };
62
+ // Discovery file — the desktop shell reads this to find the running server (like a pid/port file).
63
+ const discoveryPath = join(homedir(), ".hara", "serve.json");
64
+ if (!deps.quietDiscovery) {
65
+ mkdirSync(join(homedir(), ".hara"), { recursive: true });
66
+ writeFileSync(discoveryPath, JSON.stringify({ host: opts.host, port, token, pid: process.pid, version: deps.version }, null, 2), { mode: 0o600 });
67
+ }
68
+ /** Run one turn on a session, streaming events to all authed clients. */
69
+ const runTurn = async (s, text) => {
70
+ const sessionId = s.meta.id;
71
+ s.busy = true;
72
+ s.abort = new AbortController();
73
+ const before = { input: s.stats.input, output: s.stats.output };
74
+ const sink = {
75
+ text: (d) => broadcast("event.text", { sessionId, delta: d }),
76
+ reasoning: (d) => broadcast("event.reasoning", { sessionId, delta: d }),
77
+ tool: (name, preview) => broadcast("event.tool", { sessionId, name, preview }),
78
+ diff: (t) => broadcast("event.diff", { sessionId, text: t }),
79
+ notice: (t) => broadcast("event.notice", { sessionId, text: t }),
80
+ };
81
+ const confirm = (q) => new Promise((resolve) => {
82
+ const approvalId = randomUUID();
83
+ const timer = setTimeout(() => {
84
+ if (pendingApprovals.delete(approvalId))
85
+ resolve(false); // unanswered → deny, turn continues
86
+ }, APPROVAL_TIMEOUT_MS);
87
+ pendingApprovals.set(approvalId, (v) => {
88
+ clearTimeout(timer);
89
+ pendingApprovals.delete(approvalId);
90
+ resolve(v);
91
+ });
92
+ broadcast("approval.request", { sessionId, approvalId, question: q });
93
+ });
94
+ try {
95
+ s.history.push({ role: "user", content: text });
96
+ await runAgent(s.history, {
97
+ provider: s.provider,
98
+ ctx: {
99
+ cwd: s.meta.cwd,
100
+ sandbox: deps.sandbox,
101
+ spawn: (t, role) => deps.spawnSubagent(s.provider, s.meta.cwd, s.projectContext, s.stats, t, role),
102
+ ui: sink,
103
+ },
104
+ approval: s.approval,
105
+ confirm,
106
+ autoApprove: s.autoApprove,
107
+ projectContext: s.projectContext,
108
+ memory: memoryDigest(s.meta.cwd),
109
+ stats: s.stats,
110
+ signal: s.abort.signal,
111
+ guardian: deps.guardian,
112
+ });
113
+ hub.save(s);
114
+ const usage = { input: s.stats.input - before.input, output: s.stats.output - before.output };
115
+ const reply = lastAssistantText(s.history);
116
+ broadcast("event.turn_end", { sessionId, reply, usage });
117
+ return { reply, usage };
118
+ }
119
+ finally {
120
+ s.busy = false;
121
+ s.abort = null;
122
+ }
123
+ };
124
+ wss.on("connection", (ws) => {
125
+ ws.on("message", async (raw) => {
126
+ const parsed = parseFrame(String(raw));
127
+ if ("error" in parsed)
128
+ return void ws.send(rpcError(null, ERR.PARSE, parsed.error));
129
+ const { req } = parsed;
130
+ const id = req.id ?? null;
131
+ const reply = (frame) => void (id !== null && ws.send(frame));
132
+ const p = (req.params ?? {});
133
+ try {
134
+ if (req.method === "initialize") {
135
+ if (typeof p.token !== "string" || !sameToken(p.token, token))
136
+ return reply(rpcError(id, ERR.UNAUTHORIZED, "bad token"));
137
+ authed.add(ws);
138
+ return reply(rpcResult(id, { name: "hara", version: deps.version, protocol: PROTOCOL_VERSION, cwd: opts.cwd, provider: deps.providerId, model: deps.model }));
139
+ }
140
+ if (!authed.has(ws))
141
+ return reply(rpcError(id, ERR.UNAUTHORIZED, "initialize first"));
142
+ switch (req.method) {
143
+ case "session.list":
144
+ return reply(rpcResult(id, { sessions: hub.list(typeof p.cwd === "string" ? p.cwd : undefined).map((m) => ({ id: m.id, title: m.title, cwd: m.cwd, model: m.model, updatedAt: m.updatedAt })) }));
145
+ case "session.create": {
146
+ const provider = await deps.buildSessionProvider();
147
+ if (!provider)
148
+ return reply(rpcError(id, ERR.INTERNAL, "provider not authenticated — run `hara setup`"));
149
+ const cwd = typeof p.cwd === "string" && p.cwd ? p.cwd : opts.cwd;
150
+ const approval = ["suggest", "auto-edit", "full-auto"].includes(p.approval) ? p.approval : deps.approval;
151
+ const s = hub.create({ cwd, provider, providerId: deps.providerId, model: deps.model, approval, projectContext: loadAgentsMd(cwd) || undefined });
152
+ return reply(rpcResult(id, { sessionId: s.meta.id, model: s.meta.model }));
153
+ }
154
+ case "session.resume": {
155
+ if (typeof p.sessionId !== "string")
156
+ return reply(rpcError(id, ERR.PARAMS, "sessionId required"));
157
+ const provider = await deps.buildSessionProvider();
158
+ if (!provider)
159
+ return reply(rpcError(id, ERR.INTERNAL, "provider not authenticated — run `hara setup`"));
160
+ const r = hub.resume(p.sessionId, { provider, approval: deps.approval, projectContext: undefined });
161
+ if ("missing" in r)
162
+ return reply(rpcError(id, ERR.NO_SESSION, `no session ${p.sessionId}`));
163
+ if ("lockedBy" in r)
164
+ return reply(rpcError(id, ERR.LOCKED, `session held by live pid ${r.lockedBy}`));
165
+ r.session.projectContext = loadAgentsMd(r.session.meta.cwd) || undefined;
166
+ return reply(rpcResult(id, { sessionId: r.session.meta.id, model: r.session.meta.model, history: historyForClient(r.session.history) }));
167
+ }
168
+ case "session.send": {
169
+ if (typeof p.sessionId !== "string" || typeof p.text !== "string" || !p.text)
170
+ return reply(rpcError(id, ERR.PARAMS, "sessionId + text required"));
171
+ const s = hub.get(p.sessionId);
172
+ if (!s)
173
+ return reply(rpcError(id, ERR.NO_SESSION, `no live session ${p.sessionId} — session.create/resume first`));
174
+ if (s.busy)
175
+ return reply(rpcError(id, ERR.BUSY, "a turn is already running on this session"));
176
+ const r = await runTurn(s, p.text);
177
+ return reply(rpcResult(id, r));
178
+ }
179
+ case "session.interrupt": {
180
+ const s = typeof p.sessionId === "string" ? hub.get(p.sessionId) : undefined;
181
+ if (!s)
182
+ return reply(rpcError(id, ERR.NO_SESSION, "no such live session"));
183
+ s.abort?.abort();
184
+ return reply(rpcResult(id, {}));
185
+ }
186
+ case "approval.reply": {
187
+ if (typeof p.approvalId !== "string")
188
+ return reply(rpcError(id, ERR.PARAMS, "approvalId required"));
189
+ const resolve = pendingApprovals.get(p.approvalId);
190
+ if (resolve)
191
+ resolve(p.always === true ? "always" : p.allow === true);
192
+ return reply(rpcResult(id, {})); // idempotent — a late/duplicate reply is a no-op
193
+ }
194
+ default:
195
+ return reply(rpcError(id, ERR.METHOD, `unknown method ${req.method}`));
196
+ }
197
+ }
198
+ catch (e) {
199
+ return reply(rpcError(id, ERR.INTERNAL, String(e?.message ?? e)));
200
+ }
201
+ });
202
+ ws.on("close", () => {
203
+ authed.delete(ws);
204
+ if (authed.size === 0) {
205
+ // nobody left to answer — deny pending approvals now instead of stalling turns for the timeout
206
+ for (const resolve of pendingApprovals.values())
207
+ resolve(false);
208
+ pendingApprovals.clear();
209
+ }
210
+ });
211
+ });
212
+ const close = async () => {
213
+ for (const resolve of pendingApprovals.values())
214
+ resolve(false);
215
+ pendingApprovals.clear();
216
+ hub.releaseAll();
217
+ if (!deps.quietDiscovery) {
218
+ try {
219
+ unlinkSync(discoveryPath);
220
+ }
221
+ catch {
222
+ /* already gone */
223
+ }
224
+ }
225
+ await new Promise((res) => wss.close(() => res()));
226
+ };
227
+ return { port, token, close };
228
+ }
@@ -0,0 +1,67 @@
1
+ import { newSessionId, saveSession, loadSession, listSessions, acquireSessionLock, releaseSessionLock, deriveTitle, } from "../session/store.js";
2
+ /** The real ~/.hara/sessions store (default). */
3
+ export const realStore = {
4
+ load: loadSession,
5
+ save: saveSession,
6
+ list: listSessions,
7
+ acquire: acquireSessionLock,
8
+ release: releaseSessionLock,
9
+ };
10
+ export class SessionHub {
11
+ store;
12
+ sessions = new Map();
13
+ constructor(store = realStore) {
14
+ this.store = store;
15
+ }
16
+ create(o) {
17
+ const meta = {
18
+ id: newSessionId(),
19
+ cwd: o.cwd,
20
+ provider: o.providerId,
21
+ model: o.model,
22
+ title: "",
23
+ createdAt: new Date().toISOString(),
24
+ updatedAt: "",
25
+ };
26
+ this.store.acquire(meta.id); // fresh id — always ours; registers the single-writer claim
27
+ const s = { meta, history: [], provider: o.provider, approval: o.approval, autoApprove: new Set(), stats: { input: 0, output: 0 }, projectContext: o.projectContext, busy: false, abort: null };
28
+ this.sessions.set(meta.id, s);
29
+ return s;
30
+ }
31
+ /** Resume a persisted session. Returns the live session, or a lock/missing failure. */
32
+ resume(id, o) {
33
+ const live = this.sessions.get(id);
34
+ if (live)
35
+ return { session: live }; // already attached to this server
36
+ const prior = this.store.load(id);
37
+ if (!prior)
38
+ return { missing: true };
39
+ const lock = this.store.acquire(id);
40
+ if (!lock.ok)
41
+ return { lockedBy: lock.pid ?? 0 };
42
+ const s = { meta: prior.meta, history: [...prior.history], provider: o.provider, approval: o.approval, autoApprove: new Set(), stats: { input: 0, output: 0 }, projectContext: o.projectContext, busy: false, abort: null };
43
+ this.sessions.set(id, s);
44
+ return { session: s };
45
+ }
46
+ get(id) {
47
+ return this.sessions.get(id);
48
+ }
49
+ list(cwd) {
50
+ return this.store.list(cwd);
51
+ }
52
+ /** Persist a session after a turn (sets a title from the first user message once). */
53
+ save(s) {
54
+ if (!s.meta.title) {
55
+ const first = s.history.find((m) => m.role === "user");
56
+ if (first && "content" in first && typeof first.content === "string")
57
+ s.meta.title = deriveTitle(first.content);
58
+ }
59
+ this.store.save(s.meta, s.history);
60
+ }
61
+ /** Release all locks (server shutdown). In-flight turns are aborted by the caller first. */
62
+ releaseAll() {
63
+ for (const id of this.sessions.keys())
64
+ this.store.release(id);
65
+ this.sessions.clear();
66
+ }
67
+ }
@@ -0,0 +1,19 @@
1
+ // Side-effect aggregate: register EVERY built-in tool. The CLI entry (index.ts) has always done these
2
+ // imports itself; library entries (serve/server.ts, future embedders) import THIS so the registry is
3
+ // never empty when runAgent plans a turn — an unregistered tool is silently unplannable, which shows up
4
+ // as "the model called write_file and nothing happened".
5
+ import "./builtin.js"; // read_file / write_file / bash / job
6
+ import "./edit.js"; // edit_file
7
+ import "./search.js"; // grep / glob / ls
8
+ import "./patch.js"; // apply_patch
9
+ import "./web.js"; // web_search / web_fetch
10
+ import "./agent.js"; // agent (subagent spawn)
11
+ import "./memory.js"; // memory_search/get/write/forget + skill_create
12
+ import "./skill.js"; // skill loader
13
+ import "./codebase.js"; // codebase_search
14
+ import "./todo.js"; // todo_write
15
+ import "./send.js"; // send_file (self-gates on HARA_GATEWAY)
16
+ import "./external_agent.js"; // external_agent (claude-code / codex delegation)
17
+ import "./ask_user.js"; // ask_user
18
+ import "./cron.js"; // cronjob
19
+ import "./computer.js"; // computer (desktop control; self-gates on config)
@@ -7,7 +7,28 @@ import { nearestPaths } from "../fs-walk.js";
7
7
  import { emitDiff } from "../diff.js";
8
8
  import { recordEdit } from "../undo.js";
9
9
  import { startJob, listJobs, tailJob, killJob } from "../exec/jobs.js";
10
+ import { hostsInCommand, isNetworkGitOp, hostFromConnectError, isConnectFailure, markHostUnreachable, isHostUnreachable, unreachableHostsSnapshot, } from "./net-reachability.js";
10
11
  const MAX = 100_000;
12
+ /** Resolve the remote HOST a bare `git pull/fetch/push` targets (no URL in the command → host lives in the
13
+ * repo's remote config). Local + fast (no network); best-effort — returns "" on any hiccup. Only ever
14
+ * called after a host has already been marked unreachable, so it adds zero overhead on the happy path. */
15
+ async function gitRemoteHost(command, cwd, sandbox) {
16
+ const m = command.match(/\bgit\b[^\n]*\b(?:fetch|pull|push)\b\s+(?!-)(\S+)/);
17
+ const remote = m && /^[\w./-]+$/.test(m[1]) ? m[1] : "origin";
18
+ try {
19
+ const { stdout } = await runShell(`git remote get-url ${remote}`, cwd, sandbox, { timeout: 5000, maxBuffer: 65536 });
20
+ return hostsInCommand(stdout.trim())[0] ?? "";
21
+ }
22
+ catch {
23
+ return "";
24
+ }
25
+ }
26
+ /** git ignores the macOS system / Clash proxy unless told to — so a browser that reaches GitHub doesn't
27
+ * mean the terminal does. Appended to connectivity-failure output so the agent diagnoses instead of retrying. */
28
+ function proxyHint(host) {
29
+ const h = host || "the remote host";
30
+ return `\n\n↯ hara: this is a CONNECTIVITY failure to ${h} (timeout/DNS), not an auth error — I will NOT retry network ops to ${h} for the rest of this session (a repeat just hangs ~75s again). git does NOT use the macOS system/Clash proxy unless configured; check \`git config --global http.proxy\` and \`echo $https_proxy $ALL_PROXY\`. If you've since started a VPN/proxy or fixed DNS, tell me and I'll clear the mark and retry.`;
31
+ }
11
32
  function abs(p, cwd) {
12
33
  return isAbsolute(p) ? p : resolve(cwd, p);
13
34
  }
@@ -22,20 +43,48 @@ export function capHeadTail(s, max = MAX) {
22
43
  const head = Math.floor(max * 0.6);
23
44
  return s.slice(0, head) + `\n…[${s.length - max} chars truncated]…\n` + s.slice(s.length - (max - head));
24
45
  }
46
+ const READ_LINES = 2000; // default lines per read_file call (a long file is read in slices, not dumped whole)
47
+ const LINE_CAP = 2000; // chars per line before truncation (minified bundles / data lines)
48
+ /** Render a line slice of a file, cat -n style. The old read_file dumped the WHOLE file (100K-char cap,
49
+ * tail simply lost) — on long files that both flooded the context (~25k tokens per read, again on every
50
+ * re-read) and made everything past the cap unreachable. Now: line numbers (anchor for edits and for
51
+ * "read around line N"), a default window of READ_LINES, and a header that says how to continue. Pure —
52
+ * exported for tests. */
53
+ export function renderFileSlice(text, offset, limit) {
54
+ const lines = text.split("\n");
55
+ // A trailing newline yields one phantom "" line at the end — don't count or show it.
56
+ if (lines.length > 1 && lines[lines.length - 1] === "")
57
+ lines.pop();
58
+ const total = lines.length;
59
+ const start = Math.max(1, Math.floor(offset ?? 1));
60
+ const want = Math.max(1, Math.floor(limit ?? READ_LINES));
61
+ if (start > total)
62
+ return `(file has ${total} lines — offset ${start} is past the end)`;
63
+ const end = Math.min(total, start + want - 1);
64
+ const body = lines
65
+ .slice(start - 1, end)
66
+ .map((l, i) => `${String(start + i).padStart(6)}\t${l.length > LINE_CAP ? l.slice(0, LINE_CAP) + `…[+${l.length - LINE_CAP} chars]` : l}`)
67
+ .join("\n");
68
+ const sliced = start > 1 || end < total;
69
+ const head = sliced ? `(lines ${start}–${end} of ${total}${end < total ? ` — continue with offset:${end + 1}` : ""})\n` : "";
70
+ return head + body;
71
+ }
25
72
  registerTool({
26
73
  name: "read_file",
27
- description: "Read a UTF-8 text file and return its contents.",
74
+ description: "Read a UTF-8 text file; returns cat -n style numbered lines. Reads up to 2000 lines by default — for a longer file pass offset/limit to read the next slice (the output header tells you the total and where to continue). Prefer grep to locate, then read just that region.",
28
75
  input_schema: {
29
76
  type: "object",
30
77
  properties: {
31
78
  path: { type: "string", description: "File path, relative to cwd or absolute" },
79
+ offset: { type: "number", description: "1-based line number to start from (for long files)" },
80
+ limit: { type: "number", description: "max lines to return (default 2000)" },
32
81
  },
33
82
  required: ["path"],
34
83
  },
35
84
  kind: "read",
36
85
  async run(input, ctx) {
37
86
  try {
38
- return cap(await readFile(abs(input.path, ctx.cwd), "utf8"));
87
+ return cap(renderFileSlice(await readFile(abs(input.path, ctx.cwd), "utf8"), input.offset, input.limit));
39
88
  }
40
89
  catch (e) {
41
90
  const near = nearestPaths(ctx.cwd, input.path);
@@ -89,6 +138,22 @@ registerTool({
89
138
  const id = startJob(input.command, ctx.cwd, ctx.sandbox ?? "off");
90
139
  return `Started background job ${id}: \`${input.command}\`. Manage with the \`job\` tool — {action:"tail",id:"${id}"} for output, {action:"kill",id:"${id}"} to stop, {action:"list"} for all.`;
91
140
  }
141
+ // Network fault tolerance — short-circuit if this command targets a host already found unreachable this
142
+ // session, so a repeat doesn't burn another ~75s OS connect timeout. Only pays the git-remote lookup
143
+ // once something is actually marked (unreachableHostsSnapshot empty ⇒ zero overhead on the happy path).
144
+ if (unreachableHostsSnapshot().length) {
145
+ const explicit = hostsInCommand(input.command);
146
+ let blocked = explicit.find(isHostUnreachable) ?? "";
147
+ if (!blocked && !explicit.length && isNetworkGitOp(input.command)) {
148
+ const h = await gitRemoteHost(input.command, ctx.cwd, ctx.sandbox ?? "off");
149
+ if (h && isHostUnreachable(h))
150
+ blocked = h;
151
+ }
152
+ if (blocked) {
153
+ ctx.ui?.notice(`↯ skipping — ${blocked} was unreachable earlier this session`);
154
+ return `Skipped without running: host "${blocked}" already failed to connect earlier in THIS session — hara does not retry network operations to a host known unreachable this session (a retry just hangs ~75s again). Do not swap in a public mirror (won't serve private repos) or switch protocols; diagnose instead.${proxyHint(blocked)}`;
155
+ }
156
+ }
92
157
  let buf = ""; // TUI: line-buffer live output into the sink (one notice per line)
93
158
  const live = ctx.ui
94
159
  ? (s) => {
@@ -114,7 +179,20 @@ registerTool({
114
179
  return capHeadTail(combined.trim() || "(no output)");
115
180
  }
116
181
  catch (e) {
117
- return capHeadTail(`Command failed: ${e.message}\n${e.stdout || ""}${e.stderr || ""}`);
182
+ const base = `Command failed: ${e.message}\n${e.stdout || ""}${e.stderr || ""}`;
183
+ // Network fault tolerance — if this was a genuine host-unreachability (connect timeout / DNS, NOT
184
+ // auth / 404 / connection-refused), remember the host so we fast-fail future ops to it this session.
185
+ if (isConnectFailure(base)) {
186
+ let host = hostFromConnectError(base) || hostsInCommand(input.command)[0] || "";
187
+ if (!host && isNetworkGitOp(input.command))
188
+ host = await gitRemoteHost(input.command, ctx.cwd, ctx.sandbox ?? "off");
189
+ if (host) {
190
+ markHostUnreachable(host);
191
+ ctx.ui?.notice(`↯ ${host} marked unreachable for this session — won't retry network ops to it`);
192
+ return capHeadTail(base + proxyHint(host));
193
+ }
194
+ }
195
+ return capHeadTail(base);
118
196
  }
119
197
  },
120
198
  });
@@ -10,8 +10,9 @@ registerTool({
10
10
  description: "Edit an existing file by replacing exact strings. Provide a single `old_string`/`new_string`, " +
11
11
  "or `edits` (an array of {old_string,new_string,replace_all?}) applied in order. Each `old_string` " +
12
12
  "must match exactly and appear once (include surrounding context) unless `replace_all` is true. " +
13
- "Quote variants (straight/curly) are matched leniently. Use write_file to create a new file, or " +
14
- "apply_patch to change several files at once.",
13
+ "`old_string` is matched against the RAW file text strip read_file's line-number prefix " +
14
+ "(the leading ` 123\\t`) before matching. Quote variants (straight/curly) are matched leniently. " +
15
+ "Use write_file to create a new file, or apply_patch to change several files at once.",
15
16
  input_schema: {
16
17
  type: "object",
17
18
  properties: {
@@ -0,0 +1,67 @@
1
+ // Session-scoped "host unreachable" memory. When a network command (git / curl / …) fails because the
2
+ // HOST could not be reached — a TCP connect timeout or a DNS failure, NOT an auth/404/protocol error on a
3
+ // host that's actually up — we remember that host for the rest of this hara process. A later command aimed
4
+ // at the same host is then short-circuited INSTANTLY (see builtin.ts bash tool) instead of eating another
5
+ // ~75s OS-level connect timeout. This is the deterministic half of hara's network fault-tolerance: the
6
+ // model can't "forget" it the way a system-prompt rule can be ignored under pressure.
7
+ //
8
+ // One hara process == one session (same process-local pattern as session-model.ts's force-model flag), so
9
+ // a module-local Set is exactly session scope — no need to thread state through ~17 runAgent call sites.
10
+ // This module is PURE + fully unit-tested; the only impure step (resolving a bare `git pull`'s remote host
11
+ // with a local `git remote get-url`) lives in the bash tool, keeping the classification logic deterministic.
12
+ const unreachable = new Set();
13
+ /** Remember a host as unreachable for the rest of this session. */
14
+ export function markHostUnreachable(host) {
15
+ if (host)
16
+ unreachable.add(host.toLowerCase());
17
+ }
18
+ /** Has this host already failed to connect this session? */
19
+ export function isHostUnreachable(host) {
20
+ return !!host && unreachable.has(host.toLowerCase());
21
+ }
22
+ /** Snapshot of the hosts marked unreachable (for the pre-check fast path + tests). */
23
+ export function unreachableHostsSnapshot() {
24
+ return [...unreachable];
25
+ }
26
+ /** Clear the memory — called on /reset (network may have been fixed) and between tests. */
27
+ export function resetReachability() {
28
+ unreachable.clear();
29
+ }
30
+ /** Explicit hosts a command references: URLs (http/https/git/ssh/ftp) + scp/ssh `user@host:path` specs.
31
+ * Pure. A bare `git pull origin main` yields [] — its host lives in the repo's remote config, resolved
32
+ * separately by the bash tool. */
33
+ export function hostsInCommand(command) {
34
+ const hosts = new Set();
35
+ // scheme://[user@]host[:port]/… (github.com from https://github.com/owner/repo.git)
36
+ for (const m of command.matchAll(/\b(?:https?|ssh|git|ftp):\/\/(?:[^/\s'"@]+@)?([^/\s:'"]+)/gi)) {
37
+ hosts.add(m[1].toLowerCase());
38
+ }
39
+ // scp/ssh syntax: user@host:path (github.com from git@github.com:owner/repo.git)
40
+ for (const m of command.matchAll(/(?:^|\s)[A-Za-z0-9._-]+@([A-Za-z0-9.-]+):/g)) {
41
+ hosts.add(m[1].toLowerCase());
42
+ }
43
+ return [...hosts];
44
+ }
45
+ /** Does this command reach out over the network for git? Catches the bare forms (`git pull`, `git fetch`)
46
+ * that carry no URL, so the tool knows to resolve their remote host before deciding to short-circuit. */
47
+ export function isNetworkGitOp(command) {
48
+ return /\bgit\b[^\n]*\b(?:clone|fetch|pull|push|ls-remote|remote\s+(?:update|show)|submodule\s+update)\b/.test(command);
49
+ }
50
+ /** Pull the connect/DNS-failure HOST out of a command's error text — the deterministic signal for WHICH
51
+ * host is down. Returns "" if the text names no host. Pure. */
52
+ export function hostFromConnectError(errText) {
53
+ const m = errText.match(/Failed to connect to ([^\s:]+)\s+port/i) || // git/curl: Failed to connect to github.com port 443
54
+ errText.match(/Could not resolve host:?\s*([^\s'"]+)/i) || // git/curl DNS: Could not resolve host: github.com
55
+ errText.match(/Resolving [^\s]*?\(?([A-Za-z0-9.-]+?)\)? timed out/i) || // curl (28) Resolving api.x.com timed out
56
+ errText.match(/unable to access '[a-z]+:\/\/([^/'"]+)/i); // git: unable to access 'https://github.com/…'
57
+ return m ? m[1].toLowerCase().replace(/[.,]+$/, "") : "";
58
+ }
59
+ /** Is this error a genuine host-unreachability — a connect TIMEOUT / DNS failure / no-route, the kind that
60
+ * burns ~75s — as opposed to (a) auth/404/protocol errors on a host that IS up, or (b) "connection
61
+ * refused", which is a host that's up and fast-rejecting (a dev server not started yet), NOT the slow
62
+ * waste we're guarding against. We cache ONLY genuine unreachability. Pure. */
63
+ export function isConnectFailure(errText) {
64
+ if (/Connection refused|ECONNREFUSED/i.test(errText))
65
+ return false; // host is UP (fast reject) — never cache
66
+ return /(?:Failed to connect to [^\n]*port|Could not resolve host|Couldn't connect to server|Connection timed out|Resolving [^\n]*timed out|Operation timed out|[Nn]etwork is unreachable|[Nn]o route to host|ETIMEDOUT|ENETUNREACH|EHOSTUNREACH|EAI_AGAIN)/.test(errText);
67
+ }
@@ -12,6 +12,8 @@ export function levelsFor(style) {
12
12
  return [];
13
13
  if (style === "enable_thinking" || style === "ollama_think")
14
14
  return ["off", "high"]; // "high" renders as "on"
15
+ if (style === "deepseek")
16
+ return ["off", "low", "medium", "high", "max"]; // DeepSeek V4 honors a real "max"
15
17
  return ["off", "low", "medium", "high"];
16
18
  }
17
19
  /** Label a level for display — binary styles read as on/off, graded ones as the level name. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.112.5",
3
+ "version": "0.114.0",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "dist/index.js"
@@ -56,11 +56,13 @@
56
56
  "commander": "^15.0.0",
57
57
  "ink": "^6.8.0",
58
58
  "openai": "^6.44.0",
59
- "react": "^19.2.7"
59
+ "react": "^19.2.7",
60
+ "ws": "^8.19.0"
60
61
  },
61
62
  "devDependencies": {
62
63
  "@types/node": "^25.9.3",
63
64
  "@types/react": "^19.2.17",
65
+ "@types/ws": "^8.18.1",
64
66
  "ink-testing-library": "^4.0.0",
65
67
  "tsx": "^4.22.4",
66
68
  "typescript": "^6.0.3"