@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 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
@@ -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
- let sawReasoning = false;
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(() => out(`\r${c.dim(`${frames[fi++ % frames.length]} working ${Math.floor((Date.now() - t0) / 1000)}s`)}`), 100);
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
- if (sawReasoning) {
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
- sawReasoning = true;
129
- out(c.dim(d));
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 > selected profile >
86
- * project `.hara/config.json` > global `~/.hara/config.json` > provider defaults.
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
- const { profiles, ...globalBase } = global;
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 profileName = process.env.HARA_PROFILE ?? opts.profile;
93
- const profile = profileName && profiles && profiles[profileName] ? profiles[profileName] : {};
94
- const merged = { ...globalBase, ...project, ...profile };
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
- ...(profile.mcpServers ?? {}),
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
- 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() };
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;
@@ -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
+ // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€