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