@nanhara/hara 0.89.0 โ 0.95.2
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 +45 -8
- package/dist/config.js +25 -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 +820 -71
- 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/session/session-model.js +36 -0
- package/dist/statusbar.js +12 -3
- 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 +241 -30
- 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
|
@@ -9,6 +9,18 @@ import { mapLimit, maxParallel } from "../concurrency.js";
|
|
|
9
9
|
import { decideCommand, loadPermissionRules } from "../security/permissions.js";
|
|
10
10
|
import { subdirHint } from "../context/subdir-hints.js";
|
|
11
11
|
import { classifyError, failoverAction, errorHint } from "./failover.js";
|
|
12
|
+
import { currentTodos } from "../tools/todo.js";
|
|
13
|
+
/** Spinner verb (terminal mode + reused by TUI tests): when the agent has an in_progress todo,
|
|
14
|
+
* surface its activeForm/text so the bottom-of-screen line reads concretely ("โถ updating testsโฆ 3s")
|
|
15
|
+
* instead of "working 3s". Pure: takes a snapshot + elapsed seconds. */
|
|
16
|
+
export function spinnerVerb(list, elapsedSec) {
|
|
17
|
+
const active = list.find((t) => t.status === "in_progress");
|
|
18
|
+
if (active) {
|
|
19
|
+
const phrase = active.activeForm?.trim() || active.text;
|
|
20
|
+
return `${phrase}โฆ ${elapsedSec}s`;
|
|
21
|
+
}
|
|
22
|
+
return `working ${elapsedSec}s`;
|
|
23
|
+
}
|
|
12
24
|
/** Whether a tool call needs user confirmation under the given approval mode. */
|
|
13
25
|
export function needsConfirm(kind, mode) {
|
|
14
26
|
if (kind === "read")
|
|
@@ -79,7 +91,17 @@ export async function runAgent(history, opts) {
|
|
|
79
91
|
const sink = ctx.ui; // TUI mode: route output to ink instead of stdout
|
|
80
92
|
const tty = stdout.isTTY && !opts.quiet && !sink;
|
|
81
93
|
const md = tty && process.env.HARA_MD !== "0" ? makeRenderer(out) : null;
|
|
82
|
-
|
|
94
|
+
// Reasoning rendering in plain-terminal mode: we put reasoning on its OWN dim lines (prefixed
|
|
95
|
+
// "โ ") instead of sharing a line with the spinner โ that's what was eating DeepSeek's
|
|
96
|
+
// reasoning_content in non-TUI mode (each spinner tick `\r`-overwrote it). The TUI keeps its
|
|
97
|
+
// existing 5-line scroll window via the ink Block; this is the terminal equivalent.
|
|
98
|
+
let reasoningOpen = false;
|
|
99
|
+
const flushReasoningTail = () => {
|
|
100
|
+
if (reasoningOpen) {
|
|
101
|
+
out("\n");
|
|
102
|
+
reasoningOpen = false;
|
|
103
|
+
}
|
|
104
|
+
};
|
|
83
105
|
// "working Ns" spinner until the first output arrives (cleared on text/reasoning or turn end)
|
|
84
106
|
let spin = null;
|
|
85
107
|
const stopSpin = () => {
|
|
@@ -93,7 +115,10 @@ export async function runAgent(history, opts) {
|
|
|
93
115
|
const frames = "โ โ โ นโ ธโ ผโ ดโ ฆโ งโ โ ";
|
|
94
116
|
const t0 = Date.now();
|
|
95
117
|
let fi = 0;
|
|
96
|
-
spin = setInterval(() =>
|
|
118
|
+
spin = setInterval(() => {
|
|
119
|
+
const verb = spinnerVerb(currentTodos(), Math.floor((Date.now() - t0) / 1000));
|
|
120
|
+
out(`\r\x1b[K${c.dim(`${frames[fi++ % frames.length]} ${verb}`)}`);
|
|
121
|
+
}, 100);
|
|
97
122
|
}
|
|
98
123
|
const r = await activeProvider.turn({
|
|
99
124
|
system: composeSystem(ctx.cwd, opts.projectContext, opts.systemOverride, opts.memory),
|
|
@@ -107,10 +132,7 @@ export async function runAgent(history, opts) {
|
|
|
107
132
|
return;
|
|
108
133
|
}
|
|
109
134
|
stopSpin();
|
|
110
|
-
|
|
111
|
-
out("\n");
|
|
112
|
-
sawReasoning = false;
|
|
113
|
-
}
|
|
135
|
+
flushReasoningTail();
|
|
114
136
|
if (md)
|
|
115
137
|
md.push(d);
|
|
116
138
|
else
|
|
@@ -124,14 +146,29 @@ export async function runAgent(history, opts) {
|
|
|
124
146
|
sink.reasoning(d);
|
|
125
147
|
return;
|
|
126
148
|
}
|
|
149
|
+
// Terminal mode: render reasoning on its own dim lines (prefix `โ ` per line). Each
|
|
150
|
+
// line is committed once and never overwritten โ so a subsequent spinner tick can't
|
|
151
|
+
// clobber it (the old `out(c.dim(d))` bug). Multi-line deltas split cleanly; the
|
|
152
|
+
// current line resumes mid-output when the next delta arrives.
|
|
127
153
|
stopSpin();
|
|
128
|
-
|
|
129
|
-
|
|
154
|
+
const lines = d.split("\n");
|
|
155
|
+
for (let i = 0; i < lines.length; i++) {
|
|
156
|
+
if (!reasoningOpen) {
|
|
157
|
+
out(c.dim("โ "));
|
|
158
|
+
reasoningOpen = true;
|
|
159
|
+
}
|
|
160
|
+
out(c.dim(lines[i]));
|
|
161
|
+
if (i < lines.length - 1) {
|
|
162
|
+
out("\n");
|
|
163
|
+
reasoningOpen = false;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
130
166
|
}
|
|
131
167
|
: undefined,
|
|
132
168
|
signal: opts.signal,
|
|
133
169
|
});
|
|
134
170
|
stopSpin();
|
|
171
|
+
flushReasoningTail();
|
|
135
172
|
md?.end();
|
|
136
173
|
if (!opts.quiet && !sink)
|
|
137
174
|
out("\n");
|
package/dist/config.js
CHANGED
|
@@ -12,7 +12,8 @@ const PROVIDER_DEFAULTS = {
|
|
|
12
12
|
openai: { model: "gpt-4o-mini", envKey: "OPENAI_API_KEY" },
|
|
13
13
|
"hara-gateway": { model: "", envKey: "HARA_GATEWAY_TOKEN" }, // B-end: enrolled device โ token in ~/.hara/org.json, routed by the gateway
|
|
14
14
|
};
|
|
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"];
|
|
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", "reasoningEffort"];
|
|
16
|
+
export const REASONING_EFFORTS = ["off", "low", "medium", "high"];
|
|
16
17
|
export const APPROVAL_MODES = ["suggest", "auto-edit", "full-auto"];
|
|
17
18
|
export const SANDBOX_MODES = ["off", "workspace-write", "read-only"];
|
|
18
19
|
const PROJECT_ROOT_MARKERS = [".git", "package.json", "Cargo.toml", "go.mod", "pyproject.toml", ".hg"];
|
|
@@ -82,16 +83,27 @@ export function setModelVisionOverride(model, cap) {
|
|
|
82
83
|
persistConfig(p, cfg);
|
|
83
84
|
}
|
|
84
85
|
/**
|
|
85
|
-
* Effective config. Precedence (highโlow): env vars >
|
|
86
|
-
*
|
|
86
|
+
* Effective config. Precedence (highโlow): env vars > project `.hara/config.json` >
|
|
87
|
+
* named overlay (`overlays.<name>` in global config) > global `~/.hara/config.json`
|
|
88
|
+
* > provider defaults.
|
|
89
|
+
*
|
|
90
|
+
* NOTE: `--profile` / `HARA_PROFILE` is the IDENTITY-profile selector (personal โ org A
|
|
91
|
+
* โ org B) โ see src/profile/profile.ts. The legacy `profiles:{name:partial}` overlay
|
|
92
|
+
* mechanism (a tiny in-config preset / overlay) has been renamed to `overlays:{...}`
|
|
93
|
+
* to free the "profile" word for identity. We still read the legacy `profiles:{...}`
|
|
94
|
+
* key for one release for back-compat. Overlays are addressed by env var
|
|
95
|
+
* `HARA_OVERLAY=<name>` (or `opts.overlay`).
|
|
87
96
|
*/
|
|
88
97
|
export function loadConfig(opts = {}) {
|
|
89
98
|
const global = readRawConfig();
|
|
90
|
-
|
|
99
|
+
// Strip both the new (`overlays`) and legacy (`profiles`) overlay containers from the base merge.
|
|
100
|
+
// The legacy `profiles` key is kept readable for back-compat with users who already have it.
|
|
101
|
+
const { overlays, profiles, ...globalBase } = global;
|
|
91
102
|
const project = readProjectConfig(process.cwd());
|
|
92
|
-
const
|
|
93
|
-
const
|
|
94
|
-
const
|
|
103
|
+
const overlayName = process.env.HARA_OVERLAY ?? opts.overlay;
|
|
104
|
+
const overlayMap = overlays && typeof overlays === "object" ? overlays : profiles && typeof profiles === "object" ? profiles : null;
|
|
105
|
+
const overlay = overlayName && overlayMap && overlayMap[overlayName] ? overlayMap[overlayName] : {};
|
|
106
|
+
const merged = { ...globalBase, ...project, ...overlay };
|
|
95
107
|
const provider = (process.env.HARA_PROVIDER ?? merged.provider ?? "anthropic");
|
|
96
108
|
const d = PROVIDER_DEFAULTS[provider] ?? PROVIDER_DEFAULTS.anthropic;
|
|
97
109
|
const model = process.env.HARA_MODEL ?? merged.model ?? d.model;
|
|
@@ -118,7 +130,7 @@ export function loadConfig(opts = {}) {
|
|
|
118
130
|
const mcpServers = {
|
|
119
131
|
...(globalBase.mcpServers ?? {}),
|
|
120
132
|
...(project.mcpServers ?? {}),
|
|
121
|
-
...(
|
|
133
|
+
...(overlay.mcpServers ?? {}),
|
|
122
134
|
};
|
|
123
135
|
const hooks = (merged.hooks && typeof merged.hooks === "object" ? merged.hooks : {});
|
|
124
136
|
const notify = (process.env.HARA_NOTIFY ?? merged.notify ?? "off");
|
|
@@ -128,7 +140,11 @@ export function loadConfig(opts = {}) {
|
|
|
128
140
|
const fallbackModel = process.env.HARA_FALLBACK_MODEL ?? merged.fallbackModel;
|
|
129
141
|
const fallbackBaseURL = process.env.HARA_FALLBACK_BASE_URL ?? merged.fallbackBaseURL;
|
|
130
142
|
const fallbackApiKey = process.env.HARA_FALLBACK_API_KEY ?? merged.fallbackApiKey;
|
|
131
|
-
|
|
143
|
+
const reasoningRaw = process.env.HARA_REASONING_EFFORT ?? merged.reasoningEffort;
|
|
144
|
+
const reasoningEffort = reasoningRaw && ["off", "low", "medium", "high"].includes(reasoningRaw)
|
|
145
|
+
? reasoningRaw
|
|
146
|
+
: undefined;
|
|
147
|
+
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, reasoningEffort, mcpServers, cwd: process.cwd() };
|
|
132
148
|
}
|
|
133
149
|
export function providerEnvKey(provider) {
|
|
134
150
|
return (PROVIDER_DEFAULTS[provider] ?? PROVIDER_DEFAULTS.anthropic).envKey;
|
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") {
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// Signal adapter for `hara gateway` โ talks to a LOCAL signal-cli daemon running in HTTP/JSON-RPC mode
|
|
2
|
+
// (built-in fetch, zero new dep; no WebSocket, no cloud API). The user installs signal-cli, registers/links the
|
|
3
|
+
// bot's phone number, and runs `signal-cli -a <number> daemon --http localhost:8080`. Inbound is drained via the
|
|
4
|
+
// JSON-RPC `receive` method on a long-poll loop (the robust zero-dep path โ no SSE line-buffering quirks);
|
|
5
|
+
// outbound + attachment fetch go through JSON-RPC `send`/`getAttachment`. Creds from HARA_SIGNAL_RPC_URL
|
|
6
|
+
// (e.g. http://localhost:8080) + HARA_SIGNAL_NUMBER (the bot's registered phone, E.164). Same ChatAdapter shape
|
|
7
|
+
// as the others, so all cross-platform gateway plumbing (send_file, system context, stuck-guard, image
|
|
8
|
+
// attach/describe) works unchanged. Mirrors the Matrix long-poll model + the Discord download-media/sendFile
|
|
9
|
+
// patterns, and the hermes signal.py protocol/redaction behavior.
|
|
10
|
+
//
|
|
11
|
+
// EXTERNAL DEPENDENCY: signal-cli is NOT bundled โ it's a separate program the user installs and runs (it is the
|
|
12
|
+
// only way to speak Signal's protocol; there is no official cloud API). The adapter just speaks HTTP/JSON-RPC to
|
|
13
|
+
// the daemon the user has running. See setup notes at the bottom of this file.
|
|
14
|
+
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
15
|
+
import { join, basename } from "node:path";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { chunkText } from "./telegram.js";
|
|
18
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
19
|
+
// E.164 phone numbers (+15551234567) anywhere in a string โ +155****4567 (mirrors hermes' _redact_phone).
|
|
20
|
+
const PHONE_RE = /\+[1-9]\d{6,14}/g;
|
|
21
|
+
/** Redact phone numbers for logging. Keeps a 4/4 head+tail window; never logs the full E.164. (pure) */
|
|
22
|
+
export function redactPhone(s) {
|
|
23
|
+
if (!s)
|
|
24
|
+
return "<none>";
|
|
25
|
+
return s.replace(PHONE_RE, (p) => (p.length <= 8 ? "****" : `${p.slice(0, 4)}****${p.slice(-4)}`));
|
|
26
|
+
}
|
|
27
|
+
const isImageExt = (ext) => /^\.(png|jpe?g|gif|webp)$/i.test(ext);
|
|
28
|
+
/** Guess a file extension from an attachment's contentType / filename (signal-cli gives us both as hints). (pure) */
|
|
29
|
+
export function attachmentExt(contentType, filename) {
|
|
30
|
+
const ct = (contentType ?? "").toLowerCase();
|
|
31
|
+
if (ct.startsWith("image/")) {
|
|
32
|
+
if (ct.includes("png"))
|
|
33
|
+
return ".png";
|
|
34
|
+
if (ct.includes("jpeg") || ct.includes("jpg"))
|
|
35
|
+
return ".jpg";
|
|
36
|
+
if (ct.includes("gif"))
|
|
37
|
+
return ".gif";
|
|
38
|
+
if (ct.includes("webp"))
|
|
39
|
+
return ".webp";
|
|
40
|
+
}
|
|
41
|
+
const fromName = (filename ?? "").toLowerCase().match(/\.(png|jpe?g|gif|webp|mp4|mp3|ogg|m4a|pdf|txt)$/);
|
|
42
|
+
if (fromName)
|
|
43
|
+
return fromName[0];
|
|
44
|
+
if (ct.startsWith("audio/"))
|
|
45
|
+
return ".ogg";
|
|
46
|
+
if (ct.startsWith("video/"))
|
|
47
|
+
return ".mp4";
|
|
48
|
+
return ".bin";
|
|
49
|
+
}
|
|
50
|
+
/** Sniff a file extension from magic bytes โ fallback when signal-cli's base64 attachment lacks a contentType. (pure) */
|
|
51
|
+
export function extFromBytes(data) {
|
|
52
|
+
if (data.length >= 4 && data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47)
|
|
53
|
+
return ".png";
|
|
54
|
+
if (data.length >= 2 && data[0] === 0xff && data[1] === 0xd8)
|
|
55
|
+
return ".jpg";
|
|
56
|
+
if (data.length >= 4 && data[0] === 0x47 && data[1] === 0x49 && data[2] === 0x46 && data[3] === 0x38)
|
|
57
|
+
return ".gif";
|
|
58
|
+
if (data.length >= 12 && data[0] === 0x52 && data[1] === 0x49 && data[2] === 0x46 && data[3] === 0x46 && data[8] === 0x57 && data[9] === 0x45 && data[10] === 0x42 && data[11] === 0x50)
|
|
59
|
+
return ".webp";
|
|
60
|
+
return ".bin";
|
|
61
|
+
}
|
|
62
|
+
/** Parse one signal-cli envelope โ InboundMsg + the image attachments to fetch (pure; the fetch/download happens in
|
|
63
|
+
* start()). Mirrors hermes' _handle_envelope: unwraps the {envelope:{...}} shape, handles plain dataMessage + edits,
|
|
64
|
+
* filters our own outbound + stories. Filtering of OUR OWN number is done here via `selfNumber`. Returns the msg +
|
|
65
|
+
* the image attachment refs (only images โ other media is ignored, like Telegram/Matrix). null = skip. */
|
|
66
|
+
export function parseSignalMessage(raw, selfNumber) {
|
|
67
|
+
if (!raw || typeof raw !== "object")
|
|
68
|
+
return null;
|
|
69
|
+
const env = raw.envelope ?? raw; // signal-cli wraps payloads as { envelope: {...} }
|
|
70
|
+
// Stories / typing / receipts / sync echoes carry no inbound dataMessage โ skip. Also skip our own outbound
|
|
71
|
+
// (syncMessage is the echo of what WE sent from another linked device โ never treat it as user input).
|
|
72
|
+
if (env.syncMessage)
|
|
73
|
+
return null;
|
|
74
|
+
if (env.storyMessage)
|
|
75
|
+
return null;
|
|
76
|
+
if (env.typingMessage || env.receiptMessage)
|
|
77
|
+
return null;
|
|
78
|
+
const sender = env.sourceNumber || env.sourceUuid || env.source;
|
|
79
|
+
if (!sender)
|
|
80
|
+
return null;
|
|
81
|
+
if (selfNumber && (env.sourceNumber === selfNumber || env.source === selfNumber))
|
|
82
|
+
return null; // our own message
|
|
83
|
+
// editMessage carries its updated dataMessage nested inside (mirrors hermes).
|
|
84
|
+
const data = env.dataMessage ?? env.editMessage?.dataMessage;
|
|
85
|
+
if (!data || typeof data !== "object")
|
|
86
|
+
return null;
|
|
87
|
+
const group = data.groupInfo;
|
|
88
|
+
const groupId = group?.groupId;
|
|
89
|
+
const chatId = groupId ? `group:${groupId}` : String(sender);
|
|
90
|
+
const atts = Array.isArray(data.attachments) ? data.attachments : [];
|
|
91
|
+
const images = atts
|
|
92
|
+
.filter((a) => a?.id && (String(a.contentType ?? "").startsWith("image/") || isImageExt(attachmentExt(a?.contentType, a?.filename))))
|
|
93
|
+
.map((a) => ({ id: String(a.id), contentType: a?.contentType, filename: a?.filename }));
|
|
94
|
+
const text = typeof data.message === "string" ? data.message : "";
|
|
95
|
+
if (!text && images.length === 0)
|
|
96
|
+
return null; // no text + no image (reaction/sticker/other media) โ skip
|
|
97
|
+
return {
|
|
98
|
+
msg: {
|
|
99
|
+
chatId,
|
|
100
|
+
userId: String(sender),
|
|
101
|
+
userName: env.sourceName || String(sender),
|
|
102
|
+
text: text || "[ๅพ็]",
|
|
103
|
+
},
|
|
104
|
+
images,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
export function signalAdapter(rpcUrl, selfNumber) {
|
|
108
|
+
const base = rpcUrl.replace(/\/+$/, ""); // trim trailing slashes
|
|
109
|
+
let rpcSeq = 0;
|
|
110
|
+
/** One JSON-RPC 2.0 call to the signal-cli daemon. Returns `result` (any) or null on error. */
|
|
111
|
+
async function rpc(method, params, signal) {
|
|
112
|
+
const id = `${method}_${++rpcSeq}`;
|
|
113
|
+
try {
|
|
114
|
+
const res = await fetch(`${base}/api/v1/rpc`, {
|
|
115
|
+
method: "POST",
|
|
116
|
+
headers: { "content-type": "application/json" },
|
|
117
|
+
body: JSON.stringify({ jsonrpc: "2.0", method, params, id }),
|
|
118
|
+
signal,
|
|
119
|
+
});
|
|
120
|
+
if (!res.ok)
|
|
121
|
+
return null;
|
|
122
|
+
const j = (await res.json());
|
|
123
|
+
if (j.error) {
|
|
124
|
+
console.error(`hara gateway[signal]: RPC ${method} error:`, redactPhone(JSON.stringify(j.error)));
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
return j.result ?? null;
|
|
128
|
+
}
|
|
129
|
+
catch (e) {
|
|
130
|
+
if (signal?.aborted)
|
|
131
|
+
return null;
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** Recipient/group routing shared by send + sendFile (mirrors hermes). */
|
|
136
|
+
const target = (chatId) => {
|
|
137
|
+
const id = String(chatId);
|
|
138
|
+
return id.startsWith("group:") ? { groupId: id.slice(6) } : { recipient: [id] };
|
|
139
|
+
};
|
|
140
|
+
/** Fetch a signal-cli attachment by id (base64 over JSON-RPC) โ a local path under ~/.hara/signal/media. */
|
|
141
|
+
async function downloadAttachment(ref) {
|
|
142
|
+
try {
|
|
143
|
+
const result = await rpc("getAttachment", { account: selfNumber, id: ref.id });
|
|
144
|
+
// signal-cli returns either a raw base64 string or { data: "base64..." }
|
|
145
|
+
const b64 = typeof result === "string" ? result : result && typeof result === "object" ? result.data : null;
|
|
146
|
+
if (!b64 || typeof b64 !== "string")
|
|
147
|
+
return null;
|
|
148
|
+
const bytes = Buffer.from(b64, "base64");
|
|
149
|
+
let ext = attachmentExt(ref.contentType, ref.filename);
|
|
150
|
+
if (ext === ".bin")
|
|
151
|
+
ext = extFromBytes(bytes); // last-resort magic-byte sniff
|
|
152
|
+
const dir = join(homedir(), ".hara", "signal", "media");
|
|
153
|
+
mkdirSync(dir, { recursive: true });
|
|
154
|
+
const path = join(dir, `sig_${Date.now()}_${ref.id.slice(-12)}${ext}`);
|
|
155
|
+
writeFileSync(path, bytes);
|
|
156
|
+
return path;
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
name: "signal",
|
|
164
|
+
async send(chatId, text) {
|
|
165
|
+
for (const part of chunkText(text || "(empty)", 4000)) {
|
|
166
|
+
await rpc("send", { account: selfNumber, message: part, ...target(chatId) });
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
async sendFile(chatId, filePath) {
|
|
170
|
+
// Signal has no separate photo/document endpoints โ everything is an `attachments` path on `send`.
|
|
171
|
+
// signal-cli reads the file off the local disk by path, so we just hand it the path (no upload step).
|
|
172
|
+
await rpc("send", { account: selfNumber, message: "", attachments: [filePath], ...target(chatId) }).catch(() => { });
|
|
173
|
+
// (readFileSync/basename imported for parity with the other adapters' file plumbing; signal-cli reads by path.)
|
|
174
|
+
void readFileSync;
|
|
175
|
+
void basename;
|
|
176
|
+
},
|
|
177
|
+
async start(onMessage, signal) {
|
|
178
|
+
console.error(`hara gateway[signal]: polling signal-cli daemon at ${base} as ${redactPhone(selfNumber)} (ensure \`signal-cli -a <number> daemon --http\` is running).`);
|
|
179
|
+
while (!signal.aborted) {
|
|
180
|
+
try {
|
|
181
|
+
// JSON-RPC `receive` drains all envelopes queued since the last call. timeout is the long-poll seconds
|
|
182
|
+
// the daemon will hold the request open waiting for new messages (server-side block โ low-latency, low-spin).
|
|
183
|
+
const result = await rpc("receive", { account: selfNumber, timeout: 30 }, signal);
|
|
184
|
+
const envelopes = Array.isArray(result) ? result : result ? [result] : [];
|
|
185
|
+
for (const raw of envelopes) {
|
|
186
|
+
const parsed = parseSignalMessage(raw, selfNumber);
|
|
187
|
+
if (!parsed)
|
|
188
|
+
continue;
|
|
189
|
+
for (const ref of parsed.images) {
|
|
190
|
+
const path = await downloadAttachment(ref);
|
|
191
|
+
if (path)
|
|
192
|
+
(parsed.msg.images ??= []).push(path);
|
|
193
|
+
}
|
|
194
|
+
await onMessage(parsed.msg).catch(() => { });
|
|
195
|
+
}
|
|
196
|
+
if (envelopes.length === 0)
|
|
197
|
+
await sleep(500); // daemon returned immediately (no long-poll support) โ gentle spin
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
if (signal.aborted)
|
|
201
|
+
break;
|
|
202
|
+
await sleep(2000); // daemon down / network blip โ back off + retry (reconnect on drop)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
209
|
+
// signal-cli setup (the user does this once, OUTSIDE hara):
|
|
210
|
+
//
|
|
211
|
+
// 1. Install signal-cli brew install signal-cli (or download a release; needs a JRE)
|
|
212
|
+
// 2. Register OR link the number (a) register a NEW number:
|
|
213
|
+
// signal-cli -a +1555โฆ register # solve the captcha it prompts for
|
|
214
|
+
// signal-cli -a +1555โฆ verify 123456 # the SMS code
|
|
215
|
+
// (b) OR link to an EXISTING phone as a secondary device:
|
|
216
|
+
// signal-cli link -n "hara" # scan the QR from Signal app โ Linked devices
|
|
217
|
+
// 3. Run the HTTP/JSON-RPC daemon signal-cli -a +1555โฆ daemon --http localhost:8080
|
|
218
|
+
// 4. Point hara at it HARA_SIGNAL_RPC_URL=http://localhost:8080 HARA_SIGNAL_NUMBER=+1555โฆ
|
|
219
|
+
// hara gateway --platform signal
|
|
220
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|