@nanhara/hara 0.121.1 → 0.122.1
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 +120 -0
- package/README.md +57 -10
- package/SECURITY.md +48 -9
- package/dist/agent/loop.js +169 -31
- package/dist/agent/reminders.js +22 -7
- package/dist/agent/repeat-guard.js +26 -7
- package/dist/agent/structured.js +231 -0
- package/dist/agent/touched.js +24 -6
- package/dist/checkpoints.js +103 -17
- package/dist/cli.js +16 -0
- package/dist/config.js +173 -34
- package/dist/context/agents-md.js +44 -9
- package/dist/context/mentions.js +10 -4
- package/dist/context/subdir-hints.js +40 -7
- package/dist/cron/deliver.js +37 -3
- package/dist/cron/runner.js +372 -37
- package/dist/cron/store.js +11 -3
- package/dist/exec/jobs.js +88 -20
- package/dist/feedback.js +3 -2
- package/dist/fs-read.js +421 -12
- package/dist/fs-walk.js +8 -2
- package/dist/fs-write.js +433 -21
- package/dist/gateway/dingtalk.js +4 -1
- package/dist/gateway/discord.js +53 -20
- package/dist/gateway/feishu.js +157 -58
- package/dist/gateway/flows-pending.js +727 -0
- package/dist/gateway/flows.js +391 -16
- package/dist/gateway/matrix.js +81 -18
- package/dist/gateway/mattermost.js +44 -34
- package/dist/gateway/media.js +659 -0
- package/dist/gateway/outbound-files.js +379 -0
- package/dist/gateway/serve.js +712 -169
- package/dist/gateway/sessions.js +475 -78
- package/dist/gateway/signal.js +31 -28
- package/dist/gateway/slack.js +28 -21
- package/dist/gateway/telegram.js +33 -21
- package/dist/gateway/tmux-routes.js +11 -3
- package/dist/gateway/wecom.js +38 -31
- package/dist/gateway/weixin.js +147 -59
- package/dist/hooks.js +41 -23
- package/dist/index.js +763 -273
- package/dist/mcp/client.js +164 -12
- package/dist/memory/store.js +68 -22
- package/dist/org/planner.js +36 -10
- package/dist/org/projects.js +347 -0
- package/dist/org/review-chain.js +360 -24
- package/dist/org/roles.js +42 -13
- package/dist/profile/profile.js +152 -27
- package/dist/recall.js +4 -2
- package/dist/runtime.js +37 -0
- package/dist/sandbox.js +142 -33
- package/dist/search/semindex.js +182 -53
- package/dist/search/zvec-store.js +121 -42
- package/dist/security/permissions.js +326 -19
- package/dist/security/private-state.js +299 -0
- package/dist/security/project-trust.js +6 -0
- package/dist/security/secrets.js +84 -9
- package/dist/security/sensitive-files.js +723 -0
- package/dist/security/subprocess-env.js +210 -0
- package/dist/serve/server.js +774 -318
- package/dist/serve/sessions.js +113 -33
- package/dist/session/store.js +298 -47
- package/dist/skills/skills.js +16 -7
- package/dist/tools/all.js +1 -0
- package/dist/tools/builtin.js +77 -49
- package/dist/tools/codebase.js +3 -1
- package/dist/tools/computer.js +98 -92
- package/dist/tools/cron.js +6 -0
- package/dist/tools/edit.js +22 -9
- package/dist/tools/external_agent.js +110 -16
- package/dist/tools/memory.js +38 -8
- package/dist/tools/patch.js +253 -34
- package/dist/tools/search.js +543 -73
- package/dist/tools/send.js +11 -5
- package/dist/tools/task.js +453 -0
- package/dist/tools/todo.js +67 -16
- package/dist/tools/web.js +168 -54
- package/dist/undo.js +83 -7
- package/package.json +11 -10
- package/runtime-bootstrap.cjs +72 -0
package/dist/gateway/serve.js
CHANGED
|
@@ -4,14 +4,20 @@
|
|
|
4
4
|
// persistent process; it is never required by the core CLI.
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
6
|
import { telegramAdapter } from "./telegram.js";
|
|
7
|
-
import {
|
|
7
|
+
import { dispatchFlows } from "./flows.js";
|
|
8
|
+
import { handleOwnerReply, runNoToolModel } from "./flows-pending.js";
|
|
9
|
+
import { chatContext, chatCd, newChatSession, ownsChatSession, resolveOwnedSessionId, setChatSession, setChatAgent, toggleVoice } from "./sessions.js";
|
|
10
|
+
import { plainChat } from "../cron/deliver.js";
|
|
8
11
|
import { pickPaneForReply, capturePane, injectTmux, outputDelta } from "./tmux-routes.js";
|
|
9
12
|
import { synthesize } from "./tts.js";
|
|
13
|
+
import { cleanupTransientMedia, pruneStaleMedia } from "./media.js";
|
|
10
14
|
import { selfArgv } from "../cron/runner.js";
|
|
11
|
-
import { listSessions,
|
|
15
|
+
import { listSessions, loadSession } from "../session/store.js";
|
|
12
16
|
import { homedir, tmpdir } from "node:os";
|
|
13
17
|
import { join, resolve } from "node:path";
|
|
14
|
-
import { mkdirSync, writeFileSync, readFileSync, existsSync, statSync, rmSync } from "node:fs";
|
|
18
|
+
import { chmodSync, mkdirSync, writeFileSync, readFileSync, existsSync, statSync, rmSync } from "node:fs";
|
|
19
|
+
import { redactToolSubprocessOutput, terminateSubprocessTree } from "../security/subprocess-env.js";
|
|
20
|
+
import { cleanupOutboundSnapshot, cleanupOutboundSnapshots, consumeOutboundSnapshots, queueOutboundSnapshot, } from "./outbound-files.js";
|
|
15
21
|
/** Parse a leading slash-command from a chat message (pure). null if it isn't one. */
|
|
16
22
|
export function parseCommand(text) {
|
|
17
23
|
const m = /^\/([a-z]+)\b\s*([\s\S]*)$/i.exec(text.trim());
|
|
@@ -21,55 +27,395 @@ export function parseCommand(text) {
|
|
|
21
27
|
export function isAllowed(userId, allowlist) {
|
|
22
28
|
return allowlist.size > 0 && allowlist.has(String(userId));
|
|
23
29
|
}
|
|
30
|
+
/** Media is fetched only for an explicitly classified DM from an allowed identity. Group flows still receive
|
|
31
|
+
* their text/metadata through the normal callback, but never cause the adapter to touch attachment bytes. */
|
|
32
|
+
export function shouldDownloadInboundMedia(m, allowlist) {
|
|
33
|
+
return m.chatType === "p2p" && isAllowed(m.userId, allowlist);
|
|
34
|
+
}
|
|
35
|
+
/** CLI aliases are accepted only at the startup boundary. Session keys, flow matching, approval targets,
|
|
36
|
+
* and HARA_GATEWAY must use the adapter's canonical platform name so deferred delivery can parse them. */
|
|
37
|
+
export function canonicalGatewayPlatform(platform) {
|
|
38
|
+
const value = platform.trim().toLowerCase();
|
|
39
|
+
if (value === "lark")
|
|
40
|
+
return "feishu";
|
|
41
|
+
if (value === "ding")
|
|
42
|
+
return "dingtalk";
|
|
43
|
+
if (value === "wework")
|
|
44
|
+
return "wecom";
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
24
47
|
/** Strip hara's CLI chrome from captured `-p` output so a chat reply is just the answer: MCP status lines
|
|
25
48
|
* (`mcp: …`) and the token-usage footer (`… · ↑N ↓N tok`). Colors are off when piped, so no ANSI to strip. */
|
|
26
49
|
export function cleanReply(raw) {
|
|
27
|
-
return raw
|
|
50
|
+
return redactToolSubprocessOutput(raw)
|
|
28
51
|
.split("\n")
|
|
29
52
|
.filter((ln) => !/^\s*mcp: /.test(ln) && !/·\s*↑\d+\s*↓\d+\s*tok\s*$/.test(ln))
|
|
30
53
|
.join("\n")
|
|
31
54
|
.trim();
|
|
32
55
|
}
|
|
33
56
|
let outboxSeq = 0;
|
|
57
|
+
/** Snapshot and materialize a direct gateway file before an adapter sees it. This also covers TTS output, so
|
|
58
|
+
* every outbound attachment crosses the same verified-byte boundary rather than handing adapters a pathname. */
|
|
59
|
+
async function deliverVerifiedLocalFile(adapter, chatId, sourcePath) {
|
|
60
|
+
const sendFile = adapter.sendFile;
|
|
61
|
+
if (!sendFile)
|
|
62
|
+
throw new Error("this platform can't send files yet");
|
|
63
|
+
const outbox = join(tmpdir(), `hara-direct-send-${process.pid}-${Date.now()}-${outboxSeq++}.txt`);
|
|
64
|
+
let payload;
|
|
65
|
+
try {
|
|
66
|
+
await queueOutboundSnapshot(sourcePath, outbox);
|
|
67
|
+
[payload] = await consumeOutboundSnapshots(outbox);
|
|
68
|
+
cleanupOutboundSnapshots(outbox, payload ? [payload.snapshotPath] : []);
|
|
69
|
+
if (!payload)
|
|
70
|
+
throw new Error("the private file snapshot could not be verified");
|
|
71
|
+
await sendFile(chatId, payload);
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
if (payload)
|
|
75
|
+
cleanupOutboundSnapshot(payload.snapshotPath);
|
|
76
|
+
cleanupOutboundSnapshots(outbox);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export class GatewayQueueFullError extends Error {
|
|
80
|
+
key;
|
|
81
|
+
limit;
|
|
82
|
+
scope;
|
|
83
|
+
constructor(key, limit, scope = "session") {
|
|
84
|
+
const subject = scope === "session" ? `queue '${key}'` : scope === "keys" ? "session-key set" : "global waiting queue";
|
|
85
|
+
super(`gateway ${subject} is full (${limit})`);
|
|
86
|
+
this.key = key;
|
|
87
|
+
this.limit = limit;
|
|
88
|
+
this.scope = scope;
|
|
89
|
+
this.name = "GatewayQueueFullError";
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
export class GatewayQueueClosedError extends Error {
|
|
93
|
+
constructor(message = "gateway is shutting down") {
|
|
94
|
+
super(message);
|
|
95
|
+
this.name = "GatewayQueueClosedError";
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/** A bounded serial queue per key. Tasks with the same key run in arrival order, while different keys may
|
|
99
|
+
* run concurrently up to `maxActive`. Global key and waiting-task limits prevent rotating chat/session ids
|
|
100
|
+
* from turning the daemon into an unbounded child-process launcher. Settled keys are removed eagerly. */
|
|
101
|
+
export class KeyedSerialQueue {
|
|
102
|
+
maxDepth;
|
|
103
|
+
maxActive;
|
|
104
|
+
maxQueued;
|
|
105
|
+
maxKeys;
|
|
106
|
+
states = new Map();
|
|
107
|
+
ready = [];
|
|
108
|
+
idleWaiters = new Set();
|
|
109
|
+
active = 0;
|
|
110
|
+
queued = 0;
|
|
111
|
+
closedError;
|
|
112
|
+
constructor(maxDepth = 8, maxActive = 4, maxQueued = 64, maxKeys = 32) {
|
|
113
|
+
this.maxDepth = maxDepth;
|
|
114
|
+
this.maxActive = maxActive;
|
|
115
|
+
this.maxQueued = maxQueued;
|
|
116
|
+
this.maxKeys = maxKeys;
|
|
117
|
+
if (!Number.isSafeInteger(maxDepth) || maxDepth < 1)
|
|
118
|
+
throw new RangeError("maxDepth must be a positive integer");
|
|
119
|
+
if (!Number.isSafeInteger(maxActive) || maxActive < 1)
|
|
120
|
+
throw new RangeError("maxActive must be a positive integer");
|
|
121
|
+
if (!Number.isSafeInteger(maxQueued) || maxQueued < 1)
|
|
122
|
+
throw new RangeError("maxQueued must be a positive integer");
|
|
123
|
+
if (!Number.isSafeInteger(maxKeys) || maxKeys < 1)
|
|
124
|
+
throw new RangeError("maxKeys must be a positive integer");
|
|
125
|
+
}
|
|
126
|
+
get size() {
|
|
127
|
+
return this.states.size;
|
|
128
|
+
}
|
|
129
|
+
pending(key) {
|
|
130
|
+
return this.states.get(key)?.depth ?? 0;
|
|
131
|
+
}
|
|
132
|
+
get activeCount() {
|
|
133
|
+
return this.active;
|
|
134
|
+
}
|
|
135
|
+
get queuedCount() {
|
|
136
|
+
return this.queued;
|
|
137
|
+
}
|
|
138
|
+
run(key, task) {
|
|
139
|
+
if (this.closedError)
|
|
140
|
+
return Promise.reject(this.closedError);
|
|
141
|
+
let state = this.states.get(key);
|
|
142
|
+
if (state && state.depth >= this.maxDepth)
|
|
143
|
+
return Promise.reject(new GatewayQueueFullError(key, this.maxDepth));
|
|
144
|
+
if (!state) {
|
|
145
|
+
if (this.states.size >= this.maxKeys)
|
|
146
|
+
return Promise.reject(new GatewayQueueFullError(key, this.maxKeys, "keys"));
|
|
147
|
+
state = { depth: 0, running: false, ready: false, waiting: [] };
|
|
148
|
+
this.states.set(key, state);
|
|
149
|
+
}
|
|
150
|
+
if (this.queued >= this.maxQueued) {
|
|
151
|
+
if (state.depth === 0)
|
|
152
|
+
this.states.delete(key);
|
|
153
|
+
return Promise.reject(new GatewayQueueFullError(key, this.maxQueued, "queued"));
|
|
154
|
+
}
|
|
155
|
+
const result = new Promise((resolve, reject) => {
|
|
156
|
+
state.waiting.push({
|
|
157
|
+
task,
|
|
158
|
+
resolve: (value) => resolve(value),
|
|
159
|
+
reject,
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
state.depth++;
|
|
163
|
+
this.queued++;
|
|
164
|
+
this.markReady(key, state);
|
|
165
|
+
this.drain();
|
|
166
|
+
return result;
|
|
167
|
+
}
|
|
168
|
+
/** Reject work that has not started. Running tasks settle normally (their AbortSignal is owned by the
|
|
169
|
+
* caller), allowing daemon shutdown to kill the actual child and then wait for a bounded clean drain. */
|
|
170
|
+
close(error = new GatewayQueueClosedError()) {
|
|
171
|
+
if (this.closedError)
|
|
172
|
+
return;
|
|
173
|
+
this.closedError = error;
|
|
174
|
+
this.ready.length = 0;
|
|
175
|
+
for (const [key, state] of this.states) {
|
|
176
|
+
state.ready = false;
|
|
177
|
+
const waiting = state.waiting.splice(0);
|
|
178
|
+
state.depth -= waiting.length;
|
|
179
|
+
this.queued -= waiting.length;
|
|
180
|
+
for (const entry of waiting)
|
|
181
|
+
entry.reject(error);
|
|
182
|
+
if (!state.running)
|
|
183
|
+
this.states.delete(key);
|
|
184
|
+
}
|
|
185
|
+
this.notifyIdle();
|
|
186
|
+
}
|
|
187
|
+
waitForIdle() {
|
|
188
|
+
if (this.active === 0 && this.queued === 0)
|
|
189
|
+
return Promise.resolve();
|
|
190
|
+
return new Promise((resolve) => this.idleWaiters.add(resolve));
|
|
191
|
+
}
|
|
192
|
+
markReady(key, state) {
|
|
193
|
+
if (state.ready || state.running || state.waiting.length === 0)
|
|
194
|
+
return;
|
|
195
|
+
state.ready = true;
|
|
196
|
+
this.ready.push(key);
|
|
197
|
+
}
|
|
198
|
+
drain() {
|
|
199
|
+
while (!this.closedError && this.active < this.maxActive && this.ready.length > 0) {
|
|
200
|
+
const key = this.ready.shift();
|
|
201
|
+
const state = this.states.get(key);
|
|
202
|
+
if (!state || state.running || state.waiting.length === 0)
|
|
203
|
+
continue;
|
|
204
|
+
state.ready = false;
|
|
205
|
+
state.running = true;
|
|
206
|
+
const entry = state.waiting.shift();
|
|
207
|
+
this.queued--;
|
|
208
|
+
this.active++;
|
|
209
|
+
void Promise.resolve()
|
|
210
|
+
.then(entry.task)
|
|
211
|
+
.then((value) => this.finish(key, state, entry, true, value), (error) => this.finish(key, state, entry, false, error));
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
finish(key, state, entry, ok, outcome) {
|
|
215
|
+
this.active--;
|
|
216
|
+
state.depth--;
|
|
217
|
+
state.running = false;
|
|
218
|
+
if (state.depth === 0 && this.states.get(key) === state)
|
|
219
|
+
this.states.delete(key);
|
|
220
|
+
else if (!this.closedError)
|
|
221
|
+
this.markReady(key, state);
|
|
222
|
+
this.drain();
|
|
223
|
+
this.notifyIdle();
|
|
224
|
+
if (ok)
|
|
225
|
+
entry.resolve(outcome);
|
|
226
|
+
else
|
|
227
|
+
entry.reject(outcome);
|
|
228
|
+
}
|
|
229
|
+
notifyIdle() {
|
|
230
|
+
if (this.active !== 0 || this.queued !== 0)
|
|
231
|
+
return;
|
|
232
|
+
for (const resolve of this.idleWaiters)
|
|
233
|
+
resolve();
|
|
234
|
+
this.idleWaiters.clear();
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const DEFAULT_RUN_TIMEOUT_MS = 15 * 60_000;
|
|
238
|
+
const MAX_RUN_TIMEOUT_MS = 30 * 60_000;
|
|
239
|
+
const MIN_RUN_TIMEOUT_MS = 50;
|
|
240
|
+
const DEFAULT_KILL_GRACE_MS = 2_000;
|
|
241
|
+
const MAX_KILL_GRACE_MS = 5_000;
|
|
242
|
+
function boundedDuration(value, fallback, max) {
|
|
243
|
+
const parsed = typeof value === "number" ? value : Number(value);
|
|
244
|
+
return Number.isFinite(parsed) ? Math.max(MIN_RUN_TIMEOUT_MS, Math.min(max, Math.trunc(parsed))) : fallback;
|
|
245
|
+
}
|
|
246
|
+
/** `HARA_GATEWAY_RUN_TIMEOUT_MS` is operator-tunable but cannot disable or exceed the hard 30-minute cap. */
|
|
247
|
+
export function gatewayRunTimeoutMs(value = process.env.HARA_GATEWAY_RUN_TIMEOUT_MS) {
|
|
248
|
+
return boundedDuration(value, DEFAULT_RUN_TIMEOUT_MS, MAX_RUN_TIMEOUT_MS);
|
|
249
|
+
}
|
|
250
|
+
function runFailure(message, output) {
|
|
251
|
+
const detail = cleanReply(output);
|
|
252
|
+
return detail ? `✗ ${message}\n${detail}` : `✗ ${message}`;
|
|
253
|
+
}
|
|
254
|
+
function durationLabel(ms) {
|
|
255
|
+
if (ms < 1_000)
|
|
256
|
+
return `${ms}ms`;
|
|
257
|
+
if (ms % 60_000 === 0)
|
|
258
|
+
return `${ms / 60_000}m`;
|
|
259
|
+
return `${Math.round(ms / 1_000)}s`;
|
|
260
|
+
}
|
|
34
261
|
/** Run hara headlessly on a chat's session. Returns its cleaned text reply plus any files the agent queued
|
|
35
262
|
* via send_file. The gateway env (HARA_GATEWAY + a per-message outbox file) is what makes send_file and the
|
|
36
263
|
* in-chat system context active in the subprocess; the daemon delivers the queued files after it exits. */
|
|
37
|
-
function runHara(text, sessionId, cwd, platform, images) {
|
|
264
|
+
export function runHara(text, sessionId, cwd, platform, images, role, options = {}) {
|
|
265
|
+
if (options.signal?.aborted)
|
|
266
|
+
return Promise.resolve({ reply: "✗ hara run cancelled because the gateway is shutting down.", files: [] });
|
|
38
267
|
const outbox = join(tmpdir(), `hara-outbox-${process.pid}-${Date.now()}-${outboxSeq++}.txt`);
|
|
268
|
+
const timeoutMs = gatewayRunTimeoutMs(options.timeoutMs);
|
|
269
|
+
const killGraceMs = boundedDuration(options.killGraceMs, DEFAULT_KILL_GRACE_MS, MAX_KILL_GRACE_MS);
|
|
39
270
|
return new Promise((res) => {
|
|
40
271
|
const self = selfArgv();
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
272
|
+
const args = [...self.slice(1), "-p", text, "--approval", "full-auto", "--resume", sessionId];
|
|
273
|
+
if (role)
|
|
274
|
+
args.push("--role", role); // /agent-pinned persona for this thread (default = main agent)
|
|
275
|
+
let child;
|
|
276
|
+
try {
|
|
277
|
+
child = spawn(self[0], args, {
|
|
278
|
+
cwd,
|
|
279
|
+
detached: process.platform !== "win32",
|
|
280
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
281
|
+
env: {
|
|
282
|
+
...process.env,
|
|
283
|
+
HARA_GATEWAY: platform,
|
|
284
|
+
HARA_GATEWAY_OUTBOX: outbox,
|
|
285
|
+
...(images?.length ? { HARA_GATEWAY_IMAGES: images.join("\n") } : {}),
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
291
|
+
res({ reply: `✗ couldn't start hara: ${message}`, files: [] });
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
50
294
|
let out = "";
|
|
295
|
+
let settled = false;
|
|
296
|
+
let exited = false;
|
|
297
|
+
let exitCode = null;
|
|
298
|
+
let exitSignal = null;
|
|
299
|
+
let stopReason;
|
|
300
|
+
let drainTimer;
|
|
301
|
+
let forceIssued = false;
|
|
302
|
+
let cancelTermination;
|
|
51
303
|
const cap = (d) => {
|
|
52
|
-
|
|
304
|
+
if (settled || stopReason)
|
|
305
|
+
return;
|
|
306
|
+
out = (out + d.toString().slice(-12_000)).slice(-12_000);
|
|
307
|
+
};
|
|
308
|
+
child.stdout?.on("data", cap);
|
|
309
|
+
child.stderr?.on("data", cap);
|
|
310
|
+
// The promise/timers govern lifecycle. Neither the child handle nor inherited pipe writers may keep a
|
|
311
|
+
// shutting-down gateway alive indefinitely.
|
|
312
|
+
child.unref();
|
|
313
|
+
child.stdout?.unref?.();
|
|
314
|
+
child.stderr?.unref?.();
|
|
315
|
+
const readOutbox = async () => {
|
|
316
|
+
const files = await consumeOutboundSnapshots(outbox);
|
|
317
|
+
// Remove abandoned partials and invalid queue entries, but retain accepted snapshots until the adapter
|
|
318
|
+
// has finished uploading them in the parent gateway process.
|
|
319
|
+
cleanupOutboundSnapshots(outbox, files.map((file) => file.snapshotPath));
|
|
320
|
+
return files;
|
|
53
321
|
};
|
|
54
|
-
child.stdout.on("data", cap);
|
|
55
|
-
child.stderr.on("data", cap);
|
|
56
322
|
const finish = (reply) => {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
323
|
+
if (settled)
|
|
324
|
+
return;
|
|
325
|
+
settled = true;
|
|
326
|
+
clearTimeout(timeoutTimer);
|
|
327
|
+
if (drainTimer)
|
|
328
|
+
clearTimeout(drainTimer);
|
|
329
|
+
// The helper deliberately keeps its force timer alive by default. When a direct child closes after
|
|
330
|
+
// TERM, that timer must still SIGKILL the owned group before this stopped run can be considered gone.
|
|
331
|
+
cancelTermination?.();
|
|
332
|
+
options.signal?.removeEventListener("abort", abortRun);
|
|
333
|
+
child.stdout?.destroy();
|
|
334
|
+
child.stderr?.destroy();
|
|
335
|
+
void readOutbox().then((files) => res({ reply, files }), () => {
|
|
336
|
+
cleanupOutboundSnapshots(outbox);
|
|
337
|
+
res({ reply, files: [] });
|
|
338
|
+
});
|
|
339
|
+
};
|
|
340
|
+
const finishFromExit = () => {
|
|
341
|
+
// `exit`/`close` describes only the direct child. During a timeout/shutdown, wait until the forced
|
|
342
|
+
// process-group signal has been issued so a quiet TERM-resistant descendant cannot escape cleanup.
|
|
343
|
+
if (stopReason && !forceIssued)
|
|
344
|
+
return;
|
|
345
|
+
if (stopReason === "timeout") {
|
|
346
|
+
finish(runFailure(`hara timed out after ${durationLabel(timeoutMs)}; the run was stopped.`, out));
|
|
347
|
+
}
|
|
348
|
+
else if (stopReason === "shutdown") {
|
|
349
|
+
finish(runFailure("hara run cancelled because the gateway is shutting down.", out));
|
|
350
|
+
}
|
|
351
|
+
else if (exitSignal) {
|
|
352
|
+
finish(runFailure(`hara was terminated by ${exitSignal}.`, out));
|
|
353
|
+
}
|
|
354
|
+
else if (exitCode !== 0) {
|
|
355
|
+
finish(runFailure(exitCode === null ? "hara stopped without an exit status." : `hara failed with exit code ${exitCode}.`, out));
|
|
63
356
|
}
|
|
64
|
-
|
|
65
|
-
|
|
357
|
+
else {
|
|
358
|
+
const reply = cleanReply(out);
|
|
359
|
+
finish(reply || "✗ hara completed but produced no reply.");
|
|
66
360
|
}
|
|
67
|
-
res({ reply, files });
|
|
68
361
|
};
|
|
69
|
-
|
|
70
|
-
|
|
362
|
+
const terminate = (reason) => {
|
|
363
|
+
if (settled || cancelTermination)
|
|
364
|
+
return;
|
|
365
|
+
stopReason ??= reason;
|
|
366
|
+
cancelTermination = terminateSubprocessTree(child, {
|
|
367
|
+
processGroup: process.platform !== "win32",
|
|
368
|
+
graceMs: killGraceMs,
|
|
369
|
+
fallbackMs: 250,
|
|
370
|
+
onForce: () => {
|
|
371
|
+
forceIssued = true;
|
|
372
|
+
if (exited)
|
|
373
|
+
finishFromExit();
|
|
374
|
+
},
|
|
375
|
+
// A daemon may escape the group or a runtime may fail to emit exit/close. Destroying our pipe ends in
|
|
376
|
+
// `finish` keeps gateway shutdown and per-run timeout bounded even in that case.
|
|
377
|
+
onFallback: finishFromExit,
|
|
378
|
+
});
|
|
379
|
+
};
|
|
380
|
+
const abortRun = () => terminate("shutdown");
|
|
381
|
+
const timeoutTimer = setTimeout(() => terminate("timeout"), timeoutMs);
|
|
382
|
+
if (options.signal)
|
|
383
|
+
options.signal.addEventListener("abort", abortRun, { once: true });
|
|
384
|
+
// Close the narrow race where the signal aborts after the entry check but before the listener is attached.
|
|
385
|
+
if (options.signal?.aborted)
|
|
386
|
+
abortRun();
|
|
387
|
+
child.once("error", (error) => {
|
|
388
|
+
if (!stopReason)
|
|
389
|
+
return finish(runFailure(`couldn't start hara: ${error.message}`, out));
|
|
390
|
+
exited = true;
|
|
391
|
+
if (forceIssued)
|
|
392
|
+
finishFromExit();
|
|
393
|
+
});
|
|
394
|
+
child.once("exit", (code, signal) => {
|
|
395
|
+
exited = true;
|
|
396
|
+
exitCode = code;
|
|
397
|
+
exitSignal = signal;
|
|
398
|
+
// Usually `close` follows after the final pipe data. A grandchild can retain stdout/stderr forever, so
|
|
399
|
+
// cap that drain window and then destroy the pipes ourselves.
|
|
400
|
+
if (stopReason)
|
|
401
|
+
finishFromExit();
|
|
402
|
+
else
|
|
403
|
+
drainTimer = setTimeout(finishFromExit, 100);
|
|
404
|
+
});
|
|
405
|
+
child.once("close", (code, signal) => {
|
|
406
|
+
exited = true;
|
|
407
|
+
exitCode = code;
|
|
408
|
+
exitSignal = signal;
|
|
409
|
+
finishFromExit();
|
|
410
|
+
});
|
|
71
411
|
});
|
|
72
412
|
}
|
|
413
|
+
/** Run a one-off FLOW text task directly against the provider with `tools: []`. The old subprocess path
|
|
414
|
+
* launched the full coding agent with `--approval full-auto`, allowing a prompt-injected group message to
|
|
415
|
+
* reach bash/edit/MCP tools. `cwd` is intentionally ignored: isolated flow judgments cannot read a project. */
|
|
416
|
+
function runFlowAgent(prompt, _cwd, schema, signal) {
|
|
417
|
+
return runNoToolModel(prompt, { schema, timeoutMs: 60_000, signal });
|
|
418
|
+
}
|
|
73
419
|
/** Re-exported so `hara gateway --platform weixin --login` can run the QR flow. */
|
|
74
420
|
export { weixinLogin } from "./weixin.js";
|
|
75
421
|
async function buildAdapter(platform) {
|
|
@@ -131,7 +477,7 @@ async function buildAdapter(platform) {
|
|
|
131
477
|
return null;
|
|
132
478
|
}
|
|
133
479
|
const { matrixAdapter } = await import("./matrix.js");
|
|
134
|
-
return { adapter: matrixAdapter(homeserver, token, userId)
|
|
480
|
+
return { adapter: matrixAdapter(homeserver, token, userId) };
|
|
135
481
|
}
|
|
136
482
|
if (platform === "dingtalk" || platform === "ding") {
|
|
137
483
|
const clientId = process.env.HARA_DINGTALK_CLIENT_ID;
|
|
@@ -161,7 +507,7 @@ async function buildAdapter(platform) {
|
|
|
161
507
|
return null;
|
|
162
508
|
}
|
|
163
509
|
const { signalAdapter } = await import("./signal.js");
|
|
164
|
-
return { adapter: signalAdapter(rpcUrl, number)
|
|
510
|
+
return { adapter: signalAdapter(rpcUrl, number) };
|
|
165
511
|
}
|
|
166
512
|
const token = process.env.HARA_TELEGRAM_TOKEN;
|
|
167
513
|
if (!token) {
|
|
@@ -170,174 +516,371 @@ async function buildAdapter(platform) {
|
|
|
170
516
|
}
|
|
171
517
|
return { adapter: telegramAdapter(token) };
|
|
172
518
|
}
|
|
173
|
-
/** Allowlist = the env ids ∪
|
|
174
|
-
|
|
519
|
+
/** Allowlist = the env ids ∪ a platform-confirmed owner (WeChat QR) ∪ an explicitly configured approval
|
|
520
|
+
* owner. Matrix/Signal bot account IDs are deliberately not treated as human owners. */
|
|
521
|
+
export function resolveAllowlist(envValue, ownerId, explicitOwner) {
|
|
175
522
|
const set = new Set((envValue ?? "").split(",").map((s) => s.trim()).filter(Boolean));
|
|
176
523
|
if (ownerId)
|
|
177
524
|
set.add(ownerId);
|
|
525
|
+
if (explicitOwner?.trim())
|
|
526
|
+
set.add(explicitOwner.trim());
|
|
178
527
|
return set;
|
|
179
528
|
}
|
|
529
|
+
/** Choose exactly one identity allowed to approve consequential flow actions. A configured owner wins,
|
|
530
|
+
* then a platform-confirmed owner; otherwise a one-person allowlist is unambiguous. Multiple allowlisted
|
|
531
|
+
* operators must set HARA_GATEWAY_OWNER — we never let whichever DM replies first become "owner". */
|
|
532
|
+
export function resolveApprovalOwner(explicitOwner, detectedOwner, allowlist) {
|
|
533
|
+
const candidate = explicitOwner?.trim() || detectedOwner?.trim();
|
|
534
|
+
if (candidate)
|
|
535
|
+
return allowlist.has(candidate) ? candidate : undefined;
|
|
536
|
+
if (allowlist.size === 1)
|
|
537
|
+
return allowlist.values().next().value;
|
|
538
|
+
return undefined;
|
|
539
|
+
}
|
|
540
|
+
/** Approval replies must be private as well as identity-matched. Adapters that explicitly classify the chat
|
|
541
|
+
* are authoritative; older adapters may use the conservative DM invariant chatId===userId. Unknown channel
|
|
542
|
+
* shapes fail closed (the same action remains available in the local desktop approvals inbox). */
|
|
543
|
+
export function isPrivateApprovalMessage(m) {
|
|
544
|
+
if (m.chatType === "p2p")
|
|
545
|
+
return true;
|
|
546
|
+
if (m.chatType === "group")
|
|
547
|
+
return false;
|
|
548
|
+
return String(m.chatId) === String(m.userId);
|
|
549
|
+
}
|
|
180
550
|
/** The gateway's default workspace when no --cwd is given: a dedicated safe home under ~/.hara (like Hermes'
|
|
181
551
|
* ~/.hermes), NOT the launch dir — so a full-auto chat bot never lands on a real repo by accident. */
|
|
182
552
|
export function defaultWorkspace() {
|
|
183
|
-
const
|
|
184
|
-
|
|
553
|
+
const base = join(homedir(), ".hara");
|
|
554
|
+
const dir = join(base, "workspace");
|
|
555
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
556
|
+
try {
|
|
557
|
+
chmodSync(base, 0o700);
|
|
558
|
+
chmodSync(dir, 0o700);
|
|
559
|
+
}
|
|
560
|
+
catch {
|
|
561
|
+
/* best effort on filesystems without POSIX modes */
|
|
562
|
+
}
|
|
185
563
|
const agents = join(dir, "AGENTS.md");
|
|
186
|
-
|
|
187
|
-
|
|
564
|
+
const LEGACY = "# hara chat workspace\n\nDefault working directory for `hara gateway` (Telegram/WeChat). Each message runs here with `--approval full-auto`. A safe scratch — pass `--cwd <dir>` to point the gateway at a real project instead.\n";
|
|
565
|
+
const TEMPLATE = "# hara chat workspace\n\n" +
|
|
566
|
+
"Default working directory for `hara gateway`. Each message runs here with `--approval full-auto`. " +
|
|
567
|
+
"A safe scratch — pass `--cwd <dir>` to point the gateway at a real project instead.\n\n" +
|
|
568
|
+
"## How to work here\n\n" +
|
|
569
|
+
"- You are a chat-driven assistant on the user's machine. Deliver files/images with the `send_file` tool.\n" +
|
|
570
|
+
"- To interact with chat/work platforms (Feishu, Slack, WeChat, email, …), use an available skill or their HTTP API — " +
|
|
571
|
+
"check `skill` and the user's configured skills FIRST. Do NOT try to control other desktop apps' windows; " +
|
|
572
|
+
"screen control is disabled in gateway runs.\n" +
|
|
573
|
+
"- You often lack project context here. If a request concerns a specific project, say so and suggest `/cd <project>` " +
|
|
574
|
+
"(or answer from what you can read) rather than guessing.\n";
|
|
575
|
+
if (!existsSync(agents))
|
|
576
|
+
writeFileSync(agents, TEMPLATE, { mode: 0o600 });
|
|
577
|
+
else {
|
|
578
|
+
try {
|
|
579
|
+
if (readFileSync(agents, "utf8") === LEGACY)
|
|
580
|
+
writeFileSync(agents, TEMPLATE); // refresh unmodified old default
|
|
581
|
+
chmodSync(agents, 0o600);
|
|
582
|
+
}
|
|
583
|
+
catch {
|
|
584
|
+
/* unreadable — leave it alone */
|
|
585
|
+
}
|
|
188
586
|
}
|
|
189
587
|
return dir;
|
|
190
588
|
}
|
|
191
589
|
export async function runGateway(opts) {
|
|
192
|
-
const
|
|
590
|
+
const requestedPlatform = opts.platform || "telegram";
|
|
193
591
|
const cwd = opts.cwd ?? defaultWorkspace(); // dir-free default: hara's own ~/.hara/workspace, like Hermes' ~/.hermes
|
|
194
|
-
const built = await buildAdapter(
|
|
592
|
+
const built = await buildAdapter(requestedPlatform);
|
|
195
593
|
if (!built)
|
|
196
594
|
process.exit(1);
|
|
197
595
|
const { adapter, ownerId } = built;
|
|
198
|
-
|
|
596
|
+
// Adapter names are the source of truth (e.g. requested `lark` builds the `feishu` adapter). Keep the
|
|
597
|
+
// requested spelling only for startup/config hints; all persisted/routable identities are canonical.
|
|
598
|
+
const platform = adapter.name || canonicalGatewayPlatform(requestedPlatform);
|
|
599
|
+
const explicitOwner = process.env.HARA_GATEWAY_OWNER?.trim();
|
|
600
|
+
const allowlist = resolveAllowlist(process.env.HARA_GATEWAY_ALLOWED, ownerId, explicitOwner);
|
|
601
|
+
const approvalUserId = resolveApprovalOwner(explicitOwner, ownerId, allowlist);
|
|
602
|
+
const approvalOwner = approvalUserId ? `${platform}:${approvalUserId}` : undefined;
|
|
199
603
|
if (allowlist.size === 0) {
|
|
200
|
-
const hint = platform === "weixin"
|
|
604
|
+
const hint = platform === "weixin"
|
|
605
|
+
? "your WeChat id"
|
|
606
|
+
: platform === "telegram"
|
|
607
|
+
? "your Telegram user id (DM @userinfobot)"
|
|
608
|
+
: `your ${platform} user id`;
|
|
201
609
|
console.error(`hara gateway: ⚠ HARA_GATEWAY_ALLOWED is empty — nobody is allowed. Set it to ${hint}.`);
|
|
202
610
|
}
|
|
203
611
|
else if (ownerId) {
|
|
204
612
|
console.error(`hara gateway: bot owner auto-allowed (${ownerId}).`);
|
|
205
613
|
}
|
|
614
|
+
if (!approvalOwner) {
|
|
615
|
+
console.error("hara gateway: flow approvals disabled — set HARA_GATEWAY_OWNER to one allowed sender id (required when multiple users are allowed).");
|
|
616
|
+
}
|
|
617
|
+
else {
|
|
618
|
+
console.error(`hara gateway: flow approvals restricted to ${approvalOwner}.`);
|
|
619
|
+
}
|
|
206
620
|
const ac = new AbortController();
|
|
207
|
-
|
|
208
|
-
|
|
621
|
+
const sessionRuns = new KeyedSerialQueue(8, 4, 64, 32);
|
|
622
|
+
const stop = () => ac.abort(new GatewayQueueClosedError());
|
|
623
|
+
const closeQueue = () => sessionRuns.close(new GatewayQueueClosedError());
|
|
624
|
+
process.once("SIGINT", stop);
|
|
625
|
+
process.once("SIGTERM", stop);
|
|
626
|
+
ac.signal.addEventListener("abort", closeQueue, { once: true });
|
|
209
627
|
console.error(`hara gateway: ${adapter.name} up · cwd=${cwd} · ${allowlist.size} allowed user(s) · Ctrl-C to stop`);
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
console.error(`hara gateway:
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
//
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
628
|
+
try {
|
|
629
|
+
await pruneStaleMedia(platform).catch((error) => {
|
|
630
|
+
console.error(`hara gateway: media cleanup failed — ${error instanceof Error ? error.message : String(error)}`);
|
|
631
|
+
});
|
|
632
|
+
await adapter.start(async (m) => {
|
|
633
|
+
try {
|
|
634
|
+
if (ac.signal.aborted)
|
|
635
|
+
return;
|
|
636
|
+
// Flows (opt-in, ~/.hara/flows.json): rules that intercept a matching inbound message → agent task + deliver,
|
|
637
|
+
// BEFORE the allowlist/DM-driver logic. A matched flow is authorized by its own presence in the user's config
|
|
638
|
+
// (group senders won't be in the allowlist), so this must run first; the agent run/deliver is fire-and-forget.
|
|
639
|
+
const flowRan = await dispatchFlows(m, platform, (prompt, home, schema, signal) => runFlowAgent(prompt, home ?? cwd, schema, signal), // stateless per trigger; rule cwd = agent's home
|
|
640
|
+
(text) => adapter.send(m.chatId, plainChat(text)), // flow replies are chat bubbles too — flatten markdown
|
|
641
|
+
approvalOwner, ac.signal);
|
|
642
|
+
if (flowRan)
|
|
643
|
+
return;
|
|
644
|
+
// The gateway's default is a DM driver. Unknown channel shapes fail closed too: older/third-party adapters
|
|
645
|
+
// may omit chatType, and treating every unknown room as a DM would expose the full coding agent in groups.
|
|
646
|
+
// The chatId===userId invariant preserves legacy Telegram/Weixin-style DMs until their adapter is upgraded.
|
|
647
|
+
if (!isPrivateApprovalMessage(m))
|
|
648
|
+
return;
|
|
649
|
+
if (!isAllowed(m.userId, allowlist)) {
|
|
650
|
+
console.error(`hara gateway: ✗ message from ${m.userId} — not in allowlist. Add it to HARA_GATEWAY_ALLOWED to authorize.`);
|
|
651
|
+
await adapter.send(m.chatId, "⛔ not authorized.");
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
// Owner approving a pending flow action ("采用" / "改:…" / "取消")? Execute it (e.g. post the drafted reply
|
|
655
|
+
// back to the origin group) instead of routing this as a fresh command. This is the flow approve→execute loop.
|
|
656
|
+
if (approvalUserId && String(m.userId) === approvalUserId && isPrivateApprovalMessage(m)) {
|
|
657
|
+
const pendingReply = await handleOwnerReply(approvalOwner, m.text, { signal: ac.signal });
|
|
658
|
+
if (pendingReply) {
|
|
659
|
+
await adapter.send(m.chatId, pendingReply);
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
// If a tmux session opted in (via `hara remote ask/bind`), this reply is its input → inject it into that
|
|
664
|
+
// pane, let it react, and reply with the session's NEW output (on-inbound relay — quiet + iLink-friendly:
|
|
665
|
+
// one reply per message, no continuous push). Owner-gated by the allowlist check above.
|
|
666
|
+
if (!parseCommand(m.text)) {
|
|
667
|
+
const pane = pickPaneForReply();
|
|
668
|
+
if (pane) {
|
|
669
|
+
console.error(`hara gateway: routed reply → tmux pane ${pane}`);
|
|
670
|
+
const before = capturePane(pane) ?? "";
|
|
671
|
+
injectTmux(pane, m.text);
|
|
672
|
+
// wait for the session's output to SETTLE (poll every 800ms; stable for ~1.6s → done; cap ~10s) so a
|
|
673
|
+
// slow response isn't missed and we don't capture mid-stream.
|
|
674
|
+
let after = "";
|
|
675
|
+
let stable = 0;
|
|
676
|
+
for (let i = 0; i < 12; i++) {
|
|
677
|
+
await new Promise((r) => setTimeout(r, 800));
|
|
678
|
+
const cur = capturePane(pane) ?? "";
|
|
679
|
+
if (cur === after) {
|
|
680
|
+
if (++stable >= 2)
|
|
681
|
+
break;
|
|
682
|
+
}
|
|
683
|
+
else {
|
|
684
|
+
stable = 0;
|
|
685
|
+
after = cur;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
const delta = outputDelta(before, after).trim();
|
|
689
|
+
const body = delta ? (delta.length > 1500 ? "…\n" + delta.slice(-1500) : delta) : "(已注入,暂无新输出 — 发 ? 再看)";
|
|
690
|
+
await adapter.send(m.chatId, `🖥 ${pane}\n${body}`);
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
// Thread identity is (platform, chat) for DMs and (platform, chat, USER) for groups — auto-derived, so
|
|
695
|
+
// group members each get their own session thread instead of interleaving into one polluted context.
|
|
696
|
+
const who = { userId: m.userId, chatType: m.chatType };
|
|
697
|
+
const ctx = chatContext(adapter.name, m.chatId, cwd, who); // this chat's current { cwd, sessionId, agent }
|
|
698
|
+
if (ctx.rotatedFrom) {
|
|
699
|
+
// Idle auto-rotation just happened (session hygiene): tell the user ONCE, with the escape hatch.
|
|
700
|
+
await adapter.send(m.chatId, `🧵 fresh thread (chat was idle) — /resume ${ctx.rotatedFrom.slice(-18)} continues the previous one`);
|
|
701
|
+
}
|
|
702
|
+
const cmd = parseCommand(m.text);
|
|
703
|
+
if (cmd) {
|
|
704
|
+
if (cmd.cmd === "help")
|
|
705
|
+
return adapter.send(m.chatId, "commands:\n/pwd · /cd <dir> — project\n/sessions · /new · /resume <id> — threads\n/agent <name|project:name|main> — who answers this thread (default: main)\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");
|
|
706
|
+
if (cmd.cmd === "agent") {
|
|
707
|
+
// Per-thread agent switch, resolved via the GLOBAL index: an agent with a home also /cd's the thread
|
|
708
|
+
// there (its data + AGENTS.md context — correctness over chat continuity; /agent main switches back,
|
|
709
|
+
// and the previous thread is preserved per (chat, cwd), so nothing is lost).
|
|
710
|
+
if (!cmd.arg)
|
|
711
|
+
return adapter.send(m.chatId, `🤖 current agent: ${ctx.agent ?? "main"}\nusage: /agent <name|project:name> · /agent main`);
|
|
712
|
+
if (cmd.arg === "main" || cmd.arg === "off") {
|
|
713
|
+
setChatAgent(adapter.name, m.chatId, undefined, who);
|
|
714
|
+
const restored = chatContext(adapter.name, m.chatId, cwd, who);
|
|
715
|
+
return adapter.send(m.chatId, `🤖 back to the main agent.\n📂 ${restored.cwd}`);
|
|
716
|
+
}
|
|
717
|
+
const { resolveAgent } = await import("../org/projects.js");
|
|
718
|
+
// A bare name means the override in the thread's current project when one exists; explicit
|
|
719
|
+
// `global:name` and `project:name` remain deterministic. This avoids making a local reviewer
|
|
720
|
+
// ambiguous merely because several other registered projects also define one.
|
|
721
|
+
const hit = resolveAgent(cmd.arg, ctx.cwd);
|
|
722
|
+
if (!hit)
|
|
723
|
+
return adapter.send(m.chatId, `✗ no agent '${cmd.arg}' — see \`hara agents\` on the host.`);
|
|
724
|
+
if ("ambiguous" in hit)
|
|
725
|
+
return adapter.send(m.chatId, `'${cmd.arg}' exists in several projects — pick one:\n${hit.ambiguous.map((e) => `${e.project}:${e.name}`).join("\n")}`);
|
|
726
|
+
const agentRef = hit.project ? `${hit.project}:${hit.name}` : `global:${hit.name}`;
|
|
727
|
+
setChatAgent(adapter.name, m.chatId, agentRef, who, hit.home || undefined);
|
|
728
|
+
return adapter.send(m.chatId, `🤖 this thread now talks to ${agentRef}${hit.home ? `\n📂 ${hit.home}` : ""}\n/agent main switches back`);
|
|
729
|
+
}
|
|
730
|
+
if (cmd.cmd === "detach") {
|
|
731
|
+
const { unbindBinds } = await import("./tmux-routes.js");
|
|
732
|
+
const n = unbindBinds();
|
|
733
|
+
return adapter.send(m.chatId, n ? `🔓 detached ${n} bound tmux pane(s) — replies go to hara again.` : "(no tmux panes were bound)");
|
|
734
|
+
}
|
|
735
|
+
if (cmd.cmd === "pwd")
|
|
736
|
+
return adapter.send(m.chatId, `📂 ${ctx.cwd}\n🧵 ${ctx.sessionId.slice(-18)}`);
|
|
737
|
+
if (cmd.cmd === "cd" || cmd.cmd === "project") {
|
|
738
|
+
if (ctx.agent && !ctx.agent.startsWith("global:")) {
|
|
739
|
+
return adapter.send(m.chatId, `🤖 ${ctx.agent} is pinned to its home. Use /agent main before changing project.`);
|
|
740
|
+
}
|
|
741
|
+
if (!cmd.arg)
|
|
742
|
+
return adapter.send(m.chatId, `📂 ${ctx.cwd}\nusage: /cd <dir> (absolute, ~, or relative to here)`);
|
|
743
|
+
const target = resolve(ctx.cwd, cmd.arg.replace(/^~(?=\/|$)/, homedir()));
|
|
744
|
+
if (!existsSync(target) || !statSync(target).isDirectory())
|
|
745
|
+
return adapter.send(m.chatId, `✗ not a directory: ${target}`);
|
|
746
|
+
const sid = chatCd(adapter.name, m.chatId, target, who);
|
|
747
|
+
return adapter.send(m.chatId, `📂 now in ${target}\n🧵 ${sid.slice(-18)} · /sessions lists this dir's threads`);
|
|
748
|
+
}
|
|
749
|
+
if (cmd.cmd === "new")
|
|
750
|
+
return adapter.send(m.chatId, `✨ new thread: ${newChatSession(adapter.name, m.chatId, cwd, who).slice(-18)}`);
|
|
751
|
+
if (cmd.cmd === "sessions") {
|
|
752
|
+
const list = listSessions(ctx.cwd).filter((session) => ownsChatSession(adapter.name, m.chatId, session.id, who)).slice(0, 10).map((x) => `${x.id.slice(-18)} ${x.title || "(untitled)"}`).join("\n");
|
|
753
|
+
return adapter.send(m.chatId, `📂 ${ctx.cwd}\n${list || "(no threads in this dir yet)"}`);
|
|
754
|
+
}
|
|
755
|
+
if (cmd.cmd === "resume") {
|
|
756
|
+
const match = resolveOwnedSessionId(adapter.name, m.chatId, cmd.arg, listSessions().map((session) => session.id), who);
|
|
757
|
+
if (!match)
|
|
758
|
+
return adapter.send(m.chatId, `no session '${cmd.arg}' in this chat thread`);
|
|
759
|
+
if ("ambiguous" in match)
|
|
760
|
+
return adapter.send(m.chatId, `ambiguous session '${cmd.arg}' — use more characters`);
|
|
761
|
+
const id = match.id;
|
|
762
|
+
const target = loadSession(id)?.meta.cwd || ctx.cwd; // follow the session's own dir so it runs in the right place
|
|
763
|
+
setChatSession(adapter.name, m.chatId, id, target, who);
|
|
764
|
+
return adapter.send(m.chatId, `↩ resumed ${id.slice(-18)}\n📂 ${target}`);
|
|
765
|
+
}
|
|
766
|
+
if (cmd.cmd === "voice") {
|
|
767
|
+
if (!adapter.sendFile)
|
|
768
|
+
return adapter.send(m.chatId, "this platform can't send voice yet.");
|
|
769
|
+
const on = toggleVoice(adapter.name, m.chatId, who);
|
|
770
|
+
return adapter.send(m.chatId, on ? "🔊 voice replies ON — I'll speak each reply too." : "🔇 voice replies OFF.");
|
|
771
|
+
}
|
|
772
|
+
if (cmd.cmd === "say") {
|
|
773
|
+
if (!adapter.sendFile)
|
|
774
|
+
return adapter.send(m.chatId, "this platform can't send voice yet.");
|
|
775
|
+
if (!cmd.arg)
|
|
776
|
+
return adapter.send(m.chatId, "usage: /say <text to speak>");
|
|
777
|
+
const audio = await synthesize(cmd.arg);
|
|
778
|
+
if (!audio)
|
|
779
|
+
return adapter.send(m.chatId, "✗ TTS failed (check HARA_TTS_* config).");
|
|
780
|
+
try {
|
|
781
|
+
await deliverVerifiedLocalFile(adapter, m.chatId, audio);
|
|
782
|
+
}
|
|
783
|
+
finally {
|
|
784
|
+
rmSync(audio, { force: true });
|
|
785
|
+
}
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
if (cmd.cmd === "send") {
|
|
789
|
+
if (!adapter.sendFile)
|
|
790
|
+
return adapter.send(m.chatId, "this platform can't send files yet.");
|
|
791
|
+
const p = cmd.arg ? resolve(ctx.cwd, cmd.arg.replace(/^~(?=\/|$)/, homedir())) : "";
|
|
792
|
+
if (!p)
|
|
793
|
+
return adapter.send(m.chatId, "usage: /send <path> (abs, ~, or relative to current dir)");
|
|
794
|
+
try {
|
|
795
|
+
await deliverVerifiedLocalFile(adapter, m.chatId, p);
|
|
796
|
+
}
|
|
797
|
+
catch (error) {
|
|
798
|
+
return adapter.send(m.chatId, `✗ couldn't send ${p}: ${error instanceof Error ? error.message : String(error)}`);
|
|
799
|
+
}
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
// any other slash word → treat as a normal task
|
|
803
|
+
}
|
|
804
|
+
// Transient progress marker — capability-driven: sent ONLY where it can be recalled afterwards (Feishu),
|
|
805
|
+
// so it never leaves residue. Platforms without recall (WeChat iLink: send response is `{}`, no message
|
|
806
|
+
// id exists to revoke) get no marker at all — a clean thread beats a permanent "working…" bubble.
|
|
807
|
+
let workingId;
|
|
808
|
+
if (adapter.sendTracked && adapter.recall)
|
|
809
|
+
workingId = await adapter.sendTracked(m.chatId, "⟳ working…").catch(() => undefined);
|
|
810
|
+
let result;
|
|
811
|
+
try {
|
|
812
|
+
result = await sessionRuns.run(ctx.sessionId, () => runHara(m.text, ctx.sessionId, ctx.cwd, adapter.name, m.images, ctx.agent, { signal: ac.signal }));
|
|
813
|
+
}
|
|
814
|
+
catch (e) {
|
|
815
|
+
if (e instanceof GatewayQueueFullError) {
|
|
816
|
+
const message = e.scope === "session"
|
|
817
|
+
? "⏳ this thread is busy — wait for an earlier message to finish, then retry."
|
|
818
|
+
: "⏳ the gateway is at capacity — try again shortly.";
|
|
819
|
+
await adapter.send(m.chatId, message);
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
if (e instanceof GatewayQueueClosedError || ac.signal.aborted) {
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
throw e;
|
|
826
|
+
}
|
|
827
|
+
finally {
|
|
828
|
+
if (workingId && adapter.recall)
|
|
829
|
+
await adapter.recall(m.chatId, workingId).catch(() => { });
|
|
830
|
+
}
|
|
831
|
+
const { reply, files } = result;
|
|
832
|
+
try {
|
|
833
|
+
if (ac.signal.aborted)
|
|
834
|
+
return;
|
|
835
|
+
const hasReply = Boolean(reply);
|
|
836
|
+
if (hasReply)
|
|
837
|
+
await adapter.send(m.chatId, plainChat(reply)); // chat bubbles are plain text — flatten markdown
|
|
838
|
+
else if (files.length)
|
|
839
|
+
await adapter.send(m.chatId, "📎");
|
|
840
|
+
// Deliver only immutable private snapshots produced by send_file.
|
|
841
|
+
for (const f of files) {
|
|
842
|
+
if (!adapter.sendFile) {
|
|
843
|
+
await adapter.send(m.chatId, "(this platform can't send files yet)");
|
|
234
844
|
break;
|
|
845
|
+
}
|
|
846
|
+
try {
|
|
847
|
+
await adapter.sendFile(m.chatId, f);
|
|
848
|
+
}
|
|
849
|
+
catch (e) {
|
|
850
|
+
await adapter.send(m.chatId, `✗ couldn't send attachment: ${e.message}`);
|
|
851
|
+
}
|
|
235
852
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
853
|
+
if (hasReply && ctx.voice && adapter.sendFile) {
|
|
854
|
+
const audio = await synthesize(reply);
|
|
855
|
+
if (audio) {
|
|
856
|
+
try {
|
|
857
|
+
await deliverVerifiedLocalFile(adapter, m.chatId, audio);
|
|
858
|
+
}
|
|
859
|
+
finally {
|
|
860
|
+
rmSync(audio, { force: true });
|
|
861
|
+
}
|
|
862
|
+
}
|
|
239
863
|
}
|
|
240
864
|
}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
}
|
|
247
|
-
const ctx = chatContext(adapter.name, m.chatId, cwd); // this chat's current { cwd, sessionId }
|
|
248
|
-
if (ctx.rotatedFrom) {
|
|
249
|
-
// Idle auto-rotation just happened (session hygiene): tell the user ONCE, with the escape hatch.
|
|
250
|
-
await adapter.send(m.chatId, `🧵 fresh thread (chat was idle) — /resume ${ctx.rotatedFrom.slice(-18)} continues the previous one`);
|
|
251
|
-
}
|
|
252
|
-
const cmd = parseCommand(m.text);
|
|
253
|
-
if (cmd) {
|
|
254
|
-
if (cmd.cmd === "help")
|
|
255
|
-
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");
|
|
256
|
-
if (cmd.cmd === "detach") {
|
|
257
|
-
const { unbindBinds } = await import("./tmux-routes.js");
|
|
258
|
-
const n = unbindBinds();
|
|
259
|
-
return adapter.send(m.chatId, n ? `🔓 detached ${n} bound tmux pane(s) — replies go to hara again.` : "(no tmux panes were bound)");
|
|
260
|
-
}
|
|
261
|
-
if (cmd.cmd === "pwd")
|
|
262
|
-
return adapter.send(m.chatId, `📂 ${ctx.cwd}\n🧵 ${ctx.sessionId.slice(-18)}`);
|
|
263
|
-
if (cmd.cmd === "cd" || cmd.cmd === "project") {
|
|
264
|
-
if (!cmd.arg)
|
|
265
|
-
return adapter.send(m.chatId, `📂 ${ctx.cwd}\nusage: /cd <dir> (absolute, ~, or relative to here)`);
|
|
266
|
-
const target = resolve(ctx.cwd, cmd.arg.replace(/^~(?=\/|$)/, homedir()));
|
|
267
|
-
if (!existsSync(target) || !statSync(target).isDirectory())
|
|
268
|
-
return adapter.send(m.chatId, `✗ not a directory: ${target}`);
|
|
269
|
-
const sid = chatCd(adapter.name, m.chatId, target);
|
|
270
|
-
return adapter.send(m.chatId, `📂 now in ${target}\n🧵 ${sid.slice(-18)} · /sessions lists this dir's threads`);
|
|
271
|
-
}
|
|
272
|
-
if (cmd.cmd === "new")
|
|
273
|
-
return adapter.send(m.chatId, `✨ new thread: ${newChatSession(adapter.name, m.chatId, cwd).slice(-18)}`);
|
|
274
|
-
if (cmd.cmd === "sessions") {
|
|
275
|
-
const list = listSessions(ctx.cwd).slice(0, 10).map((x) => `${x.id.slice(-18)} ${x.title || "(untitled)"}`).join("\n");
|
|
276
|
-
return adapter.send(m.chatId, `📂 ${ctx.cwd}\n${list || "(no threads in this dir yet)"}`);
|
|
277
|
-
}
|
|
278
|
-
if (cmd.cmd === "resume") {
|
|
279
|
-
const id = resolveSessionId(cmd.arg);
|
|
280
|
-
if (!id)
|
|
281
|
-
return adapter.send(m.chatId, `no session '${cmd.arg}'`);
|
|
282
|
-
const target = loadSession(id)?.meta.cwd || ctx.cwd; // follow the session's own dir so it runs in the right place
|
|
283
|
-
setChatSession(adapter.name, m.chatId, id, target);
|
|
284
|
-
return adapter.send(m.chatId, `↩ resumed ${id.slice(-18)}\n📂 ${target}`);
|
|
285
|
-
}
|
|
286
|
-
if (cmd.cmd === "voice") {
|
|
287
|
-
if (!adapter.sendFile)
|
|
288
|
-
return adapter.send(m.chatId, "this platform can't send voice yet.");
|
|
289
|
-
const on = toggleVoice(adapter.name, m.chatId);
|
|
290
|
-
return adapter.send(m.chatId, on ? "🔊 voice replies ON — I'll speak each reply too." : "🔇 voice replies OFF.");
|
|
291
|
-
}
|
|
292
|
-
if (cmd.cmd === "say") {
|
|
293
|
-
if (!adapter.sendFile)
|
|
294
|
-
return adapter.send(m.chatId, "this platform can't send voice yet.");
|
|
295
|
-
if (!cmd.arg)
|
|
296
|
-
return adapter.send(m.chatId, "usage: /say <text to speak>");
|
|
297
|
-
const audio = await synthesize(cmd.arg);
|
|
298
|
-
if (!audio)
|
|
299
|
-
return adapter.send(m.chatId, "✗ TTS failed (check HARA_TTS_* config).");
|
|
300
|
-
await adapter.sendFile(m.chatId, audio);
|
|
301
|
-
rmSync(audio, { force: true });
|
|
302
|
-
return;
|
|
303
|
-
}
|
|
304
|
-
if (cmd.cmd === "send") {
|
|
305
|
-
if (!adapter.sendFile)
|
|
306
|
-
return adapter.send(m.chatId, "this platform can't send files yet.");
|
|
307
|
-
const p = cmd.arg ? resolve(ctx.cwd, cmd.arg.replace(/^~(?=\/|$)/, homedir())) : "";
|
|
308
|
-
if (!p || !existsSync(p) || !statSync(p).isFile())
|
|
309
|
-
return adapter.send(m.chatId, `✗ not a file: ${p || "(none)"}\nusage: /send <path> (abs, ~, or relative to current dir)`);
|
|
310
|
-
await adapter.sendFile(m.chatId, p);
|
|
311
|
-
return;
|
|
312
|
-
}
|
|
313
|
-
// any other slash word → treat as a normal task
|
|
314
|
-
}
|
|
315
|
-
await adapter.send(m.chatId, "⟳ working…");
|
|
316
|
-
const { reply, files } = await runHara(m.text, ctx.sessionId, ctx.cwd, adapter.name, m.images);
|
|
317
|
-
const hasReply = reply && reply !== "(no output)";
|
|
318
|
-
if (hasReply)
|
|
319
|
-
await adapter.send(m.chatId, reply);
|
|
320
|
-
else if (files.length)
|
|
321
|
-
await adapter.send(m.chatId, "📎");
|
|
322
|
-
// Deliver any files the agent queued via send_file (images inline, others as attachments).
|
|
323
|
-
for (const f of files) {
|
|
324
|
-
if (!adapter.sendFile) {
|
|
325
|
-
await adapter.send(m.chatId, "(this platform can't send files yet)");
|
|
326
|
-
break;
|
|
327
|
-
}
|
|
328
|
-
try {
|
|
329
|
-
await adapter.sendFile(m.chatId, f);
|
|
330
|
-
}
|
|
331
|
-
catch (e) {
|
|
332
|
-
await adapter.send(m.chatId, `✗ couldn't send ${f}: ${e.message}`);
|
|
865
|
+
finally {
|
|
866
|
+
// Text-send failures, shutdown races, unsupported adapters, and upload failures all remove snapshots.
|
|
867
|
+
for (const f of files)
|
|
868
|
+
cleanupOutboundSnapshot(f.snapshotPath);
|
|
869
|
+
}
|
|
333
870
|
}
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
await adapter.sendFile(m.chatId, audio);
|
|
339
|
-
rmSync(audio, { force: true });
|
|
871
|
+
finally {
|
|
872
|
+
await cleanupTransientMedia(platform, m.transientFiles).catch((error) => {
|
|
873
|
+
console.error(`hara gateway: inbound media cleanup failed — ${error instanceof Error ? error.message : String(error)}`);
|
|
874
|
+
});
|
|
340
875
|
}
|
|
341
|
-
}
|
|
342
|
-
}
|
|
876
|
+
}, ac.signal, (m) => shouldDownloadInboundMedia(m, allowlist));
|
|
877
|
+
}
|
|
878
|
+
finally {
|
|
879
|
+
stop();
|
|
880
|
+
closeQueue();
|
|
881
|
+
await sessionRuns.waitForIdle();
|
|
882
|
+
ac.signal.removeEventListener("abort", closeQueue);
|
|
883
|
+
process.off("SIGINT", stop);
|
|
884
|
+
process.off("SIGTERM", stop);
|
|
885
|
+
}
|
|
343
886
|
}
|