@konglx/rotom 2.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +417 -0
- package/bin/mesh-master.sh +439 -0
- package/bin/rotom +29 -0
- package/bin/rotom-link.sh +136 -0
- package/bin/rotom-send-with-status +57 -0
- package/bin/rotom-up.sh +428 -0
- package/dist/cli/ask.js +62 -0
- package/dist/cli/common.js +321 -0
- package/dist/cli/config.js +65 -0
- package/dist/cli/directory.js +17 -0
- package/dist/cli/executor.js +58 -0
- package/dist/cli/fed.js +91 -0
- package/dist/cli/group.js +273 -0
- package/dist/cli/identity.js +62 -0
- package/dist/cli/init.js +268 -0
- package/dist/cli/issue.js +202 -0
- package/dist/cli/join.js +170 -0
- package/dist/cli/link.js +47 -0
- package/dist/cli/master.js +51 -0
- package/dist/cli/memory.js +307 -0
- package/dist/cli/note.js +68 -0
- package/dist/cli/repo.js +77 -0
- package/dist/cli/rotom.js +277 -0
- package/dist/cli/routes.js +118 -0
- package/dist/cli/run.js +45 -0
- package/dist/cli/schedule.js +237 -0
- package/dist/cli/skill.js +173 -0
- package/dist/cli/team.js +106 -0
- package/dist/executor/claude-code-hook.cjs +80 -0
- package/dist/executor/cli-executor.js +8 -0
- package/dist/executor/executors/claude-code.js +780 -0
- package/dist/executor/executors/codex.js +719 -0
- package/dist/executor/executors/hermes-cli.js +855 -0
- package/dist/executor/executors/openclaw.js +467 -0
- package/dist/executor/executors/pi.js +514 -0
- package/dist/executor/index.js +269 -0
- package/dist/executor/jsonrpc-transport.js +125 -0
- package/dist/executor/process-runner.js +101 -0
- package/dist/executor/reasoning-status.js +83 -0
- package/dist/executor/repo-cache.js +502 -0
- package/dist/executor/session-store.js +188 -0
- package/dist/executor/worker-chat.js +257 -0
- package/dist/executor/worker-connection.js +89 -0
- package/dist/executor/worker-issue.js +264 -0
- package/dist/executor/worker.js +877 -0
- package/dist/link/pending-requests.js +72 -0
- package/dist/link/server.js +233 -0
- package/dist/link/visibility-store.js +58 -0
- package/dist/master/api/agents.js +333 -0
- package/dist/master/api/artifacts.js +271 -0
- package/dist/master/api/domains.js +64 -0
- package/dist/master/api/groups.js +635 -0
- package/dist/master/api/guidance-templates.js +147 -0
- package/dist/master/api/index.js +89 -0
- package/dist/master/api/issues-patrol.js +172 -0
- package/dist/master/api/issues.js +663 -0
- package/dist/master/api/links-patrol.js +168 -0
- package/dist/master/api/links.js +114 -0
- package/dist/master/api/memory.js +259 -0
- package/dist/master/api/messages.js +157 -0
- package/dist/master/api/notes.js +77 -0
- package/dist/master/api/schedule-patterns.js +133 -0
- package/dist/master/api/schedules.js +272 -0
- package/dist/master/api/sessions.js +158 -0
- package/dist/master/api/share.js +269 -0
- package/dist/master/api/skills.js +190 -0
- package/dist/master/api/teams.js +122 -0
- package/dist/master/api/uploads.js +245 -0
- package/dist/master/auth.js +134 -0
- package/dist/master/dashboard/animations/calico-dozing.apng +0 -0
- package/dist/master/dashboard/animations/calico-error.apng +0 -0
- package/dist/master/dashboard/animations/calico-happy.apng +0 -0
- package/dist/master/dashboard/animations/calico-notification.apng +0 -0
- package/dist/master/dashboard/animations/calico-sleeping.apng +0 -0
- package/dist/master/dashboard/animations/calico-thinking.apng +0 -0
- package/dist/master/dashboard/animations/calico-waking.apng +0 -0
- package/dist/master/dashboard/assets/ApprovalCard-C38VV6ko.css +1 -0
- package/dist/master/dashboard/assets/ApprovalCard-CHPh2dmE.js +17 -0
- package/dist/master/dashboard/assets/ArtifactPanel-P_2gAP7v.js +1 -0
- package/dist/master/dashboard/assets/ArtifactPanel-aGHySny5.css +1 -0
- package/dist/master/dashboard/assets/css.worker-DaIe3gwK.js +84 -0
- package/dist/master/dashboard/assets/editor.worker-BCzxt1at.js +12 -0
- package/dist/master/dashboard/assets/html.worker-CKrFyw_2.js +461 -0
- package/dist/master/dashboard/assets/index-CChrTn81.css +32 -0
- package/dist/master/dashboard/assets/index-Dhu4SN1z.js +181 -0
- package/dist/master/dashboard/assets/json.worker-B7c_PmGb.js +49 -0
- package/dist/master/dashboard/assets/markdown-CeN5IgdF.js +29 -0
- package/dist/master/dashboard/assets/monaco-core-DyX1CsEw.css +1 -0
- package/dist/master/dashboard/assets/monaco-core-oQiQUisy.js +833 -0
- package/dist/master/dashboard/assets/monaco-setup-CiOPQdmo.js +1 -0
- package/dist/master/dashboard/assets/react-vendor-C8IxlyCR.js +67 -0
- package/dist/master/dashboard/assets/ts.worker-BhkL8olL.js +51334 -0
- package/dist/master/dashboard/assets/useMonaco-ILb4vyPh.js +12 -0
- package/dist/master/dashboard/assets/vite-preload-CxJPbCTl.js +1 -0
- package/dist/master/dashboard/debug-auth.html +197 -0
- package/dist/master/dashboard/favicon.ico +0 -0
- package/dist/master/dashboard/index.html +20 -0
- package/dist/master/dashboard/rotom-avatar.png +0 -0
- package/dist/master/db/agent-sessions.js +60 -0
- package/dist/master/db/agent-visibility.js +64 -0
- package/dist/master/db/agents.js +119 -0
- package/dist/master/db/ask-bridges.js +157 -0
- package/dist/master/db/build-update.js +59 -0
- package/dist/master/db/core.js +82 -0
- package/dist/master/db/domains.js +80 -0
- package/dist/master/db/groups.js +316 -0
- package/dist/master/db/guidance-templates.js +58 -0
- package/dist/master/db/index.js +12 -0
- package/dist/master/db/internal.js +45 -0
- package/dist/master/db/issues-patrol.js +81 -0
- package/dist/master/db/issues.js +373 -0
- package/dist/master/db/links.js +221 -0
- package/dist/master/db/master-node.js +43 -0
- package/dist/master/db/memory.js +272 -0
- package/dist/master/db/messages.js +210 -0
- package/dist/master/db/notes.js +55 -0
- package/dist/master/db/schedule-patterns.js +56 -0
- package/dist/master/db/schedules.js +135 -0
- package/dist/master/db/skills.js +144 -0
- package/dist/master/db/team.js +88 -0
- package/dist/master/db/types.js +10 -0
- package/dist/master/db.js +12 -0
- package/dist/master/embedded.js +133 -0
- package/dist/master/federation/client.js +283 -0
- package/dist/master/federation/identity.js +133 -0
- package/dist/master/federation/manager.js +267 -0
- package/dist/master/federation/publisher.js +87 -0
- package/dist/master/federation/self-publisher.js +69 -0
- package/dist/master/federation/server.js +487 -0
- package/dist/master/group-paths.js +208 -0
- package/dist/master/offline-queue.js +38 -0
- package/dist/master/opc-bootstrap.js +245 -0
- package/dist/master/patrol-terminal.js +275 -0
- package/dist/master/repo-scan.js +188 -0
- package/dist/master/router.js +214 -0
- package/dist/master/scheduler-handlers.js +510 -0
- package/dist/master/scheduler.js +201 -0
- package/dist/master/server.js +203 -0
- package/dist/master/services/link-collector.js +82 -0
- package/dist/master/services/link-patrol-bootstrap.js +50 -0
- package/dist/master/services/memory-extract-prompt.js +34 -0
- package/dist/master/services/patrol-bootstrap.js +63 -0
- package/dist/master/share-tokens.js +56 -0
- package/dist/master/terminal-hub.js +300 -0
- package/dist/master/uploads.js +108 -0
- package/dist/master/util/fs.js +100 -0
- package/dist/master/util/paths.js +50 -0
- package/dist/master/util/persona.js +10 -0
- package/dist/master/ws-hub/connection.js +928 -0
- package/dist/master/ws-hub/conversation.js +290 -0
- package/dist/master/ws-hub/directory.js +70 -0
- package/dist/master/ws-hub/dispatch-enrich.js +34 -0
- package/dist/master/ws-hub/hub.js +136 -0
- package/dist/master/ws-hub/index.js +9 -0
- package/dist/master/ws-hub/internal.js +35 -0
- package/dist/master/ws-hub/routing.js +295 -0
- package/dist/master/ws-hub/sessions.js +130 -0
- package/dist/master/ws-hub.js +11 -0
- package/dist/shared/agent-profile.js +44 -0
- package/dist/shared/constants.js +55 -0
- package/dist/shared/dedup.js +33 -0
- package/dist/shared/group-context.js +62 -0
- package/dist/shared/json-codec.js +33 -0
- package/dist/shared/logger.js +136 -0
- package/dist/shared/mention.js +22 -0
- package/dist/shared/network.js +40 -0
- package/dist/shared/parse.js +18 -0
- package/dist/shared/prompt-composer.js +171 -0
- package/dist/shared/protocol/client-messages.js +8 -0
- package/dist/shared/protocol/enums.js +6 -0
- package/dist/shared/protocol/federation.js +62 -0
- package/dist/shared/protocol/guards.js +87 -0
- package/dist/shared/protocol/server-messages.js +8 -0
- package/dist/shared/protocol/types.js +8 -0
- package/dist/shared/protocol.js +19 -0
- package/dist/shared/readonly-allowlist.js +122 -0
- package/dist/shared/rotom-cli-prompt.js +23 -0
- package/dist/shared/skill-context.js +19 -0
- package/dist/shared/skill-md.js +43 -0
- package/dist/shared/slash-commands.js +50 -0
- package/dist/shared/time.js +80 -0
- package/dist/shared/title.js +46 -0
- package/dist/shared/url-extractor.js +99 -0
- package/migrations/001-schema.sql +942 -0
- package/package.json +68 -0
- package/scripts/fix-node-pty-perms.mjs +46 -0
- package/skill/rotom-a2a-communicate/SKILL.md +257 -0
- package/skill/rotom-bus-host/SKILL.md +78 -0
- package/skill/rotom-bus-host/scripts/poll-replies.sh +148 -0
|
@@ -0,0 +1,928 @@
|
|
|
1
|
+
import { nowBeijing } from "../../shared/time.js";
|
|
2
|
+
import { extractMentions } from "../../shared/mention.js";
|
|
3
|
+
/**
|
|
4
|
+
* Connection handling — WebSocket lifecycle and message dispatch.
|
|
5
|
+
*
|
|
6
|
+
* Two big methods:
|
|
7
|
+
* - handleConnection: per-connection message loop. Owns closure state
|
|
8
|
+
* (authenticated flag, agentId, generation) and dispatches by msg.type
|
|
9
|
+
* to auth / heartbeat / a2a_send / a2a_reply / a2a_reply_chunk /
|
|
10
|
+
* a2a_reply_end / update_info / disconnect / issue_update /
|
|
11
|
+
* issue_approval_request / session_view_response / session_delete_response
|
|
12
|
+
* / session_snapshot handlers. The handlers stay inside this single
|
|
13
|
+
* function to keep the closure simple — splitting per-type is a follow-up.
|
|
14
|
+
* - handleDisconnect: generation-aware, prevents stale close events from
|
|
15
|
+
* kicking a fresh reconnect.
|
|
16
|
+
*
|
|
17
|
+
* Methods attach via `Object.assign(this, connectionMethods)` in the WSHub
|
|
18
|
+
* composition root. `this` is typed as `WSHubSelf` so cross-module calls
|
|
19
|
+
* (broadcast, enrichGroupConversation,
|
|
20
|
+
* logMessage, etc.) compile.
|
|
21
|
+
*/
|
|
22
|
+
import { WebSocket } from "ws";
|
|
23
|
+
import { AUTH_TIMEOUT_MS, WS_CLOSE, RATE_LIMIT_MAX, RATE_LIMIT_WINDOW_MS, PROTOCOL_VERSION, } from "../../shared/constants.js";
|
|
24
|
+
import { isClientMessage } from "../../shared/protocol.js";
|
|
25
|
+
import { isLocalNetwork } from "../../shared/network.js";
|
|
26
|
+
import { parseProfile } from "./hub.js";
|
|
27
|
+
import { enrichWorkerDispatch } from "./dispatch-enrich.js";
|
|
28
|
+
import { resolveGroupRepoCtxLocalOnly } from "../group-paths.js";
|
|
29
|
+
import { collectLinksFromText } from "../services/link-collector.js";
|
|
30
|
+
/**
|
|
31
|
+
* Shared preamble for a2a_reply / a2a_reply_chunk / a2a_reply_end handlers.
|
|
32
|
+
* Resolves the routing target + connection metadata + group-type flag that
|
|
33
|
+
* all three reply branches need before deciding whether to broadcast,
|
|
34
|
+
* unicast, or skip dispatch.
|
|
35
|
+
*
|
|
36
|
+
* Pulled out so the three branches don't repeat the same 5-line lookup.
|
|
37
|
+
* The branches still own their (different) persistence + dispatch shapes
|
|
38
|
+
* because chunk/end/reply have meaningfully different semantics.
|
|
39
|
+
*/
|
|
40
|
+
function resolveReplyContext(hub, requestId, agentId) {
|
|
41
|
+
const targetId = hub.router.resolveReplyTarget(requestId);
|
|
42
|
+
const conversation = hub.router.getConversation(requestId);
|
|
43
|
+
const conn = hub.connections.get(agentId);
|
|
44
|
+
const fromName = conn?.name || "unknown";
|
|
45
|
+
const isA2aDirect = conversation?.groupId
|
|
46
|
+
? hub.db.getGroupById(conversation.groupId)?.type === "a2a_direct"
|
|
47
|
+
: false;
|
|
48
|
+
return { targetId, conversation, conn, fromName, isA2aDirect };
|
|
49
|
+
}
|
|
50
|
+
export const connectionMethods = {
|
|
51
|
+
handleConnection(ws, req) {
|
|
52
|
+
let authenticated = false;
|
|
53
|
+
let agentId = "";
|
|
54
|
+
let connGeneration = 0;
|
|
55
|
+
const remoteAddr = req.socket.remoteAddress;
|
|
56
|
+
// Must auth within timeout
|
|
57
|
+
const authTimeout = setTimeout(() => {
|
|
58
|
+
if (!authenticated) {
|
|
59
|
+
ws.close(WS_CLOSE.AUTH_TIMEOUT, "Auth timeout");
|
|
60
|
+
}
|
|
61
|
+
}, AUTH_TIMEOUT_MS);
|
|
62
|
+
ws.on("message", (raw) => {
|
|
63
|
+
// Parse
|
|
64
|
+
let msg;
|
|
65
|
+
try {
|
|
66
|
+
const parsed = JSON.parse(raw.toString());
|
|
67
|
+
if (!isClientMessage(parsed)) {
|
|
68
|
+
ws.close(WS_CLOSE.INVALID_JSON, "Invalid message format");
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
msg = parsed;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
ws.close(WS_CLOSE.INVALID_JSON, "Invalid JSON");
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
// Must authenticate first
|
|
78
|
+
if (!authenticated && msg.type !== "auth") {
|
|
79
|
+
ws.close(WS_CLOSE.NOT_AUTHENTICATED, "Authenticate first");
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
// ── Auth ────────────────────────────────────────────────────────────
|
|
83
|
+
if (msg.type === "auth") {
|
|
84
|
+
clearTimeout(authTimeout);
|
|
85
|
+
// Reject if already authenticated
|
|
86
|
+
if (authenticated) {
|
|
87
|
+
ws.close(WS_CLOSE.AUTH_FAILED, "Already authenticated");
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
// Try JWT reconnect first, then fall back to token auth
|
|
91
|
+
let result = null;
|
|
92
|
+
if (msg.jwt) {
|
|
93
|
+
const payload = this.auth.verify(msg.jwt);
|
|
94
|
+
if (payload) {
|
|
95
|
+
const agent = this.db.getAgentById(payload.sub);
|
|
96
|
+
if (agent) {
|
|
97
|
+
// Check if JWT was issued before the last token refresh
|
|
98
|
+
const refreshedAt = this.db.getTokenRefreshedAt(payload.sub);
|
|
99
|
+
const jwtIat = payload.iat;
|
|
100
|
+
if (refreshedAt && jwtIat) {
|
|
101
|
+
const refreshTs = Math.floor(new Date(refreshedAt).getTime() / 1000);
|
|
102
|
+
if (jwtIat < refreshTs) {
|
|
103
|
+
// JWT was issued before token refresh — reject
|
|
104
|
+
this.logger.warn(`[mesh] JWT rejected for ${agent.name}: issued before token refresh`);
|
|
105
|
+
// Fall through to token auth below
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
result = this.auth.authenticate(msg.token, msg.name);
|
|
109
|
+
if (!result) {
|
|
110
|
+
const freshJwt = this.auth.issueJwt(payload.sub, payload.name, payload.domain);
|
|
111
|
+
result = { jwt: freshJwt, agent: agent };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
// No refresh recorded — allow JWT reconnect
|
|
117
|
+
result = this.auth.authenticate(msg.token, msg.name);
|
|
118
|
+
if (!result) {
|
|
119
|
+
const freshJwt = this.auth.issueJwt(payload.sub, payload.name, payload.domain);
|
|
120
|
+
result = { jwt: freshJwt, agent: agent };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (!result) {
|
|
127
|
+
result = this.auth.authenticate(msg.token, msg.name);
|
|
128
|
+
}
|
|
129
|
+
// Fallback: agent changed name but kept same token
|
|
130
|
+
if (!result && msg.token) {
|
|
131
|
+
result = this.auth.authenticateByToken(msg.token);
|
|
132
|
+
if (result) {
|
|
133
|
+
const oldName = result.agent.name;
|
|
134
|
+
if (oldName !== msg.name) {
|
|
135
|
+
// Check name collision — another agent may already have this name
|
|
136
|
+
const nameConflict = this.db.getAgentByName(msg.name);
|
|
137
|
+
if (nameConflict) {
|
|
138
|
+
this.send(ws, { type: "auth_fail", reason: `Name "${msg.name}" is already taken` });
|
|
139
|
+
ws.close(WS_CLOSE.AUTH_FAILED, "Name conflict");
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
this.db.updateAgentName(result.agent.id, msg.name);
|
|
143
|
+
result.agent.name = msg.name;
|
|
144
|
+
this.logger.info(`[mesh] Agent renamed: "${oldName}" → "${msg.name}"`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// 本机 loopback 兜底认证:来源 IP 是 127.0.0.1 / ::1 时一律信任本机 agent,
|
|
149
|
+
// 无视 token / JWT 是否有效。这是 OPC 模式的核心 —— 本机即真人接入,
|
|
150
|
+
// 不需要 mesh_token 这种"对外认证"机制。每台机器跑 master 后,本机所有
|
|
151
|
+
// executor / CLI 调用都直通。
|
|
152
|
+
if (!result && isLocalNetwork(remoteAddr)) {
|
|
153
|
+
result = this.auth.authenticateLocal(msg.name);
|
|
154
|
+
if (result) {
|
|
155
|
+
this.logger.info(`[mesh] Local trust auth: "${msg.name}" from ${remoteAddr}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (!result) {
|
|
159
|
+
this.send(ws, { type: "auth_fail", reason: "Invalid token or name" });
|
|
160
|
+
ws.close(WS_CLOSE.AUTH_FAILED, "Auth failed");
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
authenticated = true;
|
|
164
|
+
agentId = result.agent.id;
|
|
165
|
+
const agent = result.agent;
|
|
166
|
+
// Kick existing connection if any (same agent reconnecting)
|
|
167
|
+
const existing = this.connections.get(agentId);
|
|
168
|
+
if (existing && existing.ws !== ws) {
|
|
169
|
+
this.logger.info(`[mesh] Kicking old connection for ${agent.name}`);
|
|
170
|
+
existing.ws.close(1000, "Replaced by new connection");
|
|
171
|
+
this.connections.delete(agentId);
|
|
172
|
+
// 旧连接的 issue 订阅必须清掉,否则 usage 推送会发到已死 socket
|
|
173
|
+
// (客户端重连后会重新 subscribe_issue_detail)。
|
|
174
|
+
this.unsubscribeAllIssues(agentId);
|
|
175
|
+
}
|
|
176
|
+
// Assign generation for this connection
|
|
177
|
+
this.generation++;
|
|
178
|
+
connGeneration = this.generation;
|
|
179
|
+
// Update online status
|
|
180
|
+
this.db.setAgentOnline(agentId, msg.instance);
|
|
181
|
+
// Agent-owned: description is accepted from agent auth.
|
|
182
|
+
// profile is NOT accepted here — DB is the authoritative source,
|
|
183
|
+
// updated only via PUT /agents/:id and update_info messages.
|
|
184
|
+
// Otherwise worker restarts would clobber Dashboard-edited position/bio.
|
|
185
|
+
if (msg.description) {
|
|
186
|
+
this.db.updateAgentMeta(agentId, { description: msg.description });
|
|
187
|
+
}
|
|
188
|
+
// Master-owned: domain is IGNORED from agent auth — use DB value
|
|
189
|
+
// Re-read agent from DB to get authoritative domain & enabled
|
|
190
|
+
const freshAgent = this.db.getAgentById(agentId);
|
|
191
|
+
const dbDomain = freshAgent?.domain || agent.domain || undefined;
|
|
192
|
+
const dbEnabled = freshAgent?.enabled ?? 1;
|
|
193
|
+
// Register connection
|
|
194
|
+
this.connections.set(agentId, {
|
|
195
|
+
ws,
|
|
196
|
+
agentId,
|
|
197
|
+
name: agent.name,
|
|
198
|
+
domain: dbDomain,
|
|
199
|
+
cliTool: typeof msg.cliTool === "string" && msg.cliTool ? msg.cliTool : undefined,
|
|
200
|
+
lastHeartbeat: Date.now(),
|
|
201
|
+
generation: connGeneration,
|
|
202
|
+
messageTimestamps: [],
|
|
203
|
+
});
|
|
204
|
+
// Broadcast join
|
|
205
|
+
this.broadcastDirectory("join", {
|
|
206
|
+
name: agent.name,
|
|
207
|
+
domain: dbDomain,
|
|
208
|
+
description: freshAgent?.description || msg.description || undefined,
|
|
209
|
+
status: "online",
|
|
210
|
+
enabled: dbEnabled !== 0,
|
|
211
|
+
profile: parseProfile(freshAgent?.profile),
|
|
212
|
+
});
|
|
213
|
+
// Reply auth_ok with directory, protocol version, and master-assigned config
|
|
214
|
+
const directory = this.getDirectory();
|
|
215
|
+
this.send(ws, {
|
|
216
|
+
type: "auth_ok",
|
|
217
|
+
version: PROTOCOL_VERSION,
|
|
218
|
+
jwt: result.jwt,
|
|
219
|
+
directory,
|
|
220
|
+
config: { domain: dbDomain, enabled: dbEnabled !== 0 },
|
|
221
|
+
});
|
|
222
|
+
// Push the worker's active sessions from DB so it can populate its
|
|
223
|
+
// in-memory SessionStore (used for --resume). Replaces the old
|
|
224
|
+
// worker-side ~/.rotom/sessions.json file.
|
|
225
|
+
const cliTool = typeof msg.cliTool === "string" && msg.cliTool ? msg.cliTool : undefined;
|
|
226
|
+
if (cliTool) {
|
|
227
|
+
const rows = this.db.listActiveAgentSessions(agent.name, cliTool);
|
|
228
|
+
if (rows.length > 0) {
|
|
229
|
+
this.send(ws, {
|
|
230
|
+
type: "session_sync_push",
|
|
231
|
+
entries: rows.map(r => ({
|
|
232
|
+
cliTool: r.cli_tool,
|
|
233
|
+
groupId: r.group_id,
|
|
234
|
+
sessionId: r.session_id,
|
|
235
|
+
agentName: r.agent_name,
|
|
236
|
+
usage: {
|
|
237
|
+
inputTokens: r.input_tokens ?? undefined,
|
|
238
|
+
outputTokens: r.output_tokens ?? undefined,
|
|
239
|
+
cacheReadTokens: r.cache_read_tokens ?? undefined,
|
|
240
|
+
cacheCreationTokens: r.cache_creation_tokens ?? undefined,
|
|
241
|
+
totalCostUsd: r.total_cost_usd ?? undefined,
|
|
242
|
+
},
|
|
243
|
+
model: r.model ?? null,
|
|
244
|
+
cumulativeCostUsd: r.cumulative_cost_usd,
|
|
245
|
+
cumulativeInputTokens: r.cumulative_input_tokens,
|
|
246
|
+
cumulativeOutputTokens: r.cumulative_output_tokens,
|
|
247
|
+
cumulativeCacheReadTokens: r.cumulative_cache_read_tokens,
|
|
248
|
+
cumulativeCacheCreationTokens: r.cumulative_cache_creation_tokens,
|
|
249
|
+
})),
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
// Push offline messages
|
|
254
|
+
const offlineMsgs = this.offlineQueue.pop(agentId);
|
|
255
|
+
if (offlineMsgs.length > 0) {
|
|
256
|
+
this.send(ws, { type: "offline_messages", messages: offlineMsgs });
|
|
257
|
+
}
|
|
258
|
+
this.logger.info(`[mesh] ${agent.name} connected (v${msg.version ?? "?"})`);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
// ── Rate limit check ─────────────────────────────────────────────
|
|
262
|
+
// 流式 chunk(chat reply 的 a2a_reply_chunk / a2a_reply_end、issue
|
|
263
|
+
// 进度的 issue_update)是 session 内的中间产物,只会透传给原始 target,
|
|
264
|
+
// 不会扇出给其他 agent,不该按 a2a_send 那种"防 spam"逻辑限流。新版
|
|
265
|
+
// hermes 把思考流式拆得很细,一次回答可能产生上百个 chunk,套用 60/min
|
|
266
|
+
// 直接被掐断,前端表现就是"思考完就卡住"。
|
|
267
|
+
const conn = this.connections.get(agentId);
|
|
268
|
+
const rateLimitExempt = msg.type === "heartbeat" ||
|
|
269
|
+
msg.type === "a2a_reply_chunk" ||
|
|
270
|
+
msg.type === "a2a_reply_end" ||
|
|
271
|
+
msg.type === "issue_update";
|
|
272
|
+
if (conn && !rateLimitExempt) {
|
|
273
|
+
const now = Date.now();
|
|
274
|
+
conn.messageTimestamps = conn.messageTimestamps.filter(t => now - t < RATE_LIMIT_WINDOW_MS);
|
|
275
|
+
if (conn.messageTimestamps.length >= RATE_LIMIT_MAX) {
|
|
276
|
+
this.send(ws, {
|
|
277
|
+
type: "route_result",
|
|
278
|
+
requestId: msg.requestId || "",
|
|
279
|
+
delivered: false,
|
|
280
|
+
queued: false,
|
|
281
|
+
error: "Rate limit exceeded",
|
|
282
|
+
});
|
|
283
|
+
this.logger.warn(`[mesh] Rate limited ${conn.name}`);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
conn.messageTimestamps.push(now);
|
|
287
|
+
}
|
|
288
|
+
// ── Heartbeat ───────────────────────────────────────────────────────
|
|
289
|
+
if (msg.type === "heartbeat") {
|
|
290
|
+
const conn = this.connections.get(agentId);
|
|
291
|
+
if (conn)
|
|
292
|
+
conn.lastHeartbeat = Date.now();
|
|
293
|
+
this.db.updateHeartbeat(agentId);
|
|
294
|
+
this.send(ws, { type: "heartbeat_ack" });
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
// ── Send message ────────────────────────────────────────────────────
|
|
298
|
+
if (msg.type === "a2a_send") {
|
|
299
|
+
const sendTs = Date.now();
|
|
300
|
+
const result = this.router.route(agentId, msg);
|
|
301
|
+
const conn = this.connections.get(agentId);
|
|
302
|
+
const fromName = conn?.name || "unknown";
|
|
303
|
+
const fromDomain = conn?.domain;
|
|
304
|
+
const routeType = "exact";
|
|
305
|
+
// Block messages for archived groups
|
|
306
|
+
if (msg.conversation?.groupId && this.db.isGroupArchived(msg.conversation.groupId)) {
|
|
307
|
+
this.send(ws, {
|
|
308
|
+
type: "route_result",
|
|
309
|
+
requestId: msg.requestId,
|
|
310
|
+
delivered: false,
|
|
311
|
+
queued: false,
|
|
312
|
+
error: "Group is archived",
|
|
313
|
+
});
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
let delivered = false;
|
|
317
|
+
let queued = false;
|
|
318
|
+
if (result.targetAgentId) {
|
|
319
|
+
const enrichedConversation = this.enrichGroupConversation(msg.conversation, result.targetName);
|
|
320
|
+
// chat 路径也走 worktree:group 配了 repo 且 target agent 同机时,注入 repoCtx。
|
|
321
|
+
// worker 收到后在 resolveChatCwd 里走 group 模式共享 worktree,可查 repo 代码。
|
|
322
|
+
const groupIdForRepo = enrichedConversation?.groupId;
|
|
323
|
+
const repo = groupIdForRepo && result.targetName
|
|
324
|
+
? resolveGroupRepoCtxLocalOnly(this.db, groupIdForRepo, result.targetName)
|
|
325
|
+
: null;
|
|
326
|
+
const outMsg = enrichWorkerDispatch(this, {
|
|
327
|
+
type: "a2a_message",
|
|
328
|
+
requestId: msg.requestId,
|
|
329
|
+
from: { name: fromName, domain: fromDomain, status: "online" },
|
|
330
|
+
payload: msg.payload,
|
|
331
|
+
routeType,
|
|
332
|
+
conversation: enrichedConversation,
|
|
333
|
+
...(repo ? { repoUrl: repo.repoUrl, repoBranch: repo.repoBranch, extraRepos: repo.extraRepos, worktreeMode: repo.worktreeMode } : {}),
|
|
334
|
+
}, result.targetName, enrichedConversation?.groupId);
|
|
335
|
+
delivered = this.sendToAgent(result.targetAgentId, outMsg);
|
|
336
|
+
// Persist + (for group) broadcast. Group messages are also delivered to
|
|
337
|
+
// the rest of the group via broadcastToGroup so dashboards/agents can see
|
|
338
|
+
// the conversation in real-time (mirrors a2a_reply behavior at L462-465).
|
|
339
|
+
// excludeAgentIds covers both the sender and the targeted agent so the
|
|
340
|
+
// target does not receive the same a2a_message twice.
|
|
341
|
+
if ((msg.conversation?.type === "group" || msg.conversation?.type === "single") && msg.conversation.groupId) {
|
|
342
|
+
// 兜底:发信人不在 group_members 时自动 addMembers(防"自激丢消息" +
|
|
343
|
+
// "多 tab 真人看不到自己的消息")。INSERT OR IGNORE 幂等。
|
|
344
|
+
const groupMembers = this.db.getGroupMembers(msg.conversation.groupId);
|
|
345
|
+
if (!groupMembers.some((m) => m.agent_name === fromName)) {
|
|
346
|
+
this.db.addGroupMembers(msg.conversation.groupId, [fromName]);
|
|
347
|
+
this.logger.info(`[mesh] a2a_send group: auto-joined sender "${fromName}" as group member`);
|
|
348
|
+
}
|
|
349
|
+
const mentions = extractMentions(msg.payload?.message);
|
|
350
|
+
// Skip db.addGroupMessage for group messages: the Dashboard /
|
|
351
|
+
// CLI REST endpoint already persists via POST /groups/:id/messages.
|
|
352
|
+
// Without this skip, sending one a2a_send per @mentioned agent
|
|
353
|
+
// (N sends) would store the same message N times.
|
|
354
|
+
if (msg.conversation.type !== "group") {
|
|
355
|
+
this.db.addGroupMessage(msg.conversation.groupId, fromName, msg.payload?.message || "", mentions);
|
|
356
|
+
}
|
|
357
|
+
if (msg.conversation.type === "group") {
|
|
358
|
+
// Exclude ALL @mentioned agents from broadcast — not just the
|
|
359
|
+
// current result.targetAgentId. When the Dashboard sends one
|
|
360
|
+
// a2a_send per @mentioned target (e.g. @A @B @C -> 3 sends),
|
|
361
|
+
// each send's broadcast only excludes its own target, causing
|
|
362
|
+
// other mentioned agents to receive duplicate copies via
|
|
363
|
+
// broadcast on top of their own direct delivery.
|
|
364
|
+
//
|
|
365
|
+
// 单播群(unicast, type=a2a_direct):跳过广播。消息只进 group history,
|
|
366
|
+
// 无 WS push,非 target 成员的 worker 不会被 @ 自动触发。
|
|
367
|
+
// CLI --need-reply 显式点名才叫醒 target worker 回复。
|
|
368
|
+
const a2aDirect = this.db.getGroupById(msg.conversation.groupId)?.type === "a2a_direct";
|
|
369
|
+
if (!a2aDirect) {
|
|
370
|
+
const mentionAgentIds = mentions
|
|
371
|
+
.map((name) => this.db.getAgentByName(name)?.id)
|
|
372
|
+
.filter((id) => !!id);
|
|
373
|
+
this.broadcastToGroup(msg.conversation.groupId, outMsg, [agentId, result.targetAgentId, ...mentionAgentIds]);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
if (!delivered) {
|
|
378
|
+
queued = this.offlineQueue.enqueue(result.targetAgentId, fromName, fromDomain, msg.payload, routeType);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
// Log message
|
|
382
|
+
this.db.logMessage({
|
|
383
|
+
requestId: msg.requestId,
|
|
384
|
+
fromName,
|
|
385
|
+
fromDomain,
|
|
386
|
+
toName: result.targetName,
|
|
387
|
+
toDomain: result.targetAgentId ? this.db.getAgentById(result.targetAgentId)?.domain ?? undefined : undefined,
|
|
388
|
+
routeType,
|
|
389
|
+
direction: "send",
|
|
390
|
+
payload: JSON.stringify(msg.payload),
|
|
391
|
+
status: result.error ? "failed" : queued ? "queued" : delivered ? "routed" : "no_target",
|
|
392
|
+
groupId: msg.conversation?.groupId,
|
|
393
|
+
source: "ws",
|
|
394
|
+
});
|
|
395
|
+
// Store send timestamp for latency calc on reply
|
|
396
|
+
if (result.targetAgentId) {
|
|
397
|
+
this.sendTimestamps.set(msg.requestId, sendTs);
|
|
398
|
+
}
|
|
399
|
+
this.send(ws, {
|
|
400
|
+
type: "route_result",
|
|
401
|
+
requestId: msg.requestId,
|
|
402
|
+
delivered, queued,
|
|
403
|
+
error: result.error,
|
|
404
|
+
});
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
// ── Reply ───────────────────────────────────────────────────────────
|
|
408
|
+
if (msg.type === "a2a_reply") {
|
|
409
|
+
this.logger.info(`[mesh] Received a2a_reply (non-streaming) for requestId=${msg.requestId}`);
|
|
410
|
+
const { targetId, conversation, conn, fromName, isA2aDirect } = resolveReplyContext(this, msg.requestId, agentId);
|
|
411
|
+
// ── Federation reply branch ─────────────────────────────────────
|
|
412
|
+
// 本地 agent 给一个 federated 请求(来自远端 member / link daemon,经
|
|
413
|
+
// FedDeliver 投到本机)回了消息 → 不走本地 sendToAgent,改通过
|
|
414
|
+
// router.fedReplyHook 把 FedReply 发回协调 master,协调再广播给所有 member,
|
|
415
|
+
// 由发起端的 FedClient.handleReply 解开 pendingRequest。
|
|
416
|
+
if (this.router.isFederatedRequest(msg.requestId)) {
|
|
417
|
+
const replyContent = msg.payload?.message || "";
|
|
418
|
+
this.logger.info(`[mesh] Federated reply for requestId=${msg.requestId} from ${fromName} (${replyContent.length} chars)`);
|
|
419
|
+
this.db.logMessage({
|
|
420
|
+
requestId: msg.requestId,
|
|
421
|
+
fromName,
|
|
422
|
+
fromDomain: conn?.domain,
|
|
423
|
+
toName: undefined,
|
|
424
|
+
toDomain: undefined,
|
|
425
|
+
routeType: "reply",
|
|
426
|
+
direction: "reply",
|
|
427
|
+
payload: JSON.stringify(msg.payload),
|
|
428
|
+
status: "replied",
|
|
429
|
+
latencyMs: this.sendTimestamps.get(msg.requestId) ? Date.now() - this.sendTimestamps.get(msg.requestId) : undefined,
|
|
430
|
+
groupId: conversation?.groupId,
|
|
431
|
+
source: "ws",
|
|
432
|
+
});
|
|
433
|
+
try {
|
|
434
|
+
this.router.fedReplyHook?.(msg.requestId, fromName, { message: replyContent });
|
|
435
|
+
}
|
|
436
|
+
catch (err) {
|
|
437
|
+
this.logger.warn(`[mesh] fedReplyHook error for requestId=${msg.requestId}: ${err.message}`);
|
|
438
|
+
}
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
if (targetId) {
|
|
442
|
+
const targetAgent = this.db.getAgentById(targetId);
|
|
443
|
+
const enrichedConversation = this.enrichGroupConversation(conversation, targetAgent?.name);
|
|
444
|
+
// qaMode:硬剥 @<asker> 防止 asker worker 被回触发(一问一答,不 chatter)
|
|
445
|
+
const qaAsker = this.qaModeAskers.get(msg.requestId);
|
|
446
|
+
let replyContent = msg.payload?.message || "";
|
|
447
|
+
if (qaAsker) {
|
|
448
|
+
replyContent = replyContent.replace(new RegExp(`@${qaAsker}\\b`, "g"), "");
|
|
449
|
+
this.qaModeAskers.delete(msg.requestId);
|
|
450
|
+
}
|
|
451
|
+
const replyPayload = { ...msg.payload, message: replyContent };
|
|
452
|
+
const replyMsg = enrichWorkerDispatch(this, {
|
|
453
|
+
type: "a2a_message",
|
|
454
|
+
requestId: msg.requestId,
|
|
455
|
+
from: { name: fromName, domain: conn?.domain, status: "online" },
|
|
456
|
+
payload: replyPayload,
|
|
457
|
+
routeType: "reply",
|
|
458
|
+
conversation: enrichedConversation,
|
|
459
|
+
}, targetAgent?.name, enrichedConversation?.groupId);
|
|
460
|
+
if (msg.cwd)
|
|
461
|
+
replyMsg.cwd = msg.cwd;
|
|
462
|
+
// Persist to group history BEFORE sending (avoids race with history refresh)
|
|
463
|
+
if ((conversation?.type === "group" || conversation?.type === "single") && conversation.groupId) {
|
|
464
|
+
const msgId = this.db.addGroupMessage(conversation.groupId, fromName, replyContent, []);
|
|
465
|
+
// 提取 mentions 走 bridge 检测(同 sendAsAgent 的 regex)
|
|
466
|
+
const mentions = extractMentions(replyContent);
|
|
467
|
+
this.autoCreateBridgeOnMention(conversation.groupId, fromName, mentions, msgId);
|
|
468
|
+
this.checkAndCancelBridgesForMessage(conversation.groupId, fromName, mentions, msgId);
|
|
469
|
+
// 链接采集(inline hook,失败不影响主路径)
|
|
470
|
+
collectLinksFromText(replyContent, {
|
|
471
|
+
sourceType: "group_message",
|
|
472
|
+
sourceId: String(msgId),
|
|
473
|
+
sourceGroupId: conversation.groupId,
|
|
474
|
+
sourceSender: fromName,
|
|
475
|
+
}, this.db);
|
|
476
|
+
}
|
|
477
|
+
// Group replies: broadcast to all members so everyone sees it in real-time
|
|
478
|
+
// DM replies: send to original sender only
|
|
479
|
+
// 单播群(unicast):跳过 broadcast(消息已入库,asker 通过 history 拉)。
|
|
480
|
+
// 不调 sendToAgent(target=asker)因为 asker 是 CLI 无 WS 连接,
|
|
481
|
+
// 推也推不到 —— 这正是 unicast 的设计:一对一点对点,免打扰。
|
|
482
|
+
if (conversation?.type === "group" && conversation.groupId && !isA2aDirect) {
|
|
483
|
+
this.broadcastToGroup(conversation.groupId, replyMsg, [agentId]);
|
|
484
|
+
}
|
|
485
|
+
else if (conversation?.type !== "group") {
|
|
486
|
+
// DM only — unicast 路径到这里直接落入"什么都不发"分支
|
|
487
|
+
this.sendToAgent(targetId, replyMsg);
|
|
488
|
+
}
|
|
489
|
+
// Log reply with latency. toName 优先用 @ 到的 agent(群回复 @ 了谁就是写给谁),
|
|
490
|
+
// 没提到 agent 才回落到 reply target(原始发送方)。
|
|
491
|
+
const sendTs = this.sendTimestamps.get(msg.requestId);
|
|
492
|
+
const latencyMs = sendTs ? Date.now() - sendTs : undefined;
|
|
493
|
+
const replyMentions = extractMentions(msg.payload?.message);
|
|
494
|
+
const firstMentionedName = replyMentions.find((n) => this.db.getAgentByName(n));
|
|
495
|
+
const logToAgent = firstMentionedName ? this.db.getAgentByName(firstMentionedName) : targetAgent;
|
|
496
|
+
this.db.logMessage({
|
|
497
|
+
requestId: msg.requestId,
|
|
498
|
+
fromName,
|
|
499
|
+
fromDomain: conn?.domain,
|
|
500
|
+
toName: logToAgent?.name,
|
|
501
|
+
toDomain: logToAgent?.domain ?? undefined,
|
|
502
|
+
routeType: "reply",
|
|
503
|
+
direction: "reply",
|
|
504
|
+
payload: JSON.stringify(msg.payload),
|
|
505
|
+
status: "replied",
|
|
506
|
+
latencyMs,
|
|
507
|
+
groupId: conversation?.groupId,
|
|
508
|
+
source: "ws",
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
else {
|
|
512
|
+
this.logger.warn(`[mesh] Reply target not found for requestId=${msg.requestId}`);
|
|
513
|
+
}
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
// ── Streaming reply chunk ──────────────────────────────────────────
|
|
517
|
+
if (msg.type === "a2a_reply_chunk") {
|
|
518
|
+
const { targetId, conversation, conn, fromName, isA2aDirect } = resolveReplyContext(this, msg.requestId, agentId);
|
|
519
|
+
// Federation:chunk 不转发给 link/远端 member(跨 master 流式太重);
|
|
520
|
+
// 最终完整内容随 a2a_reply_end 一次性以 FedReply 发回。
|
|
521
|
+
if (this.router.isFederatedRequest(msg.requestId)) {
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
if (targetId) {
|
|
525
|
+
const chunkMsg = {
|
|
526
|
+
type: "a2a_stream_chunk",
|
|
527
|
+
requestId: msg.requestId,
|
|
528
|
+
from: { name: fromName, domain: conn?.domain, status: "online" },
|
|
529
|
+
delta: msg.delta,
|
|
530
|
+
conversation,
|
|
531
|
+
};
|
|
532
|
+
// Send stream chunk to original sender only (streaming is per-session, no broadcast)
|
|
533
|
+
// 单播群(unicast):跳过广播,asker 通过 history 拉最终内容(中途流式 UI 不可见,
|
|
534
|
+
// 但功能层面不受影响 —— 完整 reply 在 a2a_reply_end 入库)。
|
|
535
|
+
if (conversation?.type === "group" && conversation.groupId && !isA2aDirect) {
|
|
536
|
+
this.broadcastToGroup(conversation.groupId, chunkMsg, [agentId]);
|
|
537
|
+
}
|
|
538
|
+
else if (conversation?.type !== "group") {
|
|
539
|
+
this.sendToAgent(targetId, chunkMsg);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
// ── Streaming reply end ────────────────────────────────────────────
|
|
545
|
+
if (msg.type === "a2a_reply_end") {
|
|
546
|
+
const { targetId, conversation, conn, fromName, isA2aDirect } = resolveReplyContext(this, msg.requestId, agentId);
|
|
547
|
+
// Federation:本地 agent 给一个 federated 请求回完了流式回复 →
|
|
548
|
+
// 把最终完整 payload 通过 fedReplyHook 一次性以 FedReply 发回协调 master。
|
|
549
|
+
// (chunk 阶段已经被 isFederatedRequest 分支丢弃,所以最终内容只在 end 这里。)
|
|
550
|
+
if (this.router.isFederatedRequest(msg.requestId)) {
|
|
551
|
+
const endContent = msg.payload?.message || "";
|
|
552
|
+
this.logger.info(`[mesh] Federated reply-end for requestId=${msg.requestId} from ${fromName} (${endContent.length} chars)`);
|
|
553
|
+
this.db.logMessage({
|
|
554
|
+
requestId: msg.requestId,
|
|
555
|
+
fromName,
|
|
556
|
+
fromDomain: conn?.domain,
|
|
557
|
+
toName: undefined,
|
|
558
|
+
toDomain: undefined,
|
|
559
|
+
routeType: "reply",
|
|
560
|
+
direction: "reply",
|
|
561
|
+
payload: JSON.stringify(msg.payload),
|
|
562
|
+
status: "replied",
|
|
563
|
+
latencyMs: this.sendTimestamps.get(msg.requestId) ? Date.now() - this.sendTimestamps.get(msg.requestId) : undefined,
|
|
564
|
+
groupId: conversation?.groupId,
|
|
565
|
+
source: "ws",
|
|
566
|
+
});
|
|
567
|
+
try {
|
|
568
|
+
this.router.fedReplyHook?.(msg.requestId, fromName, { message: endContent });
|
|
569
|
+
}
|
|
570
|
+
catch (err) {
|
|
571
|
+
this.logger.warn(`[mesh] fedReplyHook error for requestId=${msg.requestId}: ${err.message}`);
|
|
572
|
+
}
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
if (targetId) {
|
|
576
|
+
const cancelled = msg.cancelled === true;
|
|
577
|
+
const endMsg = {
|
|
578
|
+
type: "a2a_stream_end",
|
|
579
|
+
requestId: msg.requestId,
|
|
580
|
+
from: { name: fromName, domain: conn?.domain, status: "online" },
|
|
581
|
+
conversation,
|
|
582
|
+
};
|
|
583
|
+
if (msg.cwd)
|
|
584
|
+
endMsg.cwd = msg.cwd;
|
|
585
|
+
if (cancelled)
|
|
586
|
+
endMsg.cancelled = true;
|
|
587
|
+
// qaMode:硬剥 @<asker> 防止 asker worker 被回触发(一问一答,不 chatter)
|
|
588
|
+
const qaAsker = this.qaModeAskers.get(msg.requestId);
|
|
589
|
+
let endContent = msg.payload?.message || "";
|
|
590
|
+
if (qaAsker) {
|
|
591
|
+
endContent = endContent.replace(new RegExp(`@${qaAsker}\\b`, "g"), "");
|
|
592
|
+
this.qaModeAskers.delete(msg.requestId);
|
|
593
|
+
}
|
|
594
|
+
const endPayload = { ...msg.payload, message: endContent };
|
|
595
|
+
// Persist to group history BEFORE sending (avoids race with history refresh).
|
|
596
|
+
// Cancelled replies still persist their partial content (the user wants
|
|
597
|
+
// to keep what was streamed before the interrupt) but stamp cancelled_at
|
|
598
|
+
// so the dashboard can render the "⏹ 已中断" footer on reload.
|
|
599
|
+
if ((conversation?.type === "group" || conversation?.type === "single") && conversation.groupId) {
|
|
600
|
+
const msgId = this.db.addGroupMessage(conversation.groupId, fromName, endContent, [], cancelled ? { cancelledAt: nowBeijing() } : undefined);
|
|
601
|
+
const mentions = extractMentions(endContent);
|
|
602
|
+
this.autoCreateBridgeOnMention(conversation.groupId, fromName, mentions, msgId);
|
|
603
|
+
this.checkAndCancelBridgesForMessage(conversation.groupId, fromName, mentions, msgId);
|
|
604
|
+
// 链接采集(inline hook,失败不影响主路径)
|
|
605
|
+
collectLinksFromText(endContent, {
|
|
606
|
+
sourceType: "group_message",
|
|
607
|
+
sourceId: String(msgId),
|
|
608
|
+
sourceGroupId: conversation.groupId,
|
|
609
|
+
sourceSender: fromName,
|
|
610
|
+
}, this.db);
|
|
611
|
+
// 把 worker 回传的 composedPrompt 持久化,前端点击消息可直接读出来渲染分层。
|
|
612
|
+
// 中断态也保留(用户可能想看 prompt 排查为何中断),只要 worker 带了就存。
|
|
613
|
+
const cp = msg.composedPrompt;
|
|
614
|
+
if (cp && cp.layers && cp.final) {
|
|
615
|
+
try {
|
|
616
|
+
this.db.addChatMessagePrompt(msgId, JSON.stringify(cp.layers), cp.final, cp.generatedAt ?? nowBeijing(), cp.promptVersion ?? "unknown");
|
|
617
|
+
}
|
|
618
|
+
catch (err) {
|
|
619
|
+
this.logger.warn(`[mesh] Failed to persist composedPrompt for msgId=${msgId}: ${err.message}`);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
// Group stream end: broadcast a2a_message(带完整内容)给群成员,
|
|
624
|
+
// 让其他 agent 的 worker 能处理(worker 只认 a2a_message,不认 a2a_stream_end)。
|
|
625
|
+
// a2a_stream_end 只发给原始 target,用于 Dashboard 流式 UI 收尾。
|
|
626
|
+
// 单播群(unicast):跳过 broadcast,消息已经入库(本 handler 上方 addGroupMessage)。
|
|
627
|
+
if (conversation?.type === "group" && conversation.groupId && !isA2aDirect) {
|
|
628
|
+
const groupMsg = enrichWorkerDispatch(this, {
|
|
629
|
+
type: "a2a_message",
|
|
630
|
+
requestId: msg.requestId,
|
|
631
|
+
from: { name: fromName, domain: conn?.domain, status: "online" },
|
|
632
|
+
payload: endPayload,
|
|
633
|
+
routeType: "reply",
|
|
634
|
+
conversation: this.enrichGroupConversation(conversation),
|
|
635
|
+
}, undefined, conversation.groupId);
|
|
636
|
+
this.broadcastToGroup(conversation.groupId, groupMsg, [agentId]);
|
|
637
|
+
}
|
|
638
|
+
// a2a_stream_end 发给原始 target(发起方 Dashboard 收尾流式 UI)
|
|
639
|
+
this.sendToAgent(targetId, endMsg);
|
|
640
|
+
// Log complete reply with latency. toName 优先用 @ 到的 agent。
|
|
641
|
+
const sendTs = this.sendTimestamps.get(msg.requestId);
|
|
642
|
+
const latencyMs = sendTs ? Date.now() - sendTs : undefined;
|
|
643
|
+
const targetAgent = this.db.getAgentById(targetId);
|
|
644
|
+
const endMentions = extractMentions(msg.payload?.message);
|
|
645
|
+
const firstMentionedName = endMentions.find((n) => this.db.getAgentByName(n));
|
|
646
|
+
const logToAgent = firstMentionedName ? this.db.getAgentByName(firstMentionedName) : targetAgent;
|
|
647
|
+
this.db.logMessage({
|
|
648
|
+
requestId: msg.requestId,
|
|
649
|
+
fromName,
|
|
650
|
+
fromDomain: conn?.domain,
|
|
651
|
+
toName: logToAgent?.name,
|
|
652
|
+
toDomain: logToAgent?.domain ?? undefined,
|
|
653
|
+
routeType: "reply",
|
|
654
|
+
direction: "reply",
|
|
655
|
+
payload: JSON.stringify(msg.payload),
|
|
656
|
+
status: cancelled ? "cancelled" : "replied",
|
|
657
|
+
latencyMs,
|
|
658
|
+
groupId: conversation?.groupId,
|
|
659
|
+
source: "ws",
|
|
660
|
+
});
|
|
661
|
+
this.sendTimestamps.delete(msg.requestId);
|
|
662
|
+
}
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
// ── Update info (description / profile push from agent) ───────────
|
|
666
|
+
if (msg.type === "update_info") {
|
|
667
|
+
if (msg.description) {
|
|
668
|
+
this.db.updateAgentMeta(agentId, { description: msg.description });
|
|
669
|
+
}
|
|
670
|
+
if (msg.profile) {
|
|
671
|
+
this.db.updateAgentMeta(agentId, { profile: JSON.stringify(msg.profile) });
|
|
672
|
+
}
|
|
673
|
+
// Re-broadcast directory so dashboards see the update
|
|
674
|
+
this.broadcastAgentUpdate(agentId);
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
// ── Disconnect (graceful) ──────────────────────────────────────────
|
|
678
|
+
if (msg.type === "disconnect") {
|
|
679
|
+
this.handleDisconnect(agentId, connGeneration, "graceful");
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
// ── Issue update (from executor worker) ──────────────────────────
|
|
683
|
+
if (msg.type === "issue_update") {
|
|
684
|
+
const conn = this.connections.get(agentId);
|
|
685
|
+
if (!conn)
|
|
686
|
+
return;
|
|
687
|
+
const { issueId, status, content, metadata } = msg;
|
|
688
|
+
const issue = this.db.getIssueById(issueId);
|
|
689
|
+
if (!issue)
|
|
690
|
+
return;
|
|
691
|
+
if (status === "in_progress" || status === "completed" || status === "failed" || status === "paused") {
|
|
692
|
+
const extra = {};
|
|
693
|
+
if (status === "completed" && content)
|
|
694
|
+
extra.result = content;
|
|
695
|
+
if (status === "failed" && content)
|
|
696
|
+
extra.errorMessage = content;
|
|
697
|
+
if (metadata?.artifacts)
|
|
698
|
+
extra.artifacts = metadata.artifacts;
|
|
699
|
+
// 续聊用:首次/再次执行结束时 worker 把 cli sessionId 带回来,落到
|
|
700
|
+
// issue 表上,POST /issues/:id/continue 时再读回去 --resume。
|
|
701
|
+
// null = resume failed, clear stale session_id in DB.
|
|
702
|
+
if (metadata?.sessionId !== undefined) {
|
|
703
|
+
extra.sessionId = metadata.sessionId;
|
|
704
|
+
}
|
|
705
|
+
if (typeof metadata?.cliTool === "string" && metadata.cliTool) {
|
|
706
|
+
extra.cliTool = metadata.cliTool;
|
|
707
|
+
}
|
|
708
|
+
// Token usage / model (claude result / codex turn/completed /
|
|
709
|
+
// hermes usage_update)。usage 序列化为 JSON 字符串入库,前端按需解析。
|
|
710
|
+
if (metadata?.usage !== undefined) {
|
|
711
|
+
extra.usage = typeof metadata.usage === "string"
|
|
712
|
+
? metadata.usage
|
|
713
|
+
: JSON.stringify(metadata.usage);
|
|
714
|
+
}
|
|
715
|
+
if (typeof metadata?.model === "string" && metadata.model) {
|
|
716
|
+
extra.model = metadata.model;
|
|
717
|
+
}
|
|
718
|
+
// Don't downgrade a cancelled issue back to anything else — but if it
|
|
719
|
+
// arrived after cancellation, still record the event below.
|
|
720
|
+
if (issue.status !== "cancelled") {
|
|
721
|
+
this.db.updateIssueStatus(issueId, status, Object.keys(extra).length > 0 ? extra : undefined);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
// 提取 composedPrompt 并嵌入 issue_event 的 metadata,前端可像消息气泡一样
|
|
725
|
+
// 点击 🔍 prompt 看分层。
|
|
726
|
+
const cp = msg.composedPrompt;
|
|
727
|
+
const eventMeta = metadata ? { ...metadata } : {};
|
|
728
|
+
if (msg.cwd)
|
|
729
|
+
eventMeta.cwd = msg.cwd;
|
|
730
|
+
if (cp && cp.layers && cp.final) {
|
|
731
|
+
eventMeta.composed_prompt = cp;
|
|
732
|
+
}
|
|
733
|
+
this.db.addIssueEvent({
|
|
734
|
+
issueId,
|
|
735
|
+
eventType: status === "in_progress" ? "progress" :
|
|
736
|
+
status === "completed" ? "completed" :
|
|
737
|
+
status === "failed" ? "failed" :
|
|
738
|
+
status === "paused" ? "paused" : "output",
|
|
739
|
+
agentName: conn.name,
|
|
740
|
+
content: content || "",
|
|
741
|
+
metadata: Object.keys(eventMeta).length > 0 ? eventMeta : undefined,
|
|
742
|
+
});
|
|
743
|
+
// Notify group when issue is completed or failed
|
|
744
|
+
if ((status === "completed" || status === "failed") && issue.group_id) {
|
|
745
|
+
const artifacts = metadata?.artifacts;
|
|
746
|
+
const summary = status === "completed"
|
|
747
|
+
? `✅ Issue 「${issue.title}」已由 ${conn.name} 完成`
|
|
748
|
+
: `❌ Issue 「${issue.title}」执行失败(${conn.name})`;
|
|
749
|
+
const details = artifacts?.length
|
|
750
|
+
? `${summary}\n\n产出文件:${artifacts.join("、")}`
|
|
751
|
+
: summary;
|
|
752
|
+
// Master-proactive announcement → sender=system.
|
|
753
|
+
this.postSystemToGroup(issue.group_id, details);
|
|
754
|
+
}
|
|
755
|
+
this.send(ws, { type: "issue_update_ack", issueId, ok: true });
|
|
756
|
+
this.notifyIssueChanged(issueId, issue.group_id, "event_appended");
|
|
757
|
+
this.logger.info(`[mesh] Issue ${issueId} update: ${status} from ${conn.name}`);
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
// ── Issue todos update (claude-code worker pushes TodoWrite snapshots)
|
|
761
|
+
//
|
|
762
|
+
// 与 issue_update 平行的 side-channel:只更新 issues.latest_todos_json
|
|
763
|
+
// 快照 + 可选追加一条 event_type='todos' 时间线事件,**不动 issue 状态**。
|
|
764
|
+
// todos 内容 hash 去重:相邻两次相同则不重复落 event(避免时间线被同一条
|
|
765
|
+
// todos 反复刷屏);快照列始终覆盖更新,保证 dashboard 取到的总是最新。
|
|
766
|
+
if (msg.type === "issue_todos_update") {
|
|
767
|
+
const conn = this.connections.get(agentId);
|
|
768
|
+
if (!conn)
|
|
769
|
+
return;
|
|
770
|
+
const issue = this.db.getIssueById(msg.issueId);
|
|
771
|
+
if (!issue) {
|
|
772
|
+
this.logger.warn(`[mesh] issue_todos_update for unknown issue ${msg.issueId}`);
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
const todos = Array.isArray(msg.todos) ? msg.todos : [];
|
|
776
|
+
const serialized = JSON.stringify(todos);
|
|
777
|
+
const previousSnapshot = issue.latest_todos_json ?? "";
|
|
778
|
+
const changed = previousSnapshot !== serialized;
|
|
779
|
+
this.db.updateIssueTodos(msg.issueId, todos);
|
|
780
|
+
if (changed) {
|
|
781
|
+
this.db.addIssueEvent({
|
|
782
|
+
issueId: msg.issueId,
|
|
783
|
+
eventType: "todos",
|
|
784
|
+
agentName: conn.name,
|
|
785
|
+
content: "",
|
|
786
|
+
metadata: { todos, count: todos.length },
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
this.notifyIssueChanged(msg.issueId, issue.group_id, "event_appended");
|
|
790
|
+
this.logger.info(`[mesh] Issue ${msg.issueId} todos update from ${conn.name}: ${todos.length} item(s)${changed ? "" : " (no-change skip event)"}`);
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
// 执行过程中 worker 上报累积 token usage(每秒最多 1 次节流后)。
|
|
794
|
+
// **不落 DB**——只在内存转发给订阅了该 issue 详情的 dashboard 客户端,
|
|
795
|
+
// reload 后客户端从 issue.usage(终态 result.usage 落库)拿值。区别于
|
|
796
|
+
// issue_todos_update:todos 要写 DB(覆盖快照 + 时间线),usage 纯流式。
|
|
797
|
+
if (msg.type === "issue_usage_progress") {
|
|
798
|
+
this.sendToIssueSubscribers(msg.issueId, {
|
|
799
|
+
type: "issue_usage_progress",
|
|
800
|
+
issueId: msg.issueId,
|
|
801
|
+
usage: msg.usage,
|
|
802
|
+
});
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
// dashboard 客户端订阅 / 取消订阅某 issue 的实时推送。issue_usage_progress
|
|
806
|
+
// 只推给订阅者,避免给所有群成员打高频流量。重连时客户端必须重发订阅
|
|
807
|
+
// (订阅不跨连接保留,disconnect 时由 unsubscribeAllIssues 清掉)。
|
|
808
|
+
if (msg.type === "subscribe_issue_detail") {
|
|
809
|
+
this.subscribeIssue(msg.issueId, agentId);
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
if (msg.type === "unsubscribe_issue_detail") {
|
|
813
|
+
this.unsubscribeIssue(msg.issueId, agentId);
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
// ── Issue approval request (codex etc. asks for human Accept/Deny) ─
|
|
817
|
+
if (msg.type === "issue_approval_request") {
|
|
818
|
+
const conn = this.connections.get(agentId);
|
|
819
|
+
if (!conn)
|
|
820
|
+
return;
|
|
821
|
+
const issue = this.db.getIssueById(msg.issueId);
|
|
822
|
+
if (!issue) {
|
|
823
|
+
this.logger.warn(`[mesh] approval_request for unknown issue ${msg.issueId}`);
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
this.db.addIssueEvent({
|
|
827
|
+
issueId: msg.issueId,
|
|
828
|
+
eventType: "approval_request",
|
|
829
|
+
agentName: conn.name,
|
|
830
|
+
content: msg.summary,
|
|
831
|
+
metadata: {
|
|
832
|
+
approvalId: msg.approvalId,
|
|
833
|
+
kind: msg.kind,
|
|
834
|
+
command: msg.command,
|
|
835
|
+
cwd: msg.cwd,
|
|
836
|
+
files: msg.files,
|
|
837
|
+
plan: msg.plan,
|
|
838
|
+
diff: msg.diff,
|
|
839
|
+
questions: msg.questions,
|
|
840
|
+
status: "pending",
|
|
841
|
+
requestedBy: conn.name,
|
|
842
|
+
},
|
|
843
|
+
});
|
|
844
|
+
this.notifyIssueChanged(msg.issueId, issue.group_id, "event_appended");
|
|
845
|
+
this.logger.info(`[mesh] Approval requested by ${conn.name} on issue ${msg.issueId} (${msg.kind}, id=${msg.approvalId})`);
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
// ── Session management responses from worker (Master → Executor) ──
|
|
849
|
+
if (msg.type === "session_view_response" || msg.type === "session_delete_response") {
|
|
850
|
+
const pending = this.pendingSessionRequests.get(msg.requestId);
|
|
851
|
+
if (pending) {
|
|
852
|
+
this.pendingSessionRequests.delete(msg.requestId);
|
|
853
|
+
clearTimeout(pending.timer);
|
|
854
|
+
pending.resolve(msg);
|
|
855
|
+
}
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
// ── Session snapshot push (worker → master DB) ───────────────────
|
|
859
|
+
if (msg.type === "session_snapshot") {
|
|
860
|
+
// worker 推它当前所有 active sessions;master upsert 到 DB 持久化。
|
|
861
|
+
// in-memory cache 同步更新(供 /sessions 快速查询 + online 判定)。
|
|
862
|
+
const conn = this.connections.get(agentId);
|
|
863
|
+
const agentName = conn?.name;
|
|
864
|
+
if (agentName) {
|
|
865
|
+
for (const entry of msg.entries) {
|
|
866
|
+
this.db.upsertAgentSession({
|
|
867
|
+
groupId: entry.groupId,
|
|
868
|
+
agentName,
|
|
869
|
+
cliTool: entry.cliTool,
|
|
870
|
+
sessionId: entry.sessionId,
|
|
871
|
+
usage: entry.usage ?? undefined,
|
|
872
|
+
model: entry.model ?? undefined,
|
|
873
|
+
cumulativeCostUsd: entry.cumulativeCostUsd,
|
|
874
|
+
cumulativeInputTokens: entry.cumulativeInputTokens,
|
|
875
|
+
cumulativeOutputTokens: entry.cumulativeOutputTokens,
|
|
876
|
+
cumulativeCacheReadTokens: entry.cumulativeCacheReadTokens,
|
|
877
|
+
cumulativeCacheCreationTokens: entry.cumulativeCacheCreationTokens,
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
this.sessionSnapshots.set(agentId, msg.entries);
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
// ── Session invalidated (worker → master: 标记失效,保留历史) ────
|
|
885
|
+
if (msg.type === "session_invalidated") {
|
|
886
|
+
this.db.invalidateAgentSession(msg.cliTool, msg.groupId, msg.sessionId);
|
|
887
|
+
this.logger.info(`[mesh] Session invalidated: ${msg.cliTool}:${msg.groupId} → ${msg.sessionId}`);
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
});
|
|
891
|
+
ws.on("close", () => {
|
|
892
|
+
clearTimeout(authTimeout);
|
|
893
|
+
if (authenticated) {
|
|
894
|
+
this.handleDisconnect(agentId, connGeneration, "ws_closed");
|
|
895
|
+
}
|
|
896
|
+
});
|
|
897
|
+
ws.on("error", (err) => {
|
|
898
|
+
this.logger.warn(`[mesh] WS error:`, err.stack || err.message);
|
|
899
|
+
});
|
|
900
|
+
},
|
|
901
|
+
handleDisconnect(agentId, generation, reason) {
|
|
902
|
+
const conn = this.connections.get(agentId);
|
|
903
|
+
if (!conn)
|
|
904
|
+
return;
|
|
905
|
+
// Only disconnect if this is the SAME generation (not a newer reconnection)
|
|
906
|
+
if (conn.generation !== generation)
|
|
907
|
+
return;
|
|
908
|
+
this.connections.delete(agentId);
|
|
909
|
+
this.db.setAgentOffline(agentId);
|
|
910
|
+
// Drop the worker's session snapshot so the dashboard doesn't surface
|
|
911
|
+
// stale sessions for an offline executor. The worker re-pushes a fresh
|
|
912
|
+
// snapshot on next auth.
|
|
913
|
+
this.sessionSnapshots.delete(agentId);
|
|
914
|
+
// Clean up issue detail subscriptions so usage pushes don't go to a dead
|
|
915
|
+
// socket. Re-subscribe happens on the next connect (client ws.onopen).
|
|
916
|
+
this.unsubscribeAllIssues(agentId);
|
|
917
|
+
this.broadcastDirectory("leave", {
|
|
918
|
+
name: conn.name,
|
|
919
|
+
domain: conn.domain,
|
|
920
|
+
status: "offline",
|
|
921
|
+
});
|
|
922
|
+
// Close WebSocket if still open
|
|
923
|
+
if (conn.ws.readyState === WebSocket.OPEN) {
|
|
924
|
+
conn.ws.close(1000, reason);
|
|
925
|
+
}
|
|
926
|
+
this.logger.info(`[mesh] ${conn.name} disconnected (${reason})`);
|
|
927
|
+
},
|
|
928
|
+
};
|