@nanhara/hara 0.89.0 โ†’ 0.98.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,80 @@ 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.94.1 โ€” unreleased (gateway: relay is on-inbound, not a noisy push)
9
+
10
+ - **Fix the bind output relay to be quiet + platform-correct.** 0.94.0's continuous 3s timer-push flooded chat
11
+ (a message every few seconds) and hit iLink's `ret=-2` (its bot model is passive-reply, no continuous push).
12
+ Replaced with **on-inbound relay**: when you message a bound/registered pane, the daemon captures the pane,
13
+ injects your text, waits ~3s, and replies **once** with the session's NEW output (`๐Ÿ–ฅ <pane>\n<delta>`). One
14
+ reply per message โ€” no spam, and it fits iLink's "one inbound โ†’ one reply" model. Send `?` to peek again.
15
+ - `tmux-routes.ts`: `pickPaneForReply()` (pick+consume, no inject); the timer loop is gone.
16
+ - `serve.ts` onMessage: capture-before โ†’ inject โ†’ settle โ†’ reply with `outputDelta`.
17
+
18
+ ## 0.94.0 โ€” unreleased (gateway: bind output relay โ€” two-way remote terminal)
19
+
20
+ - **Output relay for `hara remote bind`** โ€” the daemon now polls each bound tmux pane and pushes its NEW output
21
+ back to chat once it settles, so a session you drive from your phone is **two-way**: your replies inject in
22
+ (existing), and you SEE the session's output come back (`๐Ÿ–ฅ <pane>\n<delta>`). Only bound (`bind`) panes; the
23
+ first sighting is baselined (no dump of the pre-existing screen); best-effort send (iLink cold-push may drop a
24
+ message when the chat is cold). Pure `outputDelta` (append / unchanged / scroll-anchor / tail) unit-tested.
25
+ - `src/gateway/tmux-routes.ts`: `boundRoutes`, `capturePane`, `outputDelta`.
26
+ - `src/gateway/serve.ts`: a 3s relay loop in `runGateway` (capture โ†’ settle โ†’ send delta), cleared on abort.
27
+ - Caveat: a full-screen TUI session (e.g. Claude Code's ink UI) relays the rendered frame; a non-TUI /
28
+ `HARA_TUI=0` / `-p`-style session relays cleaner. 285 tests.
29
+ ## 0.93.0 โ€” unreleased (hara remote โ€” universal chat-driven HITL for any tmux session)
30
+
31
+ - **`hara remote`** โ€” a first-class, agent-agnostic command so ANY terminal session in tmux (Claude Code, codex,
32
+ hara, a plain REPL) can be driven from chat, not just via the Claude-Code wechat-send skill:
33
+ - `hara remote ask "<q>"` โ€” register this pane (one-shot) + push the question to WeChat; your reply injects back.
34
+ - `hara remote bind` โ€” **persistent bind**: every WeChat reply injects into this pane until `unbind` / `/detach`
35
+ (drive a whole session from your phone while you're out, many messages, not just one).
36
+ - `hara remote unbind` ยท `hara remote status`.
37
+ - Route store gains `mode: "once" | "bind"`; the daemon consumes "once" routes but keeps "bind" ones
38
+ (`pickRoute`/`deliverToTmux`). New `/detach` chat command unbinds all persistent panes from your phone.
39
+ - Generic by design: the injection is just `tmux send-keys`, so it works for any program reading stdin in the
40
+ pane โ€” the agent only needs to call `hara remote ask` when it wants your input (the confirm-loop pattern).
41
+ - 284 tests (+ bind-mode `pickRoute`); persistent-bind verified live (two replies โ†’ same pane, route persists).
42
+
43
+ ## 0.92.0 โ€” unreleased (gateway: reply-into-tmux โ€” two-way HITL for any running session)
44
+
45
+ - **Reply routing into an already-running tmux session** โ€” a session you started yourself (Claude Code / codex /
46
+ hara, in tmux) can ping you on WeChat and, while you're away, your reply gets **injected back into that exact
47
+ session** so it continues. No need to launch it under a supervisor or use a blocking tool โ€” borrows the ccgram
48
+ keystroke-injection pattern (the only way to retrofit a session the daemon doesn't own).
49
+ - `src/gateway/tmux-routes.ts`: a route store (`~/.hara/gateway/tmux-routes.json`) + `tmux send-keys` injection.
50
+ The gateway daemon, on an owner reply, injects it into the **oldest live registered pane** (`deliverToTmux`),
51
+ one-shot per ask, dead panes pruned. Pure `pickRoute` + `paneAlive` (list-panes membership) unit-tested.
52
+ - `serve.ts` `onMessage`: a non-slash reply is routed to a waiting pane (and the task isn't re-run) โ€” owner-gated
53
+ by the existing allowlist; only panes that **opted in** are ever touched.
54
+ - The `wechat-send` skill gains `--ask`: send a question + register this tmux pane for the reply.
55
+ - Live-verified the injection path (send-keys โ†’ pane received the line); fixed `paneAlive` (display-message was
56
+ too lenient โ†’ use `list-panes -a` membership). 283 tests.
57
+
58
+ ## 0.91.0 โ€” unreleased (external_agent: delegate to Claude Code / Codex)
59
+
60
+ - **`external_agent` tool** โ€” hand a self-contained task to an EXTERNAL coding agent (**`claude`** / **`codex`**)
61
+ running headless in the current dir, and get its result back. Zero new deps โ€” drives each agent's native
62
+ headless flag (`claude -p โ€ฆ --output-format text --permission-mode โ€ฆ`, `codex exec โ€ฆ --cd โ€ฆ --sandbox โ€ฆ`)
63
+ over `node:child_process`, not openclaw's heavier ACP/acpx stack. Pick the best engine per task.
64
+ - **Gated**: `kind:"exec"` โ†’ inherits the approval flow; and because read-only fan-out sub-agents only get the
65
+ `READONLY_TOOLS` allow-list, this privileged tool is **never** exposed to them.
66
+ - **Trust tiers** `externalAgentTrust` / `HARA_EXTERNAL_AGENT_TRUST` = `off | gated (default) | full`. `gated`
67
+ runs the external agent in its safe sub-mode (`claude --permission-mode plan/acceptEdits`,
68
+ `codex --sandbox read-only/workspace-write`); the dangerous bypass/full-access sub-modes are only reachable at
69
+ `full`. Backend allow-list (claude/codex), timeout + output cap. Pure `buildExternalArgv` unit-tested.
70
+
71
+ ## 0.90.0 โ€” unreleased (gateway: WeCom + Signal โ†’ 10 platforms)
72
+
73
+ - **WeCom (ไผไธšๅพฎไฟก)** โ€” connects out to WeCom's AI-Bot WebSocket gateway (no public webhook). `HARA_WECOM_BOT_ID`
74
+ + `HARA_WECOM_SECRET`. Two-way: inbound text + images (incl. AES-decrypted attachments โ†’ `~/.hara/wecom/media`),
75
+ outbound text/image/file. Zero new deps (native WebSocket + node:crypto).
76
+ - **Signal** โ€” talks to a local **signal-cli** daemon (JSON-RPC). `HARA_SIGNAL_RPC_URL` + `HARA_SIGNAL_NUMBER`.
77
+ Inbound text + image attachments, outbound text/file; phone numbers redacted in logs. signal-cli is an external
78
+ daemon the user runs (documented). Zero new npm deps.
79
+ - Pure parsers `parseWecomMessage` / `parseSignalMessage` unit-tested. docs/gateway.md + README updated to 10
80
+ platforms. Ported from the openclaw + hermes adapters.
81
+
8
82
  ## 0.89.0 โ€” unreleased (gateway: Slack ยท Mattermost ยท Matrix ยท DingTalk + docs)
9
83
 
10
84
  - **Four more platforms**, all zero-new-dep (native WebSocket / `fetch`), same `ChatAdapter` seam:
package/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  **A coding agent CLI that runs like an engineering org.**
4
4
 
5
+ ![hara โ€” your coding agent, driven from 10 chat platforms](docs/assets/hara-gateway-hero.png)
6
+
5
7
  > Think "Claude Code, but it operates as a configurable, governed *organization* of role-agents" โ€”
6
8
  > with routing boundaries, a dispatcher, a single source-of-truth data layer, human-in-the-loop
7
9
  > approvals, and cron autonomy.
@@ -10,10 +12,11 @@
10
12
 
11
13
  **Highlights**
12
14
  - **An org, not just an agent** โ€” `hara org "<task>"` routes work to the role that *owns* it; `hara plan "<task>"` decomposes a task into a verified DAG of atoms (frame โ†’ atomize โ†’ sequence โ†’ execute โ†’ **verify gate**), and `hara plan --parallel` runs independent atoms concurrently.
13
- - **Drive it from chat** โ€” `hara gateway` runs your local hara from **Telegram ยท WeChat ยท Discord ยท Feishu/Lark ยท Slack ยท Mattermost ยท Matrix ยท DingTalk** (8 platforms), with **two-way images** (send a photo โ†’ it sees it; ask for a file โ†’ it sends one), per-chat resumable sessions, and project roaming. Connects out โ€” no public webhook. See **[docs/gateway.md](docs/gateway.md)**.
15
+ - **Drive it from chat** โ€” `hara gateway` runs your local hara from **Telegram ยท WeChat ยท Discord ยท Feishu/Lark ยท Slack ยท Mattermost ยท Matrix ยท DingTalk ยท WeCom ยท Signal** (10 platforms), with **two-way images** (send a photo โ†’ it sees it; ask for a file โ†’ it sends one), per-chat resumable sessions, and project roaming. Connects out โ€” no public webhook. See **[docs/gateway.md](docs/gateway.md)**.
14
16
  - **Real terminal UX** โ€” an **ink TUI**: bottom-pinned input box, **plan mode** (read-only โ†’ propose a plan โ†’ approve โ†’ execute), selectable approvals with "don't ask again", windowed reasoning, **paste images** (Ctrl+V) for vision models, light/dark theme.
15
17
  - **Persistent memory + self-evolution** โ€” `memory_*` tools over global/project `MEMORY.md`; the agent recalls before acting, **proactively saves** durable facts, and grows its own playbooks (a lexical guard screens what it writes). Inspect/consolidate it with **`hara memory show`** and **`hara memory distill`** (promote recent daily logs โ†’ durable memory). Lexical-first by design โ€” semantic search is opt-in, never required.
16
18
  - **Multi-provider, all streamed** โ€” Anthropic (Claude) or any OpenAI-compatible endpoint (Qwen/DashScope, GLM, Kimi, OpenAI) with live Markdown + visible reasoning.
19
+ - **Delegate to other agents** โ€” the **`external_agent`** tool hands a self-contained task to **Claude Code** or **Codex** running headless, and returns the result โ€” so you pick the best engine per task. Gated by approval, trust-tiered, and never exposed to read-only sub-agents.
17
20
  - **Solid coding core** โ€” `edit_file` / `apply_patch` (atomic multi-file) with colored diffs ยท `grep`/`glob`/`ls`/`codebase_search` (lexical + optional semantic search over the repo) /`web_fetch` ยท fuzzy `@file` ยท `/undo` ยท `/compact` ยท **Esc-to-interrupt** ยท parallel sub-agents ยท MCP client ยท macOS sandbox.
18
21
 
19
22
  Track it: https://github.com/hara-cli/hara ยท https://hara.run
@@ -7,8 +7,21 @@ import { skillsDigest } from "../skills/skills.js";
7
7
  import { runHooks } from "../hooks.js";
8
8
  import { mapLimit, maxParallel } from "../concurrency.js";
9
9
  import { decideCommand, loadPermissionRules } from "../security/permissions.js";
10
+ import { classifyRisk, guardianVeto, guardianEnabled, newBreaker, recordBlock } from "../security/guardian.js";
10
11
  import { subdirHint } from "../context/subdir-hints.js";
11
12
  import { classifyError, failoverAction, errorHint } from "./failover.js";
13
+ import { currentTodos } from "../tools/todo.js";
14
+ /** Spinner verb (terminal mode + reused by TUI tests): when the agent has an in_progress todo,
15
+ * surface its activeForm/text so the bottom-of-screen line reads concretely ("โ–ถ updating testsโ€ฆ 3s")
16
+ * instead of "working 3s". Pure: takes a snapshot + elapsed seconds. */
17
+ export function spinnerVerb(list, elapsedSec) {
18
+ const active = list.find((t) => t.status === "in_progress");
19
+ if (active) {
20
+ const phrase = active.activeForm?.trim() || active.text;
21
+ return `${phrase}โ€ฆ ${elapsedSec}s`;
22
+ }
23
+ return `working ${elapsedSec}s`;
24
+ }
12
25
  /** Whether a tool call needs user confirmation under the given approval mode. */
13
26
  export function needsConfirm(kind, mode) {
14
27
  if (kind === "read")
@@ -68,6 +81,11 @@ export async function runAgent(history, opts) {
68
81
  const toolCounts = new Map();
69
82
  let blindShots = 0;
70
83
  let nudged = false;
84
+ // Guardian: engaged only on HIGH-RISK actions (see classifyRisk). `on` gates the whole layer so normal
85
+ // work never pays for it; the breaker is per-run (a hard stop after repeated blocks).
86
+ const guardianOn = !!opts.guardian && (opts.guardian.enabled ?? true) && guardianEnabled();
87
+ const breaker = newBreaker();
88
+ let breakerHalt = false; // set when a tripped breaker aborts this run
71
89
  for (;;) {
72
90
  // Type-ahead steering: fold in anything the user submitted while the previous step ran, so it
73
91
  // reaches the model on this next call (drained after the last tool round; empty on the 1st pass).
@@ -79,7 +97,17 @@ export async function runAgent(history, opts) {
79
97
  const sink = ctx.ui; // TUI mode: route output to ink instead of stdout
80
98
  const tty = stdout.isTTY && !opts.quiet && !sink;
81
99
  const md = tty && process.env.HARA_MD !== "0" ? makeRenderer(out) : null;
82
- let sawReasoning = false;
100
+ // Reasoning rendering in plain-terminal mode: we put reasoning on its OWN dim lines (prefixed
101
+ // "โ”‚ ") instead of sharing a line with the spinner โ€” that's what was eating DeepSeek's
102
+ // reasoning_content in non-TUI mode (each spinner tick `\r`-overwrote it). The TUI keeps its
103
+ // existing 5-line scroll window via the ink Block; this is the terminal equivalent.
104
+ let reasoningOpen = false;
105
+ const flushReasoningTail = () => {
106
+ if (reasoningOpen) {
107
+ out("\n");
108
+ reasoningOpen = false;
109
+ }
110
+ };
83
111
  // "working Ns" spinner until the first output arrives (cleared on text/reasoning or turn end)
84
112
  let spin = null;
85
113
  const stopSpin = () => {
@@ -93,7 +121,10 @@ export async function runAgent(history, opts) {
93
121
  const frames = "โ ‹โ ™โ นโ ธโ ผโ ดโ ฆโ งโ ‡โ ";
94
122
  const t0 = Date.now();
95
123
  let fi = 0;
96
- spin = setInterval(() => out(`\r${c.dim(`${frames[fi++ % frames.length]} working ${Math.floor((Date.now() - t0) / 1000)}s`)}`), 100);
124
+ spin = setInterval(() => {
125
+ const verb = spinnerVerb(currentTodos(), Math.floor((Date.now() - t0) / 1000));
126
+ out(`\r\x1b[K${c.dim(`${frames[fi++ % frames.length]} ${verb}`)}`);
127
+ }, 100);
97
128
  }
98
129
  const r = await activeProvider.turn({
99
130
  system: composeSystem(ctx.cwd, opts.projectContext, opts.systemOverride, opts.memory),
@@ -107,10 +138,7 @@ export async function runAgent(history, opts) {
107
138
  return;
108
139
  }
109
140
  stopSpin();
110
- if (sawReasoning) {
111
- out("\n");
112
- sawReasoning = false;
113
- }
141
+ flushReasoningTail();
114
142
  if (md)
115
143
  md.push(d);
116
144
  else
@@ -124,14 +152,29 @@ export async function runAgent(history, opts) {
124
152
  sink.reasoning(d);
125
153
  return;
126
154
  }
155
+ // Terminal mode: render reasoning on its own dim lines (prefix `โ”‚ ` per line). Each
156
+ // line is committed once and never overwritten โ€” so a subsequent spinner tick can't
157
+ // clobber it (the old `out(c.dim(d))` bug). Multi-line deltas split cleanly; the
158
+ // current line resumes mid-output when the next delta arrives.
127
159
  stopSpin();
128
- sawReasoning = true;
129
- out(c.dim(d));
160
+ const lines = d.split("\n");
161
+ for (let i = 0; i < lines.length; i++) {
162
+ if (!reasoningOpen) {
163
+ out(c.dim("โ”‚ "));
164
+ reasoningOpen = true;
165
+ }
166
+ out(c.dim(lines[i]));
167
+ if (i < lines.length - 1) {
168
+ out("\n");
169
+ reasoningOpen = false;
170
+ }
171
+ }
130
172
  }
131
173
  : undefined,
132
174
  signal: opts.signal,
133
175
  });
134
176
  stopSpin();
177
+ flushReasoningTail();
135
178
  md?.end();
136
179
  if (!opts.quiet && !sink)
137
180
  out("\n");
@@ -169,6 +212,12 @@ export async function runAgent(history, opts) {
169
212
  return;
170
213
  const plans = [];
171
214
  for (const tu of r.toolUses) {
215
+ if (breakerHalt) {
216
+ // Circuit-breaker halted the run: refuse every remaining call in this round with a clear message
217
+ // (no hang, no further tools) so the model + user get a definitive stop.
218
+ plans.push({ tu, tool: getTool(tu.name), denied: "Guardian circuit-breaker halted this run (too many high-risk actions blocked). Ask the user to review and re-run." });
219
+ continue;
220
+ }
172
221
  const tool = getTool(tu.name);
173
222
  if (!tool) {
174
223
  plans.push({ tu, tool: undefined, denied: `Unknown tool: ${tu.name}` });
@@ -187,6 +236,50 @@ export async function runAgent(history, opts) {
187
236
  plans.push({ tu, tool, denied: "Denied by a permission rule (~/.hara/permissions.json). Loosen the rule or run it yourself." });
188
237
  continue;
189
238
  }
239
+ // Guardian layer โ€” runs AFTER permission rules, alongside/just before the confirm gate. The
240
+ // deterministic classifier short-circuits FIRST: read tools, in-project edits, and ordinary shell
241
+ // commands classify `low` (pure Node, no LLM) and skip everything below โ€” zero added latency. Only a
242
+ // genuinely HIGH-RISK action pays for a cheap-model veto, and that veto fails OPEN on any glitch.
243
+ if (guardianOn && !breakerHalt) {
244
+ const risk = classifyRisk(tu.name, tool.kind, input, ctx.cwd);
245
+ if (risk.level === "high") {
246
+ const detail = String(input.command ?? input.path ?? "").replace(/\s+/g, " ").trim().slice(0, 400);
247
+ const verdict = await guardianVeto(opts.guardian.provider, { tool: tu.name, detail, classifierReason: risk.reason }, history, { signal: opts.signal });
248
+ if (verdict.decision === "block") {
249
+ const tripped = recordBlock(breaker); // deterministic circuit-breaker: N blocks โ†’ hard stop
250
+ plans.push({
251
+ tu,
252
+ tool,
253
+ denied: `Guardian blocked this high-risk action: ${verdict.reason || risk.reason}. Reconsider โ€” take a safer, in-scope step, or ask the user before doing this.`,
254
+ });
255
+ if (!opts.quiet) {
256
+ const note = `โ›” guardian blocked ${tu.name} โ€” ${verdict.reason || risk.reason}`;
257
+ if (sink)
258
+ sink.notice(note);
259
+ else
260
+ out(c.yellow(` ${note}\n`));
261
+ }
262
+ if (tripped) {
263
+ // Circuit-breaker tripped โ€” a HARDER stop than the soft stuck-guard. On an INTERACTIVE run
264
+ // (an `ask` channel exists), require an explicit human OK to continue. In headless/no-UI
265
+ // (gateway/cron/-p, where `confirm` is auto-yes and there's no real user), abort SAFELY โ€”
266
+ // never auto-continue past the breaker, and never hang.
267
+ const interactive = !!ctx.ask;
268
+ const cont = interactive
269
+ ? await opts.confirm(`${c.red("โ›” guardian circuit-breaker")} โ€” ${breaker.blocks} high-risk actions blocked this turn. Continue anyway?`)
270
+ : false;
271
+ if (cont === false) {
272
+ breakerHalt = true;
273
+ }
274
+ else {
275
+ breaker.tripped = false;
276
+ breaker.blocks = 0; // user vouched โ†’ reset the counter, keep classifying
277
+ }
278
+ }
279
+ continue;
280
+ }
281
+ }
282
+ }
190
283
  if (cmdDecision !== "allow" && needsConfirm(tool.kind, opts.approval) && (alwaysGate || !opts.autoApprove?.has(tu.name))) {
191
284
  const reply = await opts.confirm(`${c.yellow("โš ")} ${c.bold(tu.name)} ${c.dim(preview)} โ€” run?`);
192
285
  if (reply === false) {
@@ -251,6 +344,18 @@ export async function runAgent(history, opts) {
251
344
  }
252
345
  await flush();
253
346
  history.push({ role: "tool", results });
347
+ if (breakerHalt) {
348
+ // A tripped-and-declined circuit-breaker is a hard stop: end the run cleanly (the denial messages are
349
+ // already in `results` so the model/user see why). Never spin further.
350
+ if (!opts.quiet) {
351
+ const note = "โ›” guardian circuit-breaker: run halted (too many high-risk actions blocked). Review and re-run.";
352
+ if (sink)
353
+ sink.notice(note);
354
+ else
355
+ out(c.red(`${note}\n`));
356
+ }
357
+ return;
358
+ }
254
359
  if (guard && !nudged) {
255
360
  for (const p of plans)
256
361
  if (p.tool && p.tool.kind !== "read")
package/dist/config.js CHANGED
@@ -10,9 +10,28 @@ const PROVIDER_DEFAULTS = {
10
10
  },
11
11
  "qwen-oauth": { model: "coder-model", envKey: "QWEN_OAUTH_TOKEN" },
12
12
  openai: { model: "gpt-4o-mini", envKey: "OPENAI_API_KEY" },
13
+ // GLM / DeepSeek / OpenRouter are OpenAI-compatible: buildProvider routes them through the
14
+ // openai path (createOpenAIProvider) using the preset baseURL below. The preset baseURL is
15
+ // applied by loadConfig (merged.baseURL ?? d.baseURL), so the setup wizard never asks for a URL.
16
+ glm: {
17
+ model: "glm-4.6",
18
+ baseURL: "https://open.bigmodel.cn/api/paas/v4",
19
+ envKey: "GLM_API_KEY",
20
+ },
21
+ deepseek: {
22
+ model: "deepseek-chat",
23
+ baseURL: "https://api.deepseek.com",
24
+ envKey: "DEEPSEEK_API_KEY",
25
+ },
26
+ openrouter: {
27
+ model: "openai/gpt-4o-mini",
28
+ baseURL: "https://openrouter.ai/api/v1",
29
+ envKey: "OPENROUTER_API_KEY",
30
+ },
13
31
  "hara-gateway": { model: "", envKey: "HARA_GATEWAY_TOKEN" }, // B-end: enrolled device โ†’ token in ~/.hara/org.json, routed by the gateway
14
32
  };
15
- 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", "notify", "vimMode", "autoCompact", "fileCheckpoints", "fallbackModel", "fallbackBaseURL", "fallbackApiKey"];
33
+ export const CONFIG_KEYS = ["provider", "apiKey", "model", "baseURL", "approval", "sandbox", "theme", "evolve", "assetCapture", "computerUse", "computerApps", "visionModel", "visionBaseURL", "visionApiKey", "embedProvider", "embedModel", "embedBaseURL", "embedApiKey", "routeModel", "routeBaseURL", "routeApiKey", "guardian", "notify", "vimMode", "autoCompact", "fileCheckpoints", "fallbackModel", "fallbackBaseURL", "fallbackApiKey", "reasoningEffort"];
34
+ export const REASONING_EFFORTS = ["off", "low", "medium", "high"];
16
35
  export const APPROVAL_MODES = ["suggest", "auto-edit", "full-auto"];
17
36
  export const SANDBOX_MODES = ["off", "workspace-write", "read-only"];
18
37
  const PROJECT_ROOT_MARKERS = [".git", "package.json", "Cargo.toml", "go.mod", "pyproject.toml", ".hg"];
@@ -82,16 +101,27 @@ export function setModelVisionOverride(model, cap) {
82
101
  persistConfig(p, cfg);
83
102
  }
84
103
  /**
85
- * Effective config. Precedence (highโ†’low): env vars > selected profile >
86
- * project `.hara/config.json` > global `~/.hara/config.json` > provider defaults.
104
+ * Effective config. Precedence (highโ†’low): env vars > project `.hara/config.json` >
105
+ * named overlay (`overlays.<name>` in global config) > global `~/.hara/config.json`
106
+ * > provider defaults.
107
+ *
108
+ * NOTE: `--profile` / `HARA_PROFILE` is the IDENTITY-profile selector (personal โ†” org A
109
+ * โ†” org B) โ€” see src/profile/profile.ts. The legacy `profiles:{name:partial}` overlay
110
+ * mechanism (a tiny in-config preset / overlay) has been renamed to `overlays:{...}`
111
+ * to free the "profile" word for identity. We still read the legacy `profiles:{...}`
112
+ * key for one release for back-compat. Overlays are addressed by env var
113
+ * `HARA_OVERLAY=<name>` (or `opts.overlay`).
87
114
  */
88
115
  export function loadConfig(opts = {}) {
89
116
  const global = readRawConfig();
90
- const { profiles, ...globalBase } = global;
117
+ // Strip both the new (`overlays`) and legacy (`profiles`) overlay containers from the base merge.
118
+ // The legacy `profiles` key is kept readable for back-compat with users who already have it.
119
+ const { overlays, profiles, ...globalBase } = global;
91
120
  const project = readProjectConfig(process.cwd());
92
- const profileName = process.env.HARA_PROFILE ?? opts.profile;
93
- const profile = profileName && profiles && profiles[profileName] ? profiles[profileName] : {};
94
- const merged = { ...globalBase, ...project, ...profile };
121
+ const overlayName = process.env.HARA_OVERLAY ?? opts.overlay;
122
+ const overlayMap = overlays && typeof overlays === "object" ? overlays : profiles && typeof profiles === "object" ? profiles : null;
123
+ const overlay = overlayName && overlayMap && overlayMap[overlayName] ? overlayMap[overlayName] : {};
124
+ const merged = { ...globalBase, ...project, ...overlay };
95
125
  const provider = (process.env.HARA_PROVIDER ?? merged.provider ?? "anthropic");
96
126
  const d = PROVIDER_DEFAULTS[provider] ?? PROVIDER_DEFAULTS.anthropic;
97
127
  const model = process.env.HARA_MODEL ?? merged.model ?? d.model;
@@ -118,9 +148,12 @@ export function loadConfig(opts = {}) {
118
148
  const mcpServers = {
119
149
  ...(globalBase.mcpServers ?? {}),
120
150
  ...(project.mcpServers ?? {}),
121
- ...(profile.mcpServers ?? {}),
151
+ ...(overlay.mcpServers ?? {}),
122
152
  };
123
153
  const hooks = (merged.hooks && typeof merged.hooks === "object" ? merged.hooks : {});
154
+ // Guardian: default ON; env HARA_GUARDIAN=0/off/false or config guardian:"off" disables it.
155
+ const guardianRaw = process.env.HARA_GUARDIAN ?? merged.guardian;
156
+ const guardian = guardianRaw === "0" || guardianRaw === "off" || guardianRaw === "false" ? "off" : "on";
124
157
  const notify = (process.env.HARA_NOTIFY ?? merged.notify ?? "off");
125
158
  const vimMode = process.env.HARA_VIM === "1" || merged.vimMode === true || merged.vimMode === "true";
126
159
  const autoCompact = !(process.env.HARA_AUTO_COMPACT === "0" || merged.autoCompact === false || merged.autoCompact === "false"); // default ON
@@ -128,8 +161,17 @@ export function loadConfig(opts = {}) {
128
161
  const fallbackModel = process.env.HARA_FALLBACK_MODEL ?? merged.fallbackModel;
129
162
  const fallbackBaseURL = process.env.HARA_FALLBACK_BASE_URL ?? merged.fallbackBaseURL;
130
163
  const fallbackApiKey = process.env.HARA_FALLBACK_API_KEY ?? merged.fallbackApiKey;
131
- return { provider, apiKey, model, baseURL, approval, sandbox, theme, evolve, assetCapture, computerUse, computerApps, visionModel, visionBaseURL, visionApiKey, modelVision, embedProvider, embedModel, embedBaseURL, embedApiKey, routeModel, routeBaseURL, routeApiKey, hooks, notify, vimMode, autoCompact, fileCheckpoints, fallbackModel, fallbackBaseURL, fallbackApiKey, mcpServers, cwd: process.cwd() };
164
+ const reasoningRaw = process.env.HARA_REASONING_EFFORT ?? merged.reasoningEffort;
165
+ const reasoningEffort = reasoningRaw && ["off", "low", "medium", "high"].includes(reasoningRaw)
166
+ ? reasoningRaw
167
+ : undefined;
168
+ return { provider, apiKey, model, baseURL, approval, sandbox, theme, evolve, assetCapture, computerUse, computerApps, visionModel, visionBaseURL, visionApiKey, modelVision, embedProvider, embedModel, embedBaseURL, embedApiKey, routeModel, routeBaseURL, routeApiKey, guardian, hooks, notify, vimMode, autoCompact, fileCheckpoints, fallbackModel, fallbackBaseURL, fallbackApiKey, reasoningEffort, mcpServers, cwd: process.cwd() };
132
169
  }
133
170
  export function providerEnvKey(provider) {
134
171
  return (PROVIDER_DEFAULTS[provider] ?? PROVIDER_DEFAULTS.anthropic).envKey;
135
172
  }
173
+ /** Preset base URL for a provider (undefined for anthropic/openai which use their SDK defaults).
174
+ * Used by `hara setup` to write a self-contained baseURL for GLM/DeepSeek/OpenRouter. */
175
+ export function providerDefaultBaseURL(provider) {
176
+ return PROVIDER_DEFAULTS[provider]?.baseURL;
177
+ }
@@ -5,6 +5,7 @@
5
5
  import { spawn } from "node:child_process";
6
6
  import { telegramAdapter } from "./telegram.js";
7
7
  import { chatContext, chatCd, newChatSession, setChatSession, toggleVoice } from "./sessions.js";
8
+ import { pickPaneForReply, capturePane, injectTmux, outputDelta } from "./tmux-routes.js";
8
9
  import { synthesize } from "./tts.js";
9
10
  import { selfArgv } from "../cron/runner.js";
10
11
  import { listSessions, resolveSessionId, loadSession } from "../session/store.js";
@@ -142,6 +143,26 @@ async function buildAdapter(platform) {
142
143
  const { dingtalkAdapter } = await import("./dingtalk.js");
143
144
  return { adapter: dingtalkAdapter(clientId, clientSecret) };
144
145
  }
146
+ if (platform === "wecom" || platform === "wework") {
147
+ const botId = process.env.HARA_WECOM_BOT_ID;
148
+ const secret = process.env.HARA_WECOM_SECRET;
149
+ if (!botId || !secret) {
150
+ console.error("hara gateway: set HARA_WECOM_BOT_ID + HARA_WECOM_SECRET (ไผไธšๅพฎไฟก admin console โ†’ AI Bot credentials) and HARA_GATEWAY_ALLOWED=<your wecom userid>. (HARA_WECOM_WS_URL overrides the gateway URL.)");
151
+ return null;
152
+ }
153
+ const { wecomAdapter } = await import("./wecom.js");
154
+ return { adapter: wecomAdapter(botId, secret, process.env.HARA_WECOM_WS_URL) };
155
+ }
156
+ if (platform === "signal") {
157
+ const rpcUrl = process.env.HARA_SIGNAL_RPC_URL;
158
+ const number = process.env.HARA_SIGNAL_NUMBER;
159
+ if (!rpcUrl || !number) {
160
+ console.error("hara gateway: set HARA_SIGNAL_RPC_URL (e.g. http://localhost:8080) + HARA_SIGNAL_NUMBER (the bot's registered phone, E.164) and HARA_GATEWAY_ALLOWED=<your signal number/uuid>. Requires a local signal-cli daemon: `signal-cli -a <number> daemon --http localhost:8080`.");
161
+ return null;
162
+ }
163
+ const { signalAdapter } = await import("./signal.js");
164
+ return { adapter: signalAdapter(rpcUrl, number), ownerId: number };
165
+ }
145
166
  const token = process.env.HARA_TELEGRAM_TOKEN;
146
167
  if (!token) {
147
168
  console.error("hara gateway: set HARA_TELEGRAM_TOKEN (from @BotFather) and HARA_GATEWAY_ALLOWED=<your telegram user id>.");
@@ -192,11 +213,47 @@ export async function runGateway(opts) {
192
213
  await adapter.send(m.chatId, "โ›” not authorized.");
193
214
  return;
194
215
  }
216
+ // If a tmux session opted in (via `hara remote ask/bind`), this reply is its input โ†’ inject it into that
217
+ // pane, let it react, and reply with the session's NEW output (on-inbound relay โ€” quiet + iLink-friendly:
218
+ // one reply per message, no continuous push). Owner-gated by the allowlist check above.
219
+ if (!parseCommand(m.text)) {
220
+ const pane = pickPaneForReply();
221
+ if (pane) {
222
+ console.error(`hara gateway: routed reply โ†’ tmux pane ${pane}`);
223
+ const before = capturePane(pane) ?? "";
224
+ injectTmux(pane, m.text);
225
+ // wait for the session's output to SETTLE (poll every 800ms; stable for ~1.6s โ†’ done; cap ~10s) so a
226
+ // slow response isn't missed and we don't capture mid-stream.
227
+ let after = "";
228
+ let stable = 0;
229
+ for (let i = 0; i < 12; i++) {
230
+ await new Promise((r) => setTimeout(r, 800));
231
+ const cur = capturePane(pane) ?? "";
232
+ if (cur === after) {
233
+ if (++stable >= 2)
234
+ break;
235
+ }
236
+ else {
237
+ stable = 0;
238
+ after = cur;
239
+ }
240
+ }
241
+ const delta = outputDelta(before, after).trim();
242
+ const body = delta ? (delta.length > 1500 ? "โ€ฆ\n" + delta.slice(-1500) : delta) : "(ๅทฒๆณจๅ…ฅ,ๆš‚ๆ— ๆ–ฐ่พ“ๅ‡บ โ€” ๅ‘ ? ๅ†็œ‹)";
243
+ await adapter.send(m.chatId, `๐Ÿ–ฅ ${pane}\n${body}`);
244
+ return;
245
+ }
246
+ }
195
247
  const ctx = chatContext(adapter.name, m.chatId, cwd); // this chat's current { cwd, sessionId }
196
248
  const cmd = parseCommand(m.text);
197
249
  if (cmd) {
198
250
  if (cmd.cmd === "help")
199
- return adapter.send(m.chatId, "commands:\n/pwd ยท /cd <dir> โ€” project\n/sessions ยท /new ยท /resume <id> โ€” threads\n/voice ยท /say <text> โ€” speech ยท /send <path> โ€” send a file\n/help\nanything else = run hara here");
251
+ return adapter.send(m.chatId, "commands:\n/pwd ยท /cd <dir> โ€” project\n/sessions ยท /new ยท /resume <id> โ€” threads\n/voice ยท /say <text> โ€” speech ยท /send <path> โ€” send a file\n/detach โ€” stop injecting replies into bound tmux panes\n/help\nanything else = run hara here");
252
+ if (cmd.cmd === "detach") {
253
+ const { unbindBinds } = await import("./tmux-routes.js");
254
+ const n = unbindBinds();
255
+ return adapter.send(m.chatId, n ? `๐Ÿ”“ detached ${n} bound tmux pane(s) โ€” replies go to hara again.` : "(no tmux panes were bound)");
256
+ }
200
257
  if (cmd.cmd === "pwd")
201
258
  return adapter.send(m.chatId, `๐Ÿ“‚ ${ctx.cwd}\n๐Ÿงต ${ctx.sessionId.slice(-18)}`);
202
259
  if (cmd.cmd === "cd" || cmd.cmd === "project") {