@nanhara/hara 0.121.1 → 0.122.1

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