@nanhara/hara 0.122.2 → 0.122.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +108 -0
- package/README.md +15 -2
- package/dist/agent/limits.js +51 -0
- package/dist/agent/loop.js +367 -112
- package/dist/agent/repeat-guard.js +49 -12
- package/dist/checkpoints.js +9 -0
- package/dist/cli.js +2 -1
- package/dist/config.js +10 -2
- package/dist/context/agents-md.js +21 -2
- package/dist/context/mentions.js +84 -1
- package/dist/context/workspace-scope.js +49 -0
- package/dist/cron/deliver.js +61 -38
- package/dist/cron/runner.js +423 -65
- package/dist/cron/schedule.js +23 -7
- package/dist/cron/store.js +709 -43
- package/dist/fs-walk.js +279 -7
- package/dist/fs-write.js +9 -0
- package/dist/gateway/feishu.js +351 -64
- package/dist/gateway/flows-pending.js +31 -17
- package/dist/gateway/flows.js +306 -31
- package/dist/gateway/outbound-files.js +20 -6
- package/dist/gateway/runtime-state.js +1485 -0
- package/dist/gateway/serve.js +550 -242
- package/dist/gateway/sessions.js +3 -3
- package/dist/gateway/telegram.js +279 -29
- package/dist/gateway/tts.js +182 -38
- package/dist/hooks.js +22 -22
- package/dist/index.js +466 -158
- package/dist/memory/store.js +8 -5
- package/dist/org/planner.js +11 -6
- package/dist/org/projects.js +3 -3
- package/dist/process-identity.js +52 -0
- package/dist/providers/bounded-turn.js +42 -0
- package/dist/recall.js +69 -1
- package/dist/runtime.js +24 -0
- package/dist/sandbox.js +54 -4
- package/dist/search/embed.js +13 -2
- package/dist/search/hybrid.js +6 -3
- package/dist/search/semindex.js +87 -5
- package/dist/security/guardian.js +3 -15
- package/dist/security/permissions.js +11 -0
- package/dist/serve/server.js +70 -42
- package/dist/serve/sessions.js +4 -3
- package/dist/sync-sleep.js +46 -0
- package/dist/tools/agent.js +1 -1
- package/dist/tools/ask_user.js +5 -1
- package/dist/tools/builtin.js +5 -2
- package/dist/tools/codebase.js +67 -18
- package/dist/tools/computer.js +149 -68
- package/dist/tools/cron.js +72 -18
- package/dist/tools/edit.js +3 -2
- package/dist/tools/external_agent.js +66 -12
- package/dist/tools/memory.js +25 -7
- package/dist/tools/patch.js +11 -2
- package/dist/tools/registry.js +16 -1
- package/dist/tools/search.js +68 -9
- package/dist/tools/send.js +1 -1
- package/dist/tools/skill.js +1 -1
- package/dist/tools/task.js +3 -3
- package/dist/tools/web.js +43 -13
- package/dist/tui/App.js +93 -25
- package/dist/vision.js +5 -6
- package/package.json +2 -2
- package/runtime-bootstrap.cjs +22 -0
package/dist/gateway/serve.js
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
// subprocess (the cron pattern) on that chat's session → the reply is sent back. This is hara's first
|
|
4
4
|
// persistent process; it is never required by the core CLI.
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
6
7
|
import { telegramAdapter } from "./telegram.js";
|
|
7
|
-
import { dispatchFlows } from "./flows.js";
|
|
8
|
+
import { dispatchFlows, flowSourceKey } from "./flows.js";
|
|
8
9
|
import { handleOwnerReply, runNoToolModel } from "./flows-pending.js";
|
|
9
10
|
import { chatContext, chatCd, newChatSession, ownsChatSession, resolveOwnedSessionId, setChatSession, setChatAgent, toggleVoice } from "./sessions.js";
|
|
10
11
|
import { plainChat } from "../cron/deliver.js";
|
|
@@ -18,6 +19,7 @@ import { join, resolve } from "node:path";
|
|
|
18
19
|
import { chmodSync, mkdirSync, writeFileSync, readFileSync, existsSync, statSync, rmSync } from "node:fs";
|
|
19
20
|
import { redactToolSubprocessOutput, terminateSubprocessTree } from "../security/subprocess-env.js";
|
|
20
21
|
import { cleanupOutboundSnapshot, cleanupOutboundSnapshots, consumeOutboundSnapshots, queueOutboundSnapshot, } from "./outbound-files.js";
|
|
22
|
+
import { acquireGatewayInstance, gatewayRuntimeScope, GatewayFlowRunStore, GatewayInboundTracker, GatewayMessageDeduper, GatewayRunOutcomeStore, } from "./runtime-state.js";
|
|
21
23
|
/** Parse a leading slash-command from a chat message (pure). null if it isn't one. */
|
|
22
24
|
export function parseCommand(text) {
|
|
23
25
|
const m = /^\/([a-z]+)\b\s*([\s\S]*)$/i.exec(text.trim());
|
|
@@ -44,6 +46,18 @@ export function canonicalGatewayPlatform(platform) {
|
|
|
44
46
|
return "wecom";
|
|
45
47
|
return value;
|
|
46
48
|
}
|
|
49
|
+
/** Stable FIFO lane for all state and side effects belonging to one chat actor. DMs deliberately use one
|
|
50
|
+
* lane per chat; group users remain isolated even when they share a room. Only the hash is retained in RAM. */
|
|
51
|
+
export function gatewayAdmissionKey(runtimeScope, message) {
|
|
52
|
+
return createHash("sha256")
|
|
53
|
+
.update("hara-gateway-admission-v1\0")
|
|
54
|
+
.update(runtimeScope)
|
|
55
|
+
.update("\0")
|
|
56
|
+
.update(String(message.chatId))
|
|
57
|
+
.update("\0")
|
|
58
|
+
.update(message.chatType === "group" ? String(message.userId) : "dm")
|
|
59
|
+
.digest("hex");
|
|
60
|
+
}
|
|
47
61
|
/** Strip hara's CLI chrome from captured `-p` output so a chat reply is just the answer: MCP status lines
|
|
48
62
|
* (`mcp: …`) and the token-usage footer (`… · ↑N ↓N tok`). Colors are off when piped, so no ANSI to strip. */
|
|
49
63
|
export function cleanReply(raw) {
|
|
@@ -54,26 +68,79 @@ export function cleanReply(raw) {
|
|
|
54
68
|
.trim();
|
|
55
69
|
}
|
|
56
70
|
let outboxSeq = 0;
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
71
|
+
const INTERRUPTED_EFFECT_REPLY = "⚠️ 上一次操作在已经开始后中断,可能已经修改文件、调用工具或改变本地状态。为避免重复副作用,Hara 没有自动重跑。请先检查工作区,再发送一条明确的新指令继续。";
|
|
72
|
+
const TERMINAL_EFFECT_REPLY = "⚠️ 这条消息对应的操作已经执行过,但保存的回复或附件已被安全缓存回收,无法再次投递。为避免重复修改文件或再次调用工具,Hara 没有自动重跑。请先检查工作区,再发送一条明确的新指令继续。";
|
|
73
|
+
function resultFromRunOutcome(outcome) {
|
|
74
|
+
if (outcome.status === "running")
|
|
75
|
+
return { reply: INTERRUPTED_EFFECT_REPLY, files: [] };
|
|
76
|
+
if (outcome.status === "terminal")
|
|
77
|
+
return { reply: TERMINAL_EFFECT_REPLY, files: [] };
|
|
78
|
+
return {
|
|
79
|
+
reply: outcome.reply,
|
|
80
|
+
files: outcome.files.map((file) => ({ ...file, snapshotPath: "" })),
|
|
81
|
+
...(outcome.voice ? { voice: { ...outcome.voice, snapshotPath: "" } } : {}),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
async function finishRunOutcome(store, messageId, candidate) {
|
|
85
|
+
try {
|
|
86
|
+
await store.finish(messageId, {
|
|
87
|
+
reply: candidate.reply,
|
|
88
|
+
files: candidate.files.map(({ safeName, bytes }) => ({ safeName, bytes })),
|
|
89
|
+
...(candidate.voice
|
|
90
|
+
? { voice: { safeName: candidate.voice.safeName, bytes: candidate.voice.bytes } }
|
|
91
|
+
: {}),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
// Never deliver a side effect's success unless its immutable outcome is durable. The started tombstone
|
|
96
|
+
// remains fail-closed; temporary snapshots are no longer needed and must not leak.
|
|
97
|
+
for (const file of candidate.files)
|
|
98
|
+
if (file.snapshotPath)
|
|
99
|
+
cleanupOutboundSnapshot(file.snapshotPath);
|
|
100
|
+
if (candidate.voice?.snapshotPath)
|
|
101
|
+
cleanupOutboundSnapshot(candidate.voice.snapshotPath);
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/** Execute any irreversible gateway action at most once for a stable platform message id. A started marker is
|
|
106
|
+
* durable before `effect`; completion is durable before transport. A retry therefore returns only the cached
|
|
107
|
+
* result (or an interrupted warning) and never invokes `effect` again. */
|
|
108
|
+
export async function executeDurableGatewayEffect(store, messageId, effect, signal) {
|
|
109
|
+
if (signal?.aborted)
|
|
110
|
+
throw signal.reason instanceof Error ? signal.reason : new Error("gateway effect cancelled");
|
|
111
|
+
const prior = await store.start(messageId);
|
|
112
|
+
if (signal?.aborted)
|
|
113
|
+
throw signal.reason instanceof Error ? signal.reason : new Error("gateway effect cancelled");
|
|
114
|
+
if (prior) {
|
|
115
|
+
const recovered = resultFromRunOutcome(prior);
|
|
116
|
+
if (prior.status !== "complete")
|
|
117
|
+
await finishRunOutcome(store, messageId, recovered);
|
|
118
|
+
return recovered;
|
|
119
|
+
}
|
|
120
|
+
const executed = await effect();
|
|
121
|
+
if (signal?.aborted) {
|
|
122
|
+
for (const file of executed.files)
|
|
123
|
+
if (file.snapshotPath)
|
|
124
|
+
cleanupOutboundSnapshot(file.snapshotPath);
|
|
125
|
+
if (executed.voice?.snapshotPath)
|
|
126
|
+
cleanupOutboundSnapshot(executed.voice.snapshotPath);
|
|
127
|
+
throw signal.reason instanceof Error ? signal.reason : new Error("gateway effect cancelled");
|
|
128
|
+
}
|
|
129
|
+
await finishRunOutcome(store, messageId, executed);
|
|
130
|
+
return executed;
|
|
131
|
+
}
|
|
132
|
+
async function snapshotVerifiedLocalFile(sourcePath) {
|
|
63
133
|
const outbox = join(tmpdir(), `hara-direct-send-${process.pid}-${Date.now()}-${outboxSeq++}.txt`);
|
|
64
134
|
let payload;
|
|
65
135
|
try {
|
|
66
136
|
await queueOutboundSnapshot(sourcePath, outbox);
|
|
67
137
|
[payload] = await consumeOutboundSnapshots(outbox);
|
|
68
|
-
cleanupOutboundSnapshots(outbox, payload ? [payload.snapshotPath] : []);
|
|
69
138
|
if (!payload)
|
|
70
139
|
throw new Error("the private file snapshot could not be verified");
|
|
71
|
-
|
|
140
|
+
return payload;
|
|
72
141
|
}
|
|
73
142
|
finally {
|
|
74
|
-
|
|
75
|
-
cleanupOutboundSnapshot(payload.snapshotPath);
|
|
76
|
-
cleanupOutboundSnapshots(outbox);
|
|
143
|
+
cleanupOutboundSnapshots(outbox, payload ? [payload.snapshotPath] : []);
|
|
77
144
|
}
|
|
78
145
|
}
|
|
79
146
|
export class GatewayQueueFullError extends Error {
|
|
@@ -427,7 +494,11 @@ async function buildAdapter(platform) {
|
|
|
427
494
|
return null;
|
|
428
495
|
}
|
|
429
496
|
// The iLink user_id is whoever scanned the QR — the bot owner. Auto-allow them so there's no wxid dance.
|
|
430
|
-
return {
|
|
497
|
+
return {
|
|
498
|
+
adapter: weixinAdapter(creds),
|
|
499
|
+
ownerId: creds.user_id || undefined,
|
|
500
|
+
runtimeScope: gatewayRuntimeScope("weixin", creds.user_id),
|
|
501
|
+
};
|
|
431
502
|
}
|
|
432
503
|
if (platform === "discord") {
|
|
433
504
|
const token = process.env.HARA_DISCORD_TOKEN;
|
|
@@ -436,7 +507,7 @@ async function buildAdapter(platform) {
|
|
|
436
507
|
return null;
|
|
437
508
|
}
|
|
438
509
|
const { discordAdapter } = await import("./discord.js");
|
|
439
|
-
return { adapter: discordAdapter(token) };
|
|
510
|
+
return { adapter: discordAdapter(token), runtimeScope: gatewayRuntimeScope("discord", token) };
|
|
440
511
|
}
|
|
441
512
|
if (platform === "feishu" || platform === "lark") {
|
|
442
513
|
const appId = process.env.HARA_FEISHU_APP_ID;
|
|
@@ -446,7 +517,7 @@ async function buildAdapter(platform) {
|
|
|
446
517
|
return null;
|
|
447
518
|
}
|
|
448
519
|
const { feishuAdapter } = await import("./feishu.js");
|
|
449
|
-
return { adapter: feishuAdapter(appId, appSecret) };
|
|
520
|
+
return { adapter: feishuAdapter(appId, appSecret), runtimeScope: gatewayRuntimeScope("feishu", appId) };
|
|
450
521
|
}
|
|
451
522
|
if (platform === "slack") {
|
|
452
523
|
const appToken = process.env.HARA_SLACK_APP_TOKEN;
|
|
@@ -456,7 +527,7 @@ async function buildAdapter(platform) {
|
|
|
456
527
|
return null;
|
|
457
528
|
}
|
|
458
529
|
const { slackAdapter } = await import("./slack.js");
|
|
459
|
-
return { adapter: slackAdapter(appToken, botToken) };
|
|
530
|
+
return { adapter: slackAdapter(appToken, botToken), runtimeScope: gatewayRuntimeScope("slack", appToken) };
|
|
460
531
|
}
|
|
461
532
|
if (platform === "mattermost") {
|
|
462
533
|
const url = process.env.HARA_MATTERMOST_URL;
|
|
@@ -466,7 +537,7 @@ async function buildAdapter(platform) {
|
|
|
466
537
|
return null;
|
|
467
538
|
}
|
|
468
539
|
const { mattermostAdapter } = await import("./mattermost.js");
|
|
469
|
-
return { adapter: mattermostAdapter(url, token) };
|
|
540
|
+
return { adapter: mattermostAdapter(url, token), runtimeScope: gatewayRuntimeScope("mattermost", `${url}\0${token}`) };
|
|
470
541
|
}
|
|
471
542
|
if (platform === "matrix") {
|
|
472
543
|
const homeserver = process.env.HARA_MATRIX_HOMESERVER;
|
|
@@ -477,7 +548,7 @@ async function buildAdapter(platform) {
|
|
|
477
548
|
return null;
|
|
478
549
|
}
|
|
479
550
|
const { matrixAdapter } = await import("./matrix.js");
|
|
480
|
-
return { adapter: matrixAdapter(homeserver, token, userId) };
|
|
551
|
+
return { adapter: matrixAdapter(homeserver, token, userId), runtimeScope: gatewayRuntimeScope("matrix", `${homeserver}\0${userId}`) };
|
|
481
552
|
}
|
|
482
553
|
if (platform === "dingtalk" || platform === "ding") {
|
|
483
554
|
const clientId = process.env.HARA_DINGTALK_CLIENT_ID;
|
|
@@ -487,7 +558,7 @@ async function buildAdapter(platform) {
|
|
|
487
558
|
return null;
|
|
488
559
|
}
|
|
489
560
|
const { dingtalkAdapter } = await import("./dingtalk.js");
|
|
490
|
-
return { adapter: dingtalkAdapter(clientId, clientSecret) };
|
|
561
|
+
return { adapter: dingtalkAdapter(clientId, clientSecret), runtimeScope: gatewayRuntimeScope("dingtalk", clientId) };
|
|
491
562
|
}
|
|
492
563
|
if (platform === "wecom" || platform === "wework") {
|
|
493
564
|
const botId = process.env.HARA_WECOM_BOT_ID;
|
|
@@ -497,7 +568,10 @@ async function buildAdapter(platform) {
|
|
|
497
568
|
return null;
|
|
498
569
|
}
|
|
499
570
|
const { wecomAdapter } = await import("./wecom.js");
|
|
500
|
-
return {
|
|
571
|
+
return {
|
|
572
|
+
adapter: wecomAdapter(botId, secret, process.env.HARA_WECOM_WS_URL),
|
|
573
|
+
runtimeScope: gatewayRuntimeScope("wecom", botId),
|
|
574
|
+
};
|
|
501
575
|
}
|
|
502
576
|
if (platform === "signal") {
|
|
503
577
|
const rpcUrl = process.env.HARA_SIGNAL_RPC_URL;
|
|
@@ -507,14 +581,37 @@ async function buildAdapter(platform) {
|
|
|
507
581
|
return null;
|
|
508
582
|
}
|
|
509
583
|
const { signalAdapter } = await import("./signal.js");
|
|
510
|
-
return { adapter: signalAdapter(rpcUrl, number) };
|
|
584
|
+
return { adapter: signalAdapter(rpcUrl, number), runtimeScope: gatewayRuntimeScope("signal", `${rpcUrl}\0${number}`) };
|
|
511
585
|
}
|
|
512
586
|
const token = process.env.HARA_TELEGRAM_TOKEN;
|
|
513
587
|
if (!token) {
|
|
514
588
|
console.error("hara gateway: set HARA_TELEGRAM_TOKEN (from @BotFather) and HARA_GATEWAY_ALLOWED=<your telegram user id>.");
|
|
515
589
|
return null;
|
|
516
590
|
}
|
|
517
|
-
return { adapter: telegramAdapter(token) };
|
|
591
|
+
return { adapter: telegramAdapter(token), runtimeScope: gatewayRuntimeScope("telegram", token) };
|
|
592
|
+
}
|
|
593
|
+
/** Recover exactly one private run marker while the corresponding credential-scoped gateway is stopped.
|
|
594
|
+
* The store requires a message-id-bound action confirmation; this wrapper also takes the normal instance
|
|
595
|
+
* lease so an operator cannot race a live callback or its platform acknowledgement. */
|
|
596
|
+
export async function recoverGatewayRunOutcome(options) {
|
|
597
|
+
const requestedPlatform = canonicalGatewayPlatform(options.platform);
|
|
598
|
+
if (![
|
|
599
|
+
"telegram", "weixin", "discord", "feishu", "slack", "mattermost", "matrix", "dingtalk", "wecom", "signal",
|
|
600
|
+
].includes(requestedPlatform)) {
|
|
601
|
+
throw new Error(`unsupported gateway recovery platform '${options.platform}'`);
|
|
602
|
+
}
|
|
603
|
+
const built = await buildAdapter(requestedPlatform);
|
|
604
|
+
if (!built)
|
|
605
|
+
throw new Error("gateway credentials are required to locate the credential-scoped outcome marker");
|
|
606
|
+
const platform = built.adapter.name || requestedPlatform;
|
|
607
|
+
const release = acquireGatewayInstance(built.runtimeScope, { displayPlatform: platform });
|
|
608
|
+
try {
|
|
609
|
+
const store = await GatewayRunOutcomeStore.open(gatewayRuntimeScope("run-cache", built.runtimeScope));
|
|
610
|
+
return await store.recover(options.messageId, options.confirmation);
|
|
611
|
+
}
|
|
612
|
+
finally {
|
|
613
|
+
release();
|
|
614
|
+
}
|
|
518
615
|
}
|
|
519
616
|
/** Allowlist = the env ids ∪ a platform-confirmed owner (WeChat QR) ∪ an explicitly configured approval
|
|
520
617
|
* owner. Matrix/Signal bot account IDs are deliberately not treated as human owners. */
|
|
@@ -592,10 +689,29 @@ export async function runGateway(opts) {
|
|
|
592
689
|
const built = await buildAdapter(requestedPlatform);
|
|
593
690
|
if (!built)
|
|
594
691
|
process.exit(1);
|
|
595
|
-
const { adapter, ownerId } = built;
|
|
692
|
+
const { adapter, ownerId, runtimeScope } = built;
|
|
596
693
|
// Adapter names are the source of truth (e.g. requested `lark` builds the `feishu` adapter). Keep the
|
|
597
694
|
// requested spelling only for startup/config hints; all persisted/routable identities are canonical.
|
|
598
695
|
const platform = adapter.name || canonicalGatewayPlatform(requestedPlatform);
|
|
696
|
+
const releaseInstance = acquireGatewayInstance(runtimeScope, { displayPlatform: platform });
|
|
697
|
+
let messageDeduper;
|
|
698
|
+
let flowEffectReceipts;
|
|
699
|
+
let flowRuns;
|
|
700
|
+
let runOutcomes;
|
|
701
|
+
try {
|
|
702
|
+
messageDeduper = await GatewayMessageDeduper.open(runtimeScope);
|
|
703
|
+
// A distinct credential-scoped receipt log lets a redelivered inbound event resume after a partial flow
|
|
704
|
+
// failure without repeating replies/notifications that already succeeded.
|
|
705
|
+
flowEffectReceipts = await GatewayMessageDeduper.open(gatewayRuntimeScope("flow-effects", runtimeScope), {
|
|
706
|
+
ttlMs: 24 * 60 * 60_000,
|
|
707
|
+
});
|
|
708
|
+
flowRuns = await GatewayFlowRunStore.open(gatewayRuntimeScope("flow-runs", runtimeScope));
|
|
709
|
+
runOutcomes = await GatewayRunOutcomeStore.open(gatewayRuntimeScope("run-cache", runtimeScope));
|
|
710
|
+
}
|
|
711
|
+
catch (error) {
|
|
712
|
+
releaseInstance();
|
|
713
|
+
throw error;
|
|
714
|
+
}
|
|
599
715
|
const explicitOwner = process.env.HARA_GATEWAY_OWNER?.trim();
|
|
600
716
|
const allowlist = resolveAllowlist(process.env.HARA_GATEWAY_ALLOWED, ownerId, explicitOwner);
|
|
601
717
|
const approvalUserId = resolveApprovalOwner(explicitOwner, ownerId, allowlist);
|
|
@@ -618,7 +734,11 @@ export async function runGateway(opts) {
|
|
|
618
734
|
console.error(`hara gateway: flow approvals restricted to ${approvalOwner}.`);
|
|
619
735
|
}
|
|
620
736
|
const ac = new AbortController();
|
|
737
|
+
// Every daemon-originated outbound operation inherits shutdown cancellation. Telegram/Feishu also enforce
|
|
738
|
+
// their own hard transfer deadlines, so an SDK that ignores this signal still cannot pin shutdown forever.
|
|
739
|
+
const sendMessage = (chatId, text, idempotencyKey) => adapter.send(chatId, text, ac.signal, idempotencyKey);
|
|
621
740
|
const sessionRuns = new KeyedSerialQueue(8, 4, 64, 32);
|
|
741
|
+
const inboundHandlers = new GatewayInboundTracker();
|
|
622
742
|
const stop = () => ac.abort(new GatewayQueueClosedError());
|
|
623
743
|
const closeQueue = () => sessionRuns.close(new GatewayQueueClosedError());
|
|
624
744
|
process.once("SIGINT", stop);
|
|
@@ -629,243 +749,424 @@ export async function runGateway(opts) {
|
|
|
629
749
|
await pruneStaleMedia(platform).catch((error) => {
|
|
630
750
|
console.error(`hara gateway: media cleanup failed — ${error instanceof Error ? error.message : String(error)}`);
|
|
631
751
|
});
|
|
632
|
-
await adapter.start(async (
|
|
752
|
+
await adapter.start((m) => inboundHandlers.track((async () => {
|
|
633
753
|
try {
|
|
634
754
|
if (ac.signal.aborted)
|
|
635
755
|
return;
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
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;
|
|
756
|
+
let existingRunOutcome = null;
|
|
757
|
+
let outcomeLoadError;
|
|
758
|
+
try {
|
|
759
|
+
existingRunOutcome = await runOutcomes.load(m.messageId);
|
|
653
760
|
}
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
if (pendingReply) {
|
|
659
|
-
await adapter.send(m.chatId, pendingReply);
|
|
660
|
-
return;
|
|
661
|
-
}
|
|
761
|
+
catch (error) {
|
|
762
|
+
// Claim first so a corrupt/unreadable terminal marker consumes the same bounded failure budget instead
|
|
763
|
+
// of causing an unbounded platform redelivery loop. Presence is conservatively treated as durable.
|
|
764
|
+
outcomeLoadError = error;
|
|
662
765
|
}
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
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
|
-
}
|
|
766
|
+
const flowRunSource = flowSourceKey({ scope: runtimeScope }, platform, m.messageId);
|
|
767
|
+
const postAckCleanup = m.messageId === undefined
|
|
768
|
+
? undefined
|
|
769
|
+
: async () => {
|
|
770
|
+
const cleanup = await Promise.allSettled([
|
|
771
|
+
runOutcomes.remove(m.messageId),
|
|
772
|
+
flowRuns.removeSource(flowRunSource),
|
|
773
|
+
]);
|
|
774
|
+
const failed = cleanup.find((result) => result.status === "rejected");
|
|
775
|
+
if (failed)
|
|
776
|
+
throw failed.reason;
|
|
777
|
+
};
|
|
778
|
+
const messageClaim = await messageDeduper.claim(m.messageId, m.createdAtMs, {
|
|
779
|
+
durable: m.durablyQueued === true || existingRunOutcome !== null || outcomeLoadError !== undefined,
|
|
780
|
+
});
|
|
781
|
+
if (!messageClaim) {
|
|
782
|
+
console.error(`hara gateway: ignored a duplicate or stale ${platform} event.`);
|
|
783
|
+
// A prior callback may have persisted the processed id and then crashed before its adapter ACK. Let the
|
|
784
|
+
// redelivery ACK first; only its adapter-confirmed cleanup may remove any terminal execution marker.
|
|
785
|
+
return postAckCleanup;
|
|
693
786
|
}
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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`);
|
|
787
|
+
if (ac.signal.aborted) {
|
|
788
|
+
await messageClaim.release();
|
|
789
|
+
return;
|
|
701
790
|
}
|
|
702
|
-
const
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
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
|
-
}
|
|
791
|
+
const messageEffectKey = (stage, index = 0) => m.messageId
|
|
792
|
+
? createHash("sha256")
|
|
793
|
+
.update("hara-gateway-effect-v1\0")
|
|
794
|
+
.update(runtimeScope)
|
|
795
|
+
.update("\0")
|
|
796
|
+
.update(m.messageId)
|
|
797
|
+
.update("\0")
|
|
798
|
+
.update(stage)
|
|
799
|
+
.update("\0")
|
|
800
|
+
.update(String(index))
|
|
801
|
+
.digest("hex")
|
|
802
|
+
: undefined;
|
|
803
|
+
const runMessageEffect = async (stage, index, effect) => {
|
|
804
|
+
const key = messageEffectKey(stage, index);
|
|
805
|
+
if (!key)
|
|
806
|
+
return effect();
|
|
807
|
+
const claim = await flowEffectReceipts.claim(key);
|
|
808
|
+
if (!claim)
|
|
786
809
|
return;
|
|
810
|
+
try {
|
|
811
|
+
await effect(key);
|
|
812
|
+
await claim.complete();
|
|
787
813
|
}
|
|
788
|
-
|
|
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)");
|
|
814
|
+
catch (error) {
|
|
794
815
|
try {
|
|
795
|
-
await
|
|
796
|
-
}
|
|
797
|
-
catch (error) {
|
|
798
|
-
return adapter.send(m.chatId, `✗ couldn't send ${p}: ${error instanceof Error ? error.message : String(error)}`);
|
|
816
|
+
await claim.release();
|
|
799
817
|
}
|
|
800
|
-
|
|
818
|
+
catch { /* retain the original delivery error */ }
|
|
819
|
+
throw error;
|
|
801
820
|
}
|
|
802
|
-
|
|
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;
|
|
821
|
+
};
|
|
822
|
+
let preparedResult;
|
|
832
823
|
try {
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
824
|
+
await (async () => {
|
|
825
|
+
if (outcomeLoadError)
|
|
826
|
+
throw outcomeLoadError;
|
|
827
|
+
// Flows (opt-in, ~/.hara/flows.json): rules that intercept a matching inbound message → agent task + deliver,
|
|
828
|
+
// BEFORE the allowlist/DM-driver logic. A matched flow is authorized by its own presence in the user's config
|
|
829
|
+
// (group senders won't be in the allowlist), so this must run first. dispatchFlows settles every claimed
|
|
830
|
+
// run/delivery before returning, keeping the inbound tracker and persistent message claim truthful.
|
|
831
|
+
const flowRan = existingRunOutcome ? false : await dispatchFlows(m, platform, (prompt, home, schema, signal) => runFlowAgent(prompt, home ?? cwd, schema, signal), // stateless per trigger; rule cwd = agent's home
|
|
832
|
+
(text, idempotencyKey) => adapter.send(m.chatId, plainChat(text), ac.signal, idempotencyKey),
|
|
833
|
+
// Flow replies are chat bubbles too — flatten markdown; an opaque effect key lets Feishu deduplicate
|
|
834
|
+
// an externally-successful request whose local response/receipt was interrupted.
|
|
835
|
+
approvalOwner, ac.signal, { scope: runtimeScope, receipts: flowEffectReceipts, runs: flowRuns });
|
|
836
|
+
if (flowRan)
|
|
837
|
+
return;
|
|
838
|
+
// The gateway's default is a DM driver. Unknown channel shapes fail closed too: older/third-party adapters
|
|
839
|
+
// may omit chatType, and treating every unknown room as a DM would expose the full coding agent in groups.
|
|
840
|
+
// The chatId===userId invariant preserves legacy Telegram/Weixin-style DMs until their adapter is upgraded.
|
|
841
|
+
if (!isPrivateApprovalMessage(m))
|
|
842
|
+
return;
|
|
843
|
+
if (!isAllowed(m.userId, allowlist)) {
|
|
844
|
+
console.error(`hara gateway: ✗ message from ${m.userId} — not in allowlist. Add it to HARA_GATEWAY_ALLOWED to authorize.`);
|
|
845
|
+
await sendMessage(m.chatId, "⛔ not authorized.");
|
|
846
|
+
return;
|
|
845
847
|
}
|
|
848
|
+
// One credential/chat/user admission covers context lookup, routing, stateful commands, coding, and
|
|
849
|
+
// delivery. Context is therefore read only after earlier messages settle; /new or /voice cannot slip
|
|
850
|
+
// between a tmux preflight and a second coding enqueue because there is no second enqueue.
|
|
851
|
+
const admissionKey = gatewayAdmissionKey(runtimeScope, m);
|
|
846
852
|
try {
|
|
847
|
-
await
|
|
853
|
+
await sessionRuns.run(admissionKey, async () => {
|
|
854
|
+
// Owner approving a pending flow action ("采用" / "改:…" / "取消")? Execute it (e.g. post the drafted reply
|
|
855
|
+
// back to the origin group) instead of routing this as a fresh command. This is the flow approve→execute loop.
|
|
856
|
+
if (!existingRunOutcome && approvalUserId && String(m.userId) === approvalUserId && isPrivateApprovalMessage(m)) {
|
|
857
|
+
const pendingReply = await handleOwnerReply(approvalOwner, m.text, { signal: ac.signal });
|
|
858
|
+
if (pendingReply) {
|
|
859
|
+
await sendMessage(m.chatId, pendingReply);
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
// Context is resolved inside this message's one FIFO admission, after all earlier chat work has settled.
|
|
864
|
+
const who = { userId: m.userId, chatType: m.chatType };
|
|
865
|
+
const ctx = chatContext(adapter.name, m.chatId, cwd, who);
|
|
866
|
+
const runSerializedSideEffect = (effect) => executeDurableGatewayEffect(runOutcomes, m.messageId, effect, ac.signal);
|
|
867
|
+
// If a tmux session opted in (via `hara remote ask/bind`), this reply is its input → inject it into that
|
|
868
|
+
// pane, let it react, and reply with the session's NEW output (on-inbound relay — quiet + iLink-friendly:
|
|
869
|
+
// one reply per message, no continuous push). Owner-gated by the allowlist check above.
|
|
870
|
+
if (!existingRunOutcome && !parseCommand(m.text)) {
|
|
871
|
+
preparedResult = await (async () => {
|
|
872
|
+
// pickPaneForReply consumes a one-shot route, so even that lookup must happen after the durable
|
|
873
|
+
// marker. If a process dies after route consumption, redelivery warns instead of injecting elsewhere.
|
|
874
|
+
const prior = await runOutcomes.start(m.messageId);
|
|
875
|
+
if (prior) {
|
|
876
|
+
const recovered = resultFromRunOutcome(prior);
|
|
877
|
+
if (prior.status !== "complete")
|
|
878
|
+
await finishRunOutcome(runOutcomes, m.messageId, recovered);
|
|
879
|
+
return recovered;
|
|
880
|
+
}
|
|
881
|
+
if (ac.signal.aborted)
|
|
882
|
+
throw ac.signal.reason instanceof Error ? ac.signal.reason : new Error("gateway effect cancelled");
|
|
883
|
+
const pane = pickPaneForReply();
|
|
884
|
+
if (!pane) {
|
|
885
|
+
// No irreversible route existed. Remove this preflight marker before falling through to coding.
|
|
886
|
+
await runOutcomes.remove(m.messageId);
|
|
887
|
+
return undefined;
|
|
888
|
+
}
|
|
889
|
+
console.error(`hara gateway: routed reply → tmux pane ${pane}`);
|
|
890
|
+
const before = capturePane(pane) ?? "";
|
|
891
|
+
injectTmux(pane, m.text);
|
|
892
|
+
// wait for the session's output to SETTLE (poll every 800ms; stable for ~1.6s → done; cap ~10s) so a
|
|
893
|
+
// slow response isn't missed and we don't capture mid-stream.
|
|
894
|
+
let after = "";
|
|
895
|
+
let stable = 0;
|
|
896
|
+
for (let i = 0; i < 12; i++) {
|
|
897
|
+
await new Promise((r) => setTimeout(r, 800));
|
|
898
|
+
const cur = capturePane(pane) ?? "";
|
|
899
|
+
if (cur === after) {
|
|
900
|
+
if (++stable >= 2)
|
|
901
|
+
break;
|
|
902
|
+
}
|
|
903
|
+
else {
|
|
904
|
+
stable = 0;
|
|
905
|
+
after = cur;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
const delta = outputDelta(before, after).trim();
|
|
909
|
+
const body = delta ? (delta.length > 1500 ? "…\n" + delta.slice(-1500) : delta) : "(已注入,暂无新输出 — 发 ? 再看)";
|
|
910
|
+
const completed = { reply: `🖥 ${pane}\n${body}`, files: [] };
|
|
911
|
+
if (ac.signal.aborted)
|
|
912
|
+
throw ac.signal.reason instanceof Error ? ac.signal.reason : new Error("gateway effect cancelled");
|
|
913
|
+
await finishRunOutcome(runOutcomes, m.messageId, completed);
|
|
914
|
+
return completed;
|
|
915
|
+
})();
|
|
916
|
+
}
|
|
917
|
+
// Thread identity is (platform, chat) for DMs and (platform, chat, USER) for groups — auto-derived, so
|
|
918
|
+
// group members each get their own session thread instead of interleaving into one polluted context.
|
|
919
|
+
if (!existingRunOutcome && !preparedResult && ctx.rotatedFrom) {
|
|
920
|
+
// Idle auto-rotation just happened (session hygiene): tell the user ONCE, with the escape hatch.
|
|
921
|
+
await sendMessage(m.chatId, `🧵 fresh thread (chat was idle) — /resume ${ctx.rotatedFrom.slice(-18)} continues the previous one`);
|
|
922
|
+
}
|
|
923
|
+
const cmd = existingRunOutcome || preparedResult ? null : parseCommand(m.text);
|
|
924
|
+
if (cmd) {
|
|
925
|
+
if (cmd.cmd === "help")
|
|
926
|
+
return sendMessage(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");
|
|
927
|
+
if (cmd.cmd === "agent") {
|
|
928
|
+
// Per-thread agent switch, resolved via the GLOBAL index: an agent with a home also /cd's the thread
|
|
929
|
+
// there (its data + AGENTS.md context — correctness over chat continuity; /agent main switches back,
|
|
930
|
+
// and the previous thread is preserved per (chat, cwd), so nothing is lost).
|
|
931
|
+
if (!cmd.arg)
|
|
932
|
+
return sendMessage(m.chatId, `🤖 current agent: ${ctx.agent ?? "main"}\nusage: /agent <name|project:name> · /agent main`);
|
|
933
|
+
if (cmd.arg === "main" || cmd.arg === "off") {
|
|
934
|
+
setChatAgent(adapter.name, m.chatId, undefined, who);
|
|
935
|
+
const restored = chatContext(adapter.name, m.chatId, cwd, who);
|
|
936
|
+
return sendMessage(m.chatId, `🤖 back to the main agent.\n📂 ${restored.cwd}`);
|
|
937
|
+
}
|
|
938
|
+
const { resolveAgent } = await import("../org/projects.js");
|
|
939
|
+
// A bare name means the override in the thread's current project when one exists; explicit
|
|
940
|
+
// `global:name` and `project:name` remain deterministic. This avoids making a local reviewer
|
|
941
|
+
// ambiguous merely because several other registered projects also define one.
|
|
942
|
+
const hit = resolveAgent(cmd.arg, ctx.cwd);
|
|
943
|
+
if (!hit)
|
|
944
|
+
return sendMessage(m.chatId, `✗ no agent '${cmd.arg}' — see \`hara agents\` on the host.`);
|
|
945
|
+
if ("ambiguous" in hit)
|
|
946
|
+
return sendMessage(m.chatId, `'${cmd.arg}' exists in several projects — pick one:\n${hit.ambiguous.map((e) => `${e.project}:${e.name}`).join("\n")}`);
|
|
947
|
+
const agentRef = hit.project ? `${hit.project}:${hit.name}` : `global:${hit.name}`;
|
|
948
|
+
setChatAgent(adapter.name, m.chatId, agentRef, who, hit.home || undefined);
|
|
949
|
+
return sendMessage(m.chatId, `🤖 this thread now talks to ${agentRef}${hit.home ? `\n📂 ${hit.home}` : ""}\n/agent main switches back`);
|
|
950
|
+
}
|
|
951
|
+
if (cmd.cmd === "detach") {
|
|
952
|
+
const { unbindBinds } = await import("./tmux-routes.js");
|
|
953
|
+
const n = unbindBinds();
|
|
954
|
+
return sendMessage(m.chatId, n ? `🔓 detached ${n} bound tmux pane(s) — replies go to hara again.` : "(no tmux panes were bound)");
|
|
955
|
+
}
|
|
956
|
+
if (cmd.cmd === "pwd")
|
|
957
|
+
return sendMessage(m.chatId, `📂 ${ctx.cwd}\n🧵 ${ctx.sessionId.slice(-18)}`);
|
|
958
|
+
if (cmd.cmd === "cd" || cmd.cmd === "project") {
|
|
959
|
+
if (ctx.agent && !ctx.agent.startsWith("global:")) {
|
|
960
|
+
return sendMessage(m.chatId, `🤖 ${ctx.agent} is pinned to its home. Use /agent main before changing project.`);
|
|
961
|
+
}
|
|
962
|
+
if (!cmd.arg)
|
|
963
|
+
return sendMessage(m.chatId, `📂 ${ctx.cwd}\nusage: /cd <dir> (absolute, ~, or relative to here)`);
|
|
964
|
+
const target = resolve(ctx.cwd, cmd.arg.replace(/^~(?=\/|$)/, homedir()));
|
|
965
|
+
if (!existsSync(target) || !statSync(target).isDirectory())
|
|
966
|
+
return sendMessage(m.chatId, `✗ not a directory: ${target}`);
|
|
967
|
+
const sid = chatCd(adapter.name, m.chatId, target, who);
|
|
968
|
+
return sendMessage(m.chatId, `📂 now in ${target}\n🧵 ${sid.slice(-18)} · /sessions lists this dir's threads`);
|
|
969
|
+
}
|
|
970
|
+
if (cmd.cmd === "new") {
|
|
971
|
+
preparedResult = await runSerializedSideEffect(async () => ({
|
|
972
|
+
reply: `✨ new thread: ${newChatSession(adapter.name, m.chatId, cwd, who).slice(-18)}`,
|
|
973
|
+
files: [],
|
|
974
|
+
}));
|
|
975
|
+
}
|
|
976
|
+
if (cmd.cmd === "sessions") {
|
|
977
|
+
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");
|
|
978
|
+
return sendMessage(m.chatId, `📂 ${ctx.cwd}\n${list || "(no threads in this dir yet)"}`);
|
|
979
|
+
}
|
|
980
|
+
if (cmd.cmd === "resume") {
|
|
981
|
+
const match = resolveOwnedSessionId(adapter.name, m.chatId, cmd.arg, listSessions().map((session) => session.id), who);
|
|
982
|
+
if (!match)
|
|
983
|
+
return sendMessage(m.chatId, `no session '${cmd.arg}' in this chat thread`);
|
|
984
|
+
if ("ambiguous" in match)
|
|
985
|
+
return sendMessage(m.chatId, `ambiguous session '${cmd.arg}' — use more characters`);
|
|
986
|
+
const id = match.id;
|
|
987
|
+
const target = loadSession(id)?.meta.cwd || ctx.cwd; // follow the session's own dir so it runs in the right place
|
|
988
|
+
setChatSession(adapter.name, m.chatId, id, target, who);
|
|
989
|
+
return sendMessage(m.chatId, `↩ resumed ${id.slice(-18)}\n📂 ${target}`);
|
|
990
|
+
}
|
|
991
|
+
if (cmd.cmd === "voice") {
|
|
992
|
+
if (!adapter.sendFile)
|
|
993
|
+
return sendMessage(m.chatId, "this platform can't send voice yet.");
|
|
994
|
+
preparedResult = await runSerializedSideEffect(async () => {
|
|
995
|
+
// Read the state inside the serialized action, derive one target, and persist that exact target.
|
|
996
|
+
// A redelivery loads the completed outcome and never invokes toggleVoice a second time.
|
|
997
|
+
const targetOn = !chatContext(adapter.name, m.chatId, cwd, who).voice;
|
|
998
|
+
const actual = toggleVoice(adapter.name, m.chatId, who);
|
|
999
|
+
if (actual !== targetOn)
|
|
1000
|
+
throw new Error("voice preference changed concurrently; refusing to toggle twice");
|
|
1001
|
+
return {
|
|
1002
|
+
reply: targetOn ? "🔊 voice replies ON — I'll speak each reply too." : "🔇 voice replies OFF.",
|
|
1003
|
+
files: [],
|
|
1004
|
+
};
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
1007
|
+
if (cmd.cmd === "say") {
|
|
1008
|
+
if (!adapter.sendFile)
|
|
1009
|
+
return sendMessage(m.chatId, "this platform can't send voice yet.");
|
|
1010
|
+
if (!cmd.arg)
|
|
1011
|
+
return sendMessage(m.chatId, "usage: /say <text to speak>");
|
|
1012
|
+
preparedResult = await runSerializedSideEffect(async () => {
|
|
1013
|
+
const audio = await synthesize(cmd.arg, ac.signal);
|
|
1014
|
+
if (!audio)
|
|
1015
|
+
return { reply: "✗ TTS failed (check HARA_TTS_* config).", files: [] };
|
|
1016
|
+
try {
|
|
1017
|
+
return { reply: "", files: [await snapshotVerifiedLocalFile(audio)] };
|
|
1018
|
+
}
|
|
1019
|
+
finally {
|
|
1020
|
+
rmSync(audio, { force: true });
|
|
1021
|
+
}
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
if (cmd.cmd === "send") {
|
|
1025
|
+
if (!adapter.sendFile)
|
|
1026
|
+
return sendMessage(m.chatId, "this platform can't send files yet.");
|
|
1027
|
+
const p = cmd.arg ? resolve(ctx.cwd, cmd.arg.replace(/^~(?=\/|$)/, homedir())) : "";
|
|
1028
|
+
if (!p)
|
|
1029
|
+
return sendMessage(m.chatId, "usage: /send <path> (abs, ~, or relative to current dir)");
|
|
1030
|
+
preparedResult = await runSerializedSideEffect(async () => {
|
|
1031
|
+
try {
|
|
1032
|
+
return { reply: "", files: [await snapshotVerifiedLocalFile(p)] };
|
|
1033
|
+
}
|
|
1034
|
+
catch (error) {
|
|
1035
|
+
return { reply: `✗ couldn't send ${p}: ${error instanceof Error ? error.message : String(error)}`, files: [] };
|
|
1036
|
+
}
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
// any other slash word → treat as a normal task
|
|
1040
|
+
}
|
|
1041
|
+
// Transient progress marker — capability-driven: sent ONLY where it can be recalled afterwards (Feishu),
|
|
1042
|
+
// so it never leaves residue. Platforms without recall (WeChat iLink: send response is `{}`, no message
|
|
1043
|
+
// id exists to revoke) get no marker at all — a clean thread beats a permanent "working…" bubble.
|
|
1044
|
+
let workingId;
|
|
1045
|
+
if (!preparedResult && !existingRunOutcome && adapter.sendTracked && adapter.recall) {
|
|
1046
|
+
workingId = await adapter.sendTracked(m.chatId, "⟳ working…", ac.signal).catch(() => undefined);
|
|
1047
|
+
}
|
|
1048
|
+
let result;
|
|
1049
|
+
try {
|
|
1050
|
+
if (preparedResult) {
|
|
1051
|
+
result = preparedResult;
|
|
1052
|
+
}
|
|
1053
|
+
else {
|
|
1054
|
+
const cached = existingRunOutcome ?? await runOutcomes.load(m.messageId);
|
|
1055
|
+
if (cached) {
|
|
1056
|
+
console.error(cached.status === "complete"
|
|
1057
|
+
? "hara gateway: resuming delivery from a completed outcome; local side effects are not repeated."
|
|
1058
|
+
: cached.status === "terminal"
|
|
1059
|
+
? "hara gateway: terminal outcome payload was reclaimed; notifying the user without repeating local side effects."
|
|
1060
|
+
: "hara gateway: ALERT an interrupted operation was recovered; local side effects were not repeated automatically.");
|
|
1061
|
+
result = await executeDurableGatewayEffect(runOutcomes, m.messageId, async () => { throw new Error("cached gateway outcome disappeared during recovery"); }, ac.signal);
|
|
1062
|
+
}
|
|
1063
|
+
else {
|
|
1064
|
+
// The outer chat admission is the only queue. Starting the tombstone and running the agent here
|
|
1065
|
+
// prevents a later /new, /voice, or coding message from slipping between routing and execution.
|
|
1066
|
+
result = await executeDurableGatewayEffect(runOutcomes, m.messageId, async () => {
|
|
1067
|
+
const completed = await runHara(m.text, ctx.sessionId, ctx.cwd, adapter.name, m.images, ctx.agent, { signal: ac.signal });
|
|
1068
|
+
if (!completed.reply || !ctx.voice || !adapter.sendFile)
|
|
1069
|
+
return completed;
|
|
1070
|
+
const audio = await synthesize(completed.reply, ac.signal);
|
|
1071
|
+
if (!audio)
|
|
1072
|
+
return completed;
|
|
1073
|
+
try {
|
|
1074
|
+
return { ...completed, voice: await snapshotVerifiedLocalFile(audio) };
|
|
1075
|
+
}
|
|
1076
|
+
finally {
|
|
1077
|
+
rmSync(audio, { force: true });
|
|
1078
|
+
}
|
|
1079
|
+
}, ac.signal);
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
catch (e) {
|
|
1084
|
+
if (e instanceof GatewayQueueClosedError || ac.signal.aborted) {
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
1087
|
+
throw e;
|
|
1088
|
+
}
|
|
1089
|
+
finally {
|
|
1090
|
+
if (workingId && adapter.recall)
|
|
1091
|
+
await adapter.recall(m.chatId, workingId, ac.signal).catch(() => { });
|
|
1092
|
+
}
|
|
1093
|
+
const { reply, files, voice } = result;
|
|
1094
|
+
try {
|
|
1095
|
+
if (ac.signal.aborted)
|
|
1096
|
+
return;
|
|
1097
|
+
const hasReply = Boolean(reply);
|
|
1098
|
+
const deliveryCommand = parseCommand(m.text)?.cmd;
|
|
1099
|
+
const directFileCommand = deliveryCommand === "say" || deliveryCommand === "send";
|
|
1100
|
+
if (hasReply) {
|
|
1101
|
+
await runMessageEffect("reply", 0, (key) => sendMessage(m.chatId, plainChat(reply), key));
|
|
1102
|
+
}
|
|
1103
|
+
else if (files.length && !directFileCommand) {
|
|
1104
|
+
await runMessageEffect("attachment-marker", 0, (key) => sendMessage(m.chatId, "📎", key));
|
|
1105
|
+
}
|
|
1106
|
+
// Deliver only immutable private snapshots produced by send_file.
|
|
1107
|
+
for (const [fileIndex, f] of files.entries()) {
|
|
1108
|
+
if (!adapter.sendFile) {
|
|
1109
|
+
await sendMessage(m.chatId, "(this platform can't send files yet)");
|
|
1110
|
+
break;
|
|
1111
|
+
}
|
|
1112
|
+
try {
|
|
1113
|
+
await runMessageEffect("attachment", fileIndex, (key) => adapter.sendFile(m.chatId, f, ac.signal, key));
|
|
1114
|
+
}
|
|
1115
|
+
catch (e) {
|
|
1116
|
+
const deliveryError = e instanceof Error ? e : new Error(String(e));
|
|
1117
|
+
await runMessageEffect("attachment-error", fileIndex, (key) => sendMessage(m.chatId, `✗ couldn't send attachment: ${deliveryError.message}`, key));
|
|
1118
|
+
// The durable outcome and per-attachment receipt let platform redelivery retry only this unfinished
|
|
1119
|
+
// transport. Never ACK a failed upload and force the user to rerun the side-effect command.
|
|
1120
|
+
throw deliveryError;
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
if (voice && adapter.sendFile) {
|
|
1124
|
+
// `voice` is part of the durable run outcome. If this upload fails, platform redelivery observes the
|
|
1125
|
+
// completed outcome and retries only this receipt/transport with the identical audio bytes.
|
|
1126
|
+
await runMessageEffect("voice", 0, (key) => adapter.sendFile(m.chatId, voice, ac.signal, key));
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
finally {
|
|
1130
|
+
// Text-send failures, shutdown races, unsupported adapters, and upload failures all remove snapshots.
|
|
1131
|
+
for (const f of files)
|
|
1132
|
+
if (f.snapshotPath)
|
|
1133
|
+
cleanupOutboundSnapshot(f.snapshotPath);
|
|
1134
|
+
if (voice?.snapshotPath)
|
|
1135
|
+
cleanupOutboundSnapshot(voice.snapshotPath);
|
|
1136
|
+
}
|
|
1137
|
+
});
|
|
848
1138
|
}
|
|
849
|
-
catch (
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
await deliverVerifiedLocalFile(adapter, m.chatId, audio);
|
|
858
|
-
}
|
|
859
|
-
finally {
|
|
860
|
-
rmSync(audio, { force: true });
|
|
861
|
-
}
|
|
1139
|
+
catch (error) {
|
|
1140
|
+
if (!(error instanceof GatewayQueueFullError))
|
|
1141
|
+
throw error;
|
|
1142
|
+
const message = error.scope === "session"
|
|
1143
|
+
? "⏳ this chat is busy — wait for earlier messages to finish, then retry."
|
|
1144
|
+
: "⏳ the gateway is at capacity — try again shortly.";
|
|
1145
|
+
await sendMessage(m.chatId, message);
|
|
1146
|
+
return;
|
|
862
1147
|
}
|
|
1148
|
+
})();
|
|
1149
|
+
if (ac.signal.aborted)
|
|
1150
|
+
await messageClaim.release();
|
|
1151
|
+
else {
|
|
1152
|
+
await messageClaim.complete();
|
|
1153
|
+
// Telegram invokes this only after a subsequent offset-bearing poll succeeds; Feishu invokes it only
|
|
1154
|
+
// after durable spool deletion. A crash between handler completion and platform ACK therefore leaves
|
|
1155
|
+
// the terminal marker intact and can never turn a redelivery into another coding/tool execution.
|
|
1156
|
+
return postAckCleanup;
|
|
863
1157
|
}
|
|
864
1158
|
}
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
1159
|
+
catch (error) {
|
|
1160
|
+
if (ac.signal.aborted) {
|
|
1161
|
+
await messageClaim.release(); // shutdown is not a poison-message failure
|
|
1162
|
+
throw error;
|
|
1163
|
+
}
|
|
1164
|
+
const exhausted = await messageClaim.fail();
|
|
1165
|
+
if (!exhausted)
|
|
1166
|
+
throw error;
|
|
1167
|
+
// Third real failure is dead-lettered by the deduper. Resolve normally so Telegram advances its offset
|
|
1168
|
+
// and a durable Feishu spool can remove the item instead of rerunning full-auto coding forever.
|
|
1169
|
+
return postAckCleanup;
|
|
869
1170
|
}
|
|
870
1171
|
}
|
|
871
1172
|
finally {
|
|
@@ -873,14 +1174,21 @@ export async function runGateway(opts) {
|
|
|
873
1174
|
console.error(`hara gateway: inbound media cleanup failed — ${error instanceof Error ? error.message : String(error)}`);
|
|
874
1175
|
});
|
|
875
1176
|
}
|
|
876
|
-
}, ac.signal, (m) => shouldDownloadInboundMedia(m, allowlist));
|
|
1177
|
+
})()), ac.signal, (m) => shouldDownloadInboundMedia(m, allowlist));
|
|
877
1178
|
}
|
|
878
1179
|
finally {
|
|
879
1180
|
stop();
|
|
880
1181
|
closeQueue();
|
|
881
1182
|
await sessionRuns.waitForIdle();
|
|
1183
|
+
const handlersDrained = await inboundHandlers.drain();
|
|
882
1184
|
ac.signal.removeEventListener("abort", closeQueue);
|
|
883
1185
|
process.off("SIGINT", stop);
|
|
884
1186
|
process.off("SIGTERM", stop);
|
|
1187
|
+
if (handlersDrained)
|
|
1188
|
+
releaseInstance();
|
|
1189
|
+
else {
|
|
1190
|
+
console.error("hara gateway: shutdown timed out with inbound work still active; instance lease retained until callbacks finish.");
|
|
1191
|
+
void inboundHandlers.waitForIdle().then(() => releaseInstance(), (error) => console.error(`hara gateway: could not release the deferred instance lease — ${error instanceof Error ? error.message : String(error)}`));
|
|
1192
|
+
}
|
|
885
1193
|
}
|
|
886
1194
|
}
|