@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 +74 -0
- package/README.md +4 -1
- package/dist/agent/loop.js +113 -8
- package/dist/config.js +51 -9
- package/dist/gateway/serve.js +58 -1
- package/dist/gateway/signal.js +220 -0
- package/dist/gateway/tmux-routes.js +135 -0
- package/dist/gateway/wecom.js +383 -0
- package/dist/index.js +1021 -82
- package/dist/org-fleet/enroll.js +28 -5
- package/dist/plugins/plugins.js +49 -1
- package/dist/profile/profile.js +436 -0
- package/dist/providers/anthropic.js +28 -1
- package/dist/providers/openai.js +16 -1
- package/dist/security/guardian.js +261 -0
- package/dist/session/session-model.js +36 -0
- package/dist/statusbar.js +12 -3
- package/dist/tools/ask_user.js +64 -0
- package/dist/tools/external_agent.js +118 -0
- package/dist/tools/skill.js +6 -2
- package/dist/tools/todo.js +65 -8
- package/dist/tui/App.js +280 -33
- package/dist/tui/run.js +36 -1
- package/package.json +1 -1
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
|
+

|
|
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** (
|
|
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
|
package/dist/agent/loop.js
CHANGED
|
@@ -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
|
-
|
|
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(() =>
|
|
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
|
-
|
|
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
|
-
|
|
129
|
-
|
|
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 >
|
|
86
|
-
*
|
|
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
|
-
|
|
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
|
|
93
|
-
const
|
|
94
|
-
const
|
|
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
|
-
...(
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/gateway/serve.js
CHANGED
|
@@ -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") {
|