@nanhara/hara 0.121.0 → 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.
Files changed (49) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +40 -6
  3. package/dist/agent/failover.js +1 -1
  4. package/dist/agent/loop.js +158 -21
  5. package/dist/agent/reminders.js +22 -7
  6. package/dist/agent/repeat-guard.js +26 -7
  7. package/dist/agent/structured.js +231 -0
  8. package/dist/agent/touched.js +20 -6
  9. package/dist/config.js +62 -26
  10. package/dist/cron/deliver.js +37 -3
  11. package/dist/feedback.js +5 -9
  12. package/dist/fs-read.js +106 -12
  13. package/dist/fs-write.js +242 -16
  14. package/dist/gateway/dingtalk.js +4 -1
  15. package/dist/gateway/discord.js +53 -18
  16. package/dist/gateway/feishu.js +158 -57
  17. package/dist/gateway/flows-pending.js +720 -0
  18. package/dist/gateway/flows.js +391 -16
  19. package/dist/gateway/matrix.js +80 -15
  20. package/dist/gateway/mattermost.js +44 -32
  21. package/dist/gateway/media.js +659 -0
  22. package/dist/gateway/serve.js +657 -162
  23. package/dist/gateway/sessions.js +475 -78
  24. package/dist/gateway/signal.js +27 -22
  25. package/dist/gateway/slack.js +26 -17
  26. package/dist/gateway/telegram.js +32 -18
  27. package/dist/gateway/wecom.js +32 -24
  28. package/dist/gateway/weixin.js +127 -49
  29. package/dist/hooks.js +32 -20
  30. package/dist/index.js +640 -219
  31. package/dist/org/projects.js +347 -0
  32. package/dist/org/roles.js +38 -11
  33. package/dist/security/secrets.js +150 -0
  34. package/dist/serve/server.js +772 -317
  35. package/dist/serve/sessions.js +112 -28
  36. package/dist/session/store.js +337 -44
  37. package/dist/tools/all.js +1 -0
  38. package/dist/tools/builtin.js +61 -23
  39. package/dist/tools/codebase.js +3 -1
  40. package/dist/tools/computer.js +98 -92
  41. package/dist/tools/edit.js +11 -8
  42. package/dist/tools/patch.js +230 -31
  43. package/dist/tools/search.js +482 -72
  44. package/dist/tools/task.js +453 -0
  45. package/dist/tools/todo.js +67 -16
  46. package/dist/tools/web.js +364 -64
  47. package/dist/tui/run.js +26 -23
  48. package/dist/undo.js +83 -7
  49. package/package.json +1 -1
@@ -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 { chatContext, chatCd, newChatSession, setChatSession, toggleVoice } from "./sessions.js";
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, resolveSessionId, loadSession } from "../session/store.js";
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 child = spawn(self[0], [...self.slice(1), "-p", text, "--approval", "full-auto", "--resume", sessionId], {
42
- cwd,
43
- env: {
44
- ...process.env,
45
- HARA_GATEWAY: platform,
46
- HARA_GATEWAY_OUTBOX: outbox,
47
- ...(images?.length ? { HARA_GATEWAY_IMAGES: images.join("\n") } : {}),
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(-12000);
294
+ out = (out + d.toString().slice(-12_000)).slice(-12_000);
53
295
  };
54
- child.stdout.on("data", cap);
55
- child.stderr.on("data", cap);
56
- const finish = (reply) => {
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
- res({ reply, files });
314
+ return files;
68
315
  };
69
- child.on("error", (e) => finish(`(error: ${e.message})`));
70
- child.on("close", () => finish(cleanReply(out) || "(no output)"));
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), ownerId: 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), ownerId: 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 ∪ the bot owner (on WeChat, whoever scanned the QR is always allowed). */
174
- export function resolveAllowlist(envValue, ownerId) {
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 dir = join(homedir(), ".hara", "workspace");
184
- mkdirSync(dir, { recursive: true });
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
- if (!existsSync(agents)) {
187
- writeFileSync(agents, "# 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");
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 platform = opts.platform || "telegram";
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(platform);
564
+ const built = await buildAdapter(requestedPlatform);
195
565
  if (!built)
196
566
  process.exit(1);
197
567
  const { adapter, ownerId } = built;
198
- const allowlist = resolveAllowlist(process.env.HARA_GATEWAY_ALLOWED, ownerId);
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" ? "your WeChat id" : "your Telegram user id (DM @userinfobot)";
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
- process.on("SIGINT", () => ac.abort());
208
- process.on("SIGTERM", () => ac.abort());
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
- await adapter.start(async (m) => {
211
- if (!isAllowed(m.userId, allowlist)) {
212
- console.error(`hara gateway: message from ${m.userId} not in allowlist. Add it to HARA_GATEWAY_ALLOWED to authorize.`);
213
- await adapter.send(m.chatId, "⛔ not authorized.");
214
- return;
215
- }
216
- // If a tmux session opted in (via `hara remote ask/bind`), this reply is its input → inject it into that
217
- // pane, let it react, and reply with the session's NEW output (on-inbound relay — quiet + iLink-friendly:
218
- // one reply per message, no continuous push). Owner-gated by the allowlist check above.
219
- if (!parseCommand(m.text)) {
220
- const pane = pickPaneForReply();
221
- if (pane) {
222
- console.error(`hara gateway: routed reply tmux pane ${pane}`);
223
- const before = capturePane(pane) ?? "";
224
- injectTmux(pane, m.text);
225
- // wait for the session's output to SETTLE (poll every 800ms; stable for ~1.6s → done; cap ~10s) so a
226
- // slow response isn't missed and we don't capture mid-stream.
227
- let after = "";
228
- let stable = 0;
229
- for (let i = 0; i < 12; i++) {
230
- await new Promise((r) => setTimeout(r, 800));
231
- const cur = capturePane(pane) ?? "";
232
- if (cur === after) {
233
- if (++stable >= 2)
234
- break;
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
- else {
237
- stable = 0;
238
- after = cur;
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
- if (hasReply && ctx.voice && adapter.sendFile) {
336
- const audio = await synthesize(reply);
337
- if (audio) {
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
- }, ac.signal);
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
  }