@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.
Files changed (189) hide show
  1. package/README.md +417 -0
  2. package/bin/mesh-master.sh +439 -0
  3. package/bin/rotom +29 -0
  4. package/bin/rotom-link.sh +136 -0
  5. package/bin/rotom-send-with-status +57 -0
  6. package/bin/rotom-up.sh +428 -0
  7. package/dist/cli/ask.js +62 -0
  8. package/dist/cli/common.js +321 -0
  9. package/dist/cli/config.js +65 -0
  10. package/dist/cli/directory.js +17 -0
  11. package/dist/cli/executor.js +58 -0
  12. package/dist/cli/fed.js +91 -0
  13. package/dist/cli/group.js +273 -0
  14. package/dist/cli/identity.js +62 -0
  15. package/dist/cli/init.js +268 -0
  16. package/dist/cli/issue.js +202 -0
  17. package/dist/cli/join.js +170 -0
  18. package/dist/cli/link.js +47 -0
  19. package/dist/cli/master.js +51 -0
  20. package/dist/cli/memory.js +307 -0
  21. package/dist/cli/note.js +68 -0
  22. package/dist/cli/repo.js +77 -0
  23. package/dist/cli/rotom.js +277 -0
  24. package/dist/cli/routes.js +118 -0
  25. package/dist/cli/run.js +45 -0
  26. package/dist/cli/schedule.js +237 -0
  27. package/dist/cli/skill.js +173 -0
  28. package/dist/cli/team.js +106 -0
  29. package/dist/executor/claude-code-hook.cjs +80 -0
  30. package/dist/executor/cli-executor.js +8 -0
  31. package/dist/executor/executors/claude-code.js +780 -0
  32. package/dist/executor/executors/codex.js +719 -0
  33. package/dist/executor/executors/hermes-cli.js +855 -0
  34. package/dist/executor/executors/openclaw.js +467 -0
  35. package/dist/executor/executors/pi.js +514 -0
  36. package/dist/executor/index.js +269 -0
  37. package/dist/executor/jsonrpc-transport.js +125 -0
  38. package/dist/executor/process-runner.js +101 -0
  39. package/dist/executor/reasoning-status.js +83 -0
  40. package/dist/executor/repo-cache.js +502 -0
  41. package/dist/executor/session-store.js +188 -0
  42. package/dist/executor/worker-chat.js +257 -0
  43. package/dist/executor/worker-connection.js +89 -0
  44. package/dist/executor/worker-issue.js +264 -0
  45. package/dist/executor/worker.js +877 -0
  46. package/dist/link/pending-requests.js +72 -0
  47. package/dist/link/server.js +233 -0
  48. package/dist/link/visibility-store.js +58 -0
  49. package/dist/master/api/agents.js +333 -0
  50. package/dist/master/api/artifacts.js +271 -0
  51. package/dist/master/api/domains.js +64 -0
  52. package/dist/master/api/groups.js +635 -0
  53. package/dist/master/api/guidance-templates.js +147 -0
  54. package/dist/master/api/index.js +89 -0
  55. package/dist/master/api/issues-patrol.js +172 -0
  56. package/dist/master/api/issues.js +663 -0
  57. package/dist/master/api/links-patrol.js +168 -0
  58. package/dist/master/api/links.js +114 -0
  59. package/dist/master/api/memory.js +259 -0
  60. package/dist/master/api/messages.js +157 -0
  61. package/dist/master/api/notes.js +77 -0
  62. package/dist/master/api/schedule-patterns.js +133 -0
  63. package/dist/master/api/schedules.js +272 -0
  64. package/dist/master/api/sessions.js +158 -0
  65. package/dist/master/api/share.js +269 -0
  66. package/dist/master/api/skills.js +190 -0
  67. package/dist/master/api/teams.js +122 -0
  68. package/dist/master/api/uploads.js +245 -0
  69. package/dist/master/auth.js +134 -0
  70. package/dist/master/dashboard/animations/calico-dozing.apng +0 -0
  71. package/dist/master/dashboard/animations/calico-error.apng +0 -0
  72. package/dist/master/dashboard/animations/calico-happy.apng +0 -0
  73. package/dist/master/dashboard/animations/calico-notification.apng +0 -0
  74. package/dist/master/dashboard/animations/calico-sleeping.apng +0 -0
  75. package/dist/master/dashboard/animations/calico-thinking.apng +0 -0
  76. package/dist/master/dashboard/animations/calico-waking.apng +0 -0
  77. package/dist/master/dashboard/assets/ApprovalCard-C38VV6ko.css +1 -0
  78. package/dist/master/dashboard/assets/ApprovalCard-CHPh2dmE.js +17 -0
  79. package/dist/master/dashboard/assets/ArtifactPanel-P_2gAP7v.js +1 -0
  80. package/dist/master/dashboard/assets/ArtifactPanel-aGHySny5.css +1 -0
  81. package/dist/master/dashboard/assets/css.worker-DaIe3gwK.js +84 -0
  82. package/dist/master/dashboard/assets/editor.worker-BCzxt1at.js +12 -0
  83. package/dist/master/dashboard/assets/html.worker-CKrFyw_2.js +461 -0
  84. package/dist/master/dashboard/assets/index-CChrTn81.css +32 -0
  85. package/dist/master/dashboard/assets/index-Dhu4SN1z.js +181 -0
  86. package/dist/master/dashboard/assets/json.worker-B7c_PmGb.js +49 -0
  87. package/dist/master/dashboard/assets/markdown-CeN5IgdF.js +29 -0
  88. package/dist/master/dashboard/assets/monaco-core-DyX1CsEw.css +1 -0
  89. package/dist/master/dashboard/assets/monaco-core-oQiQUisy.js +833 -0
  90. package/dist/master/dashboard/assets/monaco-setup-CiOPQdmo.js +1 -0
  91. package/dist/master/dashboard/assets/react-vendor-C8IxlyCR.js +67 -0
  92. package/dist/master/dashboard/assets/ts.worker-BhkL8olL.js +51334 -0
  93. package/dist/master/dashboard/assets/useMonaco-ILb4vyPh.js +12 -0
  94. package/dist/master/dashboard/assets/vite-preload-CxJPbCTl.js +1 -0
  95. package/dist/master/dashboard/debug-auth.html +197 -0
  96. package/dist/master/dashboard/favicon.ico +0 -0
  97. package/dist/master/dashboard/index.html +20 -0
  98. package/dist/master/dashboard/rotom-avatar.png +0 -0
  99. package/dist/master/db/agent-sessions.js +60 -0
  100. package/dist/master/db/agent-visibility.js +64 -0
  101. package/dist/master/db/agents.js +119 -0
  102. package/dist/master/db/ask-bridges.js +157 -0
  103. package/dist/master/db/build-update.js +59 -0
  104. package/dist/master/db/core.js +82 -0
  105. package/dist/master/db/domains.js +80 -0
  106. package/dist/master/db/groups.js +316 -0
  107. package/dist/master/db/guidance-templates.js +58 -0
  108. package/dist/master/db/index.js +12 -0
  109. package/dist/master/db/internal.js +45 -0
  110. package/dist/master/db/issues-patrol.js +81 -0
  111. package/dist/master/db/issues.js +373 -0
  112. package/dist/master/db/links.js +221 -0
  113. package/dist/master/db/master-node.js +43 -0
  114. package/dist/master/db/memory.js +272 -0
  115. package/dist/master/db/messages.js +210 -0
  116. package/dist/master/db/notes.js +55 -0
  117. package/dist/master/db/schedule-patterns.js +56 -0
  118. package/dist/master/db/schedules.js +135 -0
  119. package/dist/master/db/skills.js +144 -0
  120. package/dist/master/db/team.js +88 -0
  121. package/dist/master/db/types.js +10 -0
  122. package/dist/master/db.js +12 -0
  123. package/dist/master/embedded.js +133 -0
  124. package/dist/master/federation/client.js +283 -0
  125. package/dist/master/federation/identity.js +133 -0
  126. package/dist/master/federation/manager.js +267 -0
  127. package/dist/master/federation/publisher.js +87 -0
  128. package/dist/master/federation/self-publisher.js +69 -0
  129. package/dist/master/federation/server.js +487 -0
  130. package/dist/master/group-paths.js +208 -0
  131. package/dist/master/offline-queue.js +38 -0
  132. package/dist/master/opc-bootstrap.js +245 -0
  133. package/dist/master/patrol-terminal.js +275 -0
  134. package/dist/master/repo-scan.js +188 -0
  135. package/dist/master/router.js +214 -0
  136. package/dist/master/scheduler-handlers.js +510 -0
  137. package/dist/master/scheduler.js +201 -0
  138. package/dist/master/server.js +203 -0
  139. package/dist/master/services/link-collector.js +82 -0
  140. package/dist/master/services/link-patrol-bootstrap.js +50 -0
  141. package/dist/master/services/memory-extract-prompt.js +34 -0
  142. package/dist/master/services/patrol-bootstrap.js +63 -0
  143. package/dist/master/share-tokens.js +56 -0
  144. package/dist/master/terminal-hub.js +300 -0
  145. package/dist/master/uploads.js +108 -0
  146. package/dist/master/util/fs.js +100 -0
  147. package/dist/master/util/paths.js +50 -0
  148. package/dist/master/util/persona.js +10 -0
  149. package/dist/master/ws-hub/connection.js +928 -0
  150. package/dist/master/ws-hub/conversation.js +290 -0
  151. package/dist/master/ws-hub/directory.js +70 -0
  152. package/dist/master/ws-hub/dispatch-enrich.js +34 -0
  153. package/dist/master/ws-hub/hub.js +136 -0
  154. package/dist/master/ws-hub/index.js +9 -0
  155. package/dist/master/ws-hub/internal.js +35 -0
  156. package/dist/master/ws-hub/routing.js +295 -0
  157. package/dist/master/ws-hub/sessions.js +130 -0
  158. package/dist/master/ws-hub.js +11 -0
  159. package/dist/shared/agent-profile.js +44 -0
  160. package/dist/shared/constants.js +55 -0
  161. package/dist/shared/dedup.js +33 -0
  162. package/dist/shared/group-context.js +62 -0
  163. package/dist/shared/json-codec.js +33 -0
  164. package/dist/shared/logger.js +136 -0
  165. package/dist/shared/mention.js +22 -0
  166. package/dist/shared/network.js +40 -0
  167. package/dist/shared/parse.js +18 -0
  168. package/dist/shared/prompt-composer.js +171 -0
  169. package/dist/shared/protocol/client-messages.js +8 -0
  170. package/dist/shared/protocol/enums.js +6 -0
  171. package/dist/shared/protocol/federation.js +62 -0
  172. package/dist/shared/protocol/guards.js +87 -0
  173. package/dist/shared/protocol/server-messages.js +8 -0
  174. package/dist/shared/protocol/types.js +8 -0
  175. package/dist/shared/protocol.js +19 -0
  176. package/dist/shared/readonly-allowlist.js +122 -0
  177. package/dist/shared/rotom-cli-prompt.js +23 -0
  178. package/dist/shared/skill-context.js +19 -0
  179. package/dist/shared/skill-md.js +43 -0
  180. package/dist/shared/slash-commands.js +50 -0
  181. package/dist/shared/time.js +80 -0
  182. package/dist/shared/title.js +46 -0
  183. package/dist/shared/url-extractor.js +99 -0
  184. package/migrations/001-schema.sql +942 -0
  185. package/package.json +68 -0
  186. package/scripts/fix-node-pty-perms.mjs +46 -0
  187. package/skill/rotom-a2a-communicate/SKILL.md +257 -0
  188. package/skill/rotom-bus-host/SKILL.md +78 -0
  189. package/skill/rotom-bus-host/scripts/poll-replies.sh +148 -0
@@ -0,0 +1,188 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createLogger } from "../shared/logger.js";
4
+ const log = createLogger("mesh-executor-session-store", { stream: "stderr" });
5
+ /**
6
+ * In-memory registry of conversation sessions per group per CLI.
7
+ *
8
+ * Persistence moved to master DB (`agent_sessions` table). On startup the
9
+ * worker receives a `session_sync_push` from master with all its active
10
+ * sessions; mutations are pushed back via `session_snapshot`. The old
11
+ * `~/.rotom/sessions.json` file is gone — master is the single source of
12
+ * truth, which fixes the multi-worker flush-overwrite bug and lets the
13
+ * dashboard surface full session history (including invalidated ones).
14
+ *
15
+ * Key format: `${cliTool}:${groupId}` → StoredSession
16
+ */
17
+ export class SessionStore {
18
+ sessions = new Map();
19
+ /** Populate from master's session_sync_push on startup. Merges with any
20
+ * existing in-memory state (e.g. legacy backfill) — master entries only
21
+ * fill in (cliTool, groupId) pairs the store doesn't already have. */
22
+ hydrate(entries) {
23
+ let added = 0;
24
+ for (const e of entries) {
25
+ const k = this.key(e.cliTool, e.groupId);
26
+ if (this.sessions.has(k))
27
+ continue;
28
+ const stored = { sessionId: e.sessionId };
29
+ if (e.usage)
30
+ stored.usage = e.usage;
31
+ if (e.model)
32
+ stored.model = e.model;
33
+ if (typeof e.cumulativeCostUsd === "number")
34
+ stored.cumulativeCostUsd = e.cumulativeCostUsd;
35
+ if (typeof e.cumulativeInputTokens === "number")
36
+ stored.cumulativeInputTokens = e.cumulativeInputTokens;
37
+ if (typeof e.cumulativeOutputTokens === "number")
38
+ stored.cumulativeOutputTokens = e.cumulativeOutputTokens;
39
+ if (typeof e.cumulativeCacheReadTokens === "number")
40
+ stored.cumulativeCacheReadTokens = e.cumulativeCacheReadTokens;
41
+ if (typeof e.cumulativeCacheCreationTokens === "number")
42
+ stored.cumulativeCacheCreationTokens = e.cumulativeCacheCreationTokens;
43
+ this.sessions.set(k, stored);
44
+ added++;
45
+ }
46
+ if (added > 0) {
47
+ log.info(`Hydrated ${added} session(s) from master (of ${entries.length} pushed)`);
48
+ }
49
+ }
50
+ /**
51
+ * One-time migration: read the legacy `~/.rotom/sessions.json` file and
52
+ * populate the in-memory store. The file is deleted after reading so
53
+ * subsequent starts don't re-backfill (which would overwrite newer DB
54
+ * state). Safe to call multiple times — no-op if the file is gone.
55
+ *
56
+ * Called once from executor index.ts after constructing the shared
57
+ * SessionStore. After this, master DB is the source of truth.
58
+ */
59
+ backfillFromLegacyJson(rotomHome) {
60
+ const file = path.join(rotomHome, "sessions.json");
61
+ let raw;
62
+ try {
63
+ raw = fs.readFileSync(file, "utf-8");
64
+ }
65
+ catch {
66
+ return; // file gone or never existed — normal path after first migration
67
+ }
68
+ try {
69
+ const data = JSON.parse(raw);
70
+ let count = 0;
71
+ for (const [k, v] of Object.entries(data)) {
72
+ const sep = k.indexOf(":");
73
+ if (sep === -1)
74
+ continue;
75
+ const cliTool = k.slice(0, sep);
76
+ const groupId = k.slice(sep + 1);
77
+ if (typeof v === "string") {
78
+ this.sessions.set(k, { sessionId: v });
79
+ count++;
80
+ }
81
+ else if (v && typeof v === "object" && typeof v.sessionId === "string") {
82
+ const obj = v;
83
+ const stored = { sessionId: obj.sessionId };
84
+ if (obj.usage)
85
+ stored.usage = obj.usage;
86
+ if (obj.model)
87
+ stored.model = obj.model;
88
+ if (typeof obj.cumulativeCostUsd === "number")
89
+ stored.cumulativeCostUsd = obj.cumulativeCostUsd;
90
+ this.sessions.set(k, stored);
91
+ count++;
92
+ }
93
+ }
94
+ log.info(`Backfilled ${count} session(s) from legacy ${file}`);
95
+ // Delete the file so we never re-backfill (would clobber DB state).
96
+ try {
97
+ fs.unlinkSync(file);
98
+ log.info(`Removed legacy ${file}`);
99
+ }
100
+ catch (err) {
101
+ log.warn(`Failed to remove legacy ${file}: ${err.message}`);
102
+ }
103
+ }
104
+ catch (err) {
105
+ log.warn(`Failed to parse legacy ${file}: ${err.message} (leaving file in place)`);
106
+ }
107
+ }
108
+ key(cliTool, groupId) {
109
+ return `${cliTool}:${groupId}`;
110
+ }
111
+ get(cliTool, groupId) {
112
+ return this.sessions.get(this.key(cliTool, groupId))?.sessionId;
113
+ }
114
+ set(cliTool, groupId, sessionId) {
115
+ // Preserve existing usage/model when the sessionId is being refreshed
116
+ // (e.g. a new chat turn returned the same sessionId). If the sessionId
117
+ // truly changed, clear stale usage — the new session has no turns yet.
118
+ const k = this.key(cliTool, groupId);
119
+ const existing = this.sessions.get(k);
120
+ if (existing && existing.sessionId === sessionId) {
121
+ this.sessions.set(k, { ...existing, sessionId });
122
+ }
123
+ else {
124
+ this.sessions.set(k, { sessionId });
125
+ }
126
+ }
127
+ /** Record the latest usage/model captured from the CLI backend for this
128
+ * session. No-op if no session exists for (cliTool, groupId). */
129
+ recordUsage(cliTool, groupId, usage, model) {
130
+ const k = this.key(cliTool, groupId);
131
+ const existing = this.sessions.get(k);
132
+ if (!existing)
133
+ return;
134
+ // Only update if there's something to record — avoids bumping state
135
+ // on every chat turn that reports nothing.
136
+ if (!usage && !model)
137
+ return;
138
+ // 累加 cost + tokens:每次 turn 的值加到 cumulative 字段。
139
+ // usage 本身仍整体覆盖(保留"最近一 turn 用量"语义,tooltip 用)。
140
+ const turnCost = typeof usage?.totalCostUsd === "number" ? usage.totalCostUsd : 0;
141
+ const turnIn = typeof usage?.inputTokens === "number" ? usage.inputTokens : 0;
142
+ const turnOut = typeof usage?.outputTokens === "number" ? usage.outputTokens : 0;
143
+ const turnCacheRead = typeof usage?.cacheReadTokens === "number" ? usage.cacheReadTokens : 0;
144
+ const turnCacheCreation = typeof usage?.cacheCreationTokens === "number" ? usage.cacheCreationTokens : 0;
145
+ this.sessions.set(k, {
146
+ ...existing,
147
+ ...(usage ? { usage } : {}),
148
+ ...(model ? { model } : {}),
149
+ cumulativeCostUsd: (existing.cumulativeCostUsd ?? 0) + turnCost,
150
+ cumulativeInputTokens: (existing.cumulativeInputTokens ?? 0) + turnIn,
151
+ cumulativeOutputTokens: (existing.cumulativeOutputTokens ?? 0) + turnOut,
152
+ cumulativeCacheReadTokens: (existing.cumulativeCacheReadTokens ?? 0) + turnCacheRead,
153
+ cumulativeCacheCreationTokens: (existing.cumulativeCacheCreationTokens ?? 0) + turnCacheCreation,
154
+ });
155
+ }
156
+ delete(cliTool, groupId) {
157
+ this.sessions.delete(this.key(cliTool, groupId));
158
+ }
159
+ has(cliTool, groupId, sessionId) {
160
+ return this.sessions.get(this.key(cliTool, groupId))?.sessionId === sessionId;
161
+ }
162
+ /**
163
+ * Return every entry in the store, parsed from the `${cliTool}:${groupId}`
164
+ * keys. Used by the worker's session_snapshot push so master can persist
165
+ * to DB.
166
+ */
167
+ listAll() {
168
+ const out = [];
169
+ for (const [k, stored] of this.sessions) {
170
+ const sep = k.indexOf(":");
171
+ if (sep === -1)
172
+ continue;
173
+ out.push({
174
+ cliTool: k.slice(0, sep),
175
+ groupId: k.slice(sep + 1),
176
+ sessionId: stored.sessionId,
177
+ ...(stored.usage ? { usage: stored.usage } : {}),
178
+ ...(stored.model ? { model: stored.model } : {}),
179
+ ...(typeof stored.cumulativeCostUsd === "number" ? { cumulativeCostUsd: stored.cumulativeCostUsd } : {}),
180
+ ...(typeof stored.cumulativeInputTokens === "number" ? { cumulativeInputTokens: stored.cumulativeInputTokens } : {}),
181
+ ...(typeof stored.cumulativeOutputTokens === "number" ? { cumulativeOutputTokens: stored.cumulativeOutputTokens } : {}),
182
+ ...(typeof stored.cumulativeCacheReadTokens === "number" ? { cumulativeCacheReadTokens: stored.cumulativeCacheReadTokens } : {}),
183
+ ...(typeof stored.cumulativeCacheCreationTokens === "number" ? { cumulativeCacheCreationTokens: stored.cumulativeCacheCreationTokens } : {}),
184
+ });
185
+ }
186
+ return out;
187
+ }
188
+ }
@@ -0,0 +1,257 @@
1
+ /**
2
+ * ChatHandler — group chat replies for ExecutorWorker.
3
+ *
4
+ * handleChatReply serves @mention / DM turns; the activeTasks key is
5
+ * `chat:${requestId}` so the WS router's `chat_cancelled` branch can find it.
6
+ * Shares session/usage bookkeeping via the worker's SessionStore.
7
+ */
8
+ import { composePrompt } from "../shared/prompt-composer.js";
9
+ import { createLogger } from "../shared/logger.js";
10
+ const log = createLogger("mesh-executor-worker-chat", { stream: "stderr" });
11
+ export class ChatHandler {
12
+ worker;
13
+ /** 同群 chat 队列:groupId → 待处理消息队列。同群串行,保证 session 不丢。 */
14
+ groupChatQueues = new Map();
15
+ groupChatActive = new Set();
16
+ constructor(worker) {
17
+ this.worker = worker;
18
+ }
19
+ async handleChatReply(requestId, content, fromName, conversation, cwdOverride, repoCtx) {
20
+ const taskKey = `chat:${requestId}`;
21
+ if (this.worker.activeTasks.has(taskKey))
22
+ return;
23
+ const groupId = conversation?.id ?? conversation?.groupId ?? "";
24
+ // 同群串行:若该群已有活跃 chat 任务,排队等当前任务结束(session 已存)再处理。
25
+ // 避免新 chat 开新 session 丢失上下文(ask-bridge 复述到达时 A 的原始 turn 可能还没结束)。
26
+ if (groupId && this.groupChatActive.has(groupId)) {
27
+ const queue = this.groupChatQueues.get(groupId) ?? [];
28
+ queue.push({ requestId, content, fromName, conversation, cwdOverride, repoCtx });
29
+ this.groupChatQueues.set(groupId, queue);
30
+ log.info(this.worker.tag, `Chat from ${fromName} queued for group ${groupId} (queue=${queue.length})`);
31
+ return;
32
+ }
33
+ await this.runChatReply(requestId, content, fromName, conversation, cwdOverride, groupId, undefined, repoCtx);
34
+ }
35
+ async runChatReply(requestId, content, fromName, conversation, cwdOverride, groupId, mergedSiblings, repoCtx) {
36
+ const taskKey = `chat:${requestId}`;
37
+ // cwd 优先用 master 推送(Dashboard 群工作目录 / per-agent override);
38
+ // 本机不存在或未推送时回落本地派生(<workingDirMap[groupId]> 或 <base>/<groupId>)。
39
+ // 旧的 conversation.workingDir 仍忽略(那是展示元数据,与 spawn 无关)。
40
+ // group 配了 repo 且同机时 master 在 a2a_message 里下发 repoCtx,这里走 group 模式
41
+ // 共享 worktree(<groupDir>/repos/primary/),让 chat 也能查 repo 代码。
42
+ const resolveChatCwd = async () => this.worker.resolveIssueCwd(groupId || undefined, cwdOverride, repoCtx);
43
+ if (this.worker.activeTasks.size >= this.worker.maxConcurrent) {
44
+ this.worker.sendChatEnd(requestId, `[系统] 当前任务繁忙,请稍后再试`, conversation, await resolveChatCwd());
45
+ return;
46
+ }
47
+ const controller = new AbortController();
48
+ const task = { aborted: false, controller };
49
+ this.worker.activeTasks.set(taskKey, task);
50
+ if (groupId)
51
+ this.groupChatActive.add(groupId);
52
+ const body = content.replace(`@${this.worker.config.name}`, "").trim();
53
+ if (!body) {
54
+ this.worker.activeTasks.delete(taskKey);
55
+ this.dequeueNextChat(groupId);
56
+ this.worker.sendChatEnd(requestId, "你好,有什么可以帮你的?", conversation, await resolveChatCwd());
57
+ return;
58
+ }
59
+ // Resolve session for this group
60
+ const sessionId = groupId ? this.worker.sessions.get(this.worker.cliTool, groupId) : undefined;
61
+ const cwd = await resolveChatCwd();
62
+ // 拼 prompt:rotom-cli → agent-role → group-basic → cwd → task。
63
+ // group 信息从 conversation 抽出(master 已 enrich 过 activeIssues / groupName)。
64
+ // fromName 告诉 agent 这条消息是谁发的,避免 agent 不知道对话方身份。
65
+ const composed = composePrompt({
66
+ mode: "chat",
67
+ agentName: this.worker.config.name,
68
+ agentProfile: this.worker.agentProfile,
69
+ group: conversation?.groupId
70
+ ? {
71
+ id: conversation.groupId,
72
+ name: conversation.groupName || conversation.groupId,
73
+ activeIssues: conversation.activeIssues ?? [],
74
+ guidancePrompt: conversation.guidancePrompt ?? null,
75
+ memoryCounts: conversation.memoryCounts,
76
+ skillCount: conversation.skillCount,
77
+ }
78
+ : null,
79
+ cwd,
80
+ fromName: fromName || null,
81
+ body,
82
+ });
83
+ log.info(this.worker.tag, `Session lookup: cliTool=${this.worker.cliTool}, groupId=${groupId}, sessionId=${sessionId ?? "(none)"}, conversation=${JSON.stringify(conversation)}`);
84
+ log.info(this.worker.tag, `Replying to ${fromName}: ${composed.final.slice(0, 60)}...`);
85
+ // 合并 turn:把合并用的 composedPrompt 挂到 sibling 气泡上,让 dashboard
86
+ // "查看 prompt"在每个被合并的 bubble 都能打开(否则 sibling 只有一条系统
87
+ // 文案,hasPrompt=false,按钮不出现)。sibling 的 loading bubble 也由此关闭。
88
+ if (mergedSiblings && mergedSiblings.length > 0) {
89
+ for (const sib of mergedSiblings) {
90
+ this.worker.sendChatEnd(sib.requestId, "[系统] 已合并到下一条回复", sib.conversation, undefined, composed);
91
+ }
92
+ }
93
+ // 提到 try 块外:catch 路径下(子进程被 SIGTERM 后某些 executor 会 throw)
94
+ // 仍要拿着已积累的 partial content 走 cancelled 终态,否则传空字符串给
95
+ // master 会把前端已经看到的流式内容覆盖成空。
96
+ let fullContent = "";
97
+ try {
98
+ // Chat replies (DM + @-mention in groups) intentionally do NOT pass
99
+ // onApprovalRequest. Rationale:
100
+ // • Conversational tool calls should feel snappy — pausing for a
101
+ // human Accept/Deny breaks the chat UX.
102
+ // • Codex chat sessions are resumed by sessionId; a denied tool call
103
+ // leaves an "assistant tool_calls without matching tool message"
104
+ // hole in the conversation history, which makes the NEXT chat turn
105
+ // fail with `invalid_request_error: An assistant message with
106
+ // 'tool_calls' must be followed by tool messages…`.
107
+ // File writes here still need a backing in-progress issue (the prompt
108
+ // tells the agent so — see composePrompt group-basic active_issues block).
109
+ const execOptions = {
110
+ signal: controller.signal,
111
+ env: this.worker.agentEnv(),
112
+ kind: "chat",
113
+ // 2-minute hard wall-clock cap on chat replies. Without this a
114
+ // hanging openclaw subprocess can tie up the worker's
115
+ // activeTasks slot until the user gives up and the daemon
116
+ // restarts. Executors pass this through to `--timeout` AND set
117
+ // a defensive SIGKILL after a small grace.
118
+ timeoutMs: 120_000,
119
+ };
120
+ if (sessionId)
121
+ execOptions.sessionId = sessionId;
122
+ // cwd 按 groupId 派生
123
+ const result = await this.worker.executor.execute(composed.final, cwd, (chunk) => {
124
+ if (task.aborted)
125
+ return;
126
+ fullContent += chunk;
127
+ this.worker.sendChatChunk(requestId, chunk);
128
+ }, execOptions);
129
+ // Drop the cached sessionId if the executor reports the conversation
130
+ // history is poisoned (e.g. dangling tool_calls, or a terminal
131
+ // provider error — see HermesCliExecutor's provider error sniffer).
132
+ // Next chat turn will start fresh instead of trying to resume into
133
+ // a broken transcript.
134
+ //
135
+ // 中断态不视为 poison —— codex 的 turn_aborted 走自己的清理路径,
136
+ // session 可以正常续聊。只有 invalidateSession=true 且非用户主动中断
137
+ // 时才丢弃 sessionId。
138
+ if (groupId && result.invalidateSession && !task.aborted) {
139
+ // 失效前抓住 sessionId,通知 master 在 DB 里打 invalidated_at 戳(保留历史)。
140
+ const invalidatedSessionId = sessionId;
141
+ this.worker.sessions.delete(this.worker.cliTool, groupId);
142
+ log.warn(this.worker.tag, `Session invalidated: ${this.worker.cliTool}:${groupId}` +
143
+ (result.failed ? " (provider error)" : " (poisoned history)"));
144
+ if (invalidatedSessionId) {
145
+ this.worker.send({
146
+ type: "session_invalidated",
147
+ cliTool: this.worker.cliTool,
148
+ groupId,
149
+ sessionId: invalidatedSessionId,
150
+ });
151
+ }
152
+ this.worker.sendSessionSnapshot();
153
+ }
154
+ else {
155
+ // Persist sessionId for future messages in this group. Even when
156
+ // result.sessionId is absent (some backends only return it on the
157
+ // first turn), the existing session is still valid — record usage
158
+ // so the Debug view can show this chat session's own token cost.
159
+ if (groupId && result.sessionId) {
160
+ this.worker.sessions.set(this.worker.cliTool, groupId, result.sessionId);
161
+ log.info(this.worker.tag, `Session stored: ${this.worker.cliTool}:${groupId} → ${result.sessionId}`);
162
+ }
163
+ if (groupId && (result.usage || result.model)) {
164
+ this.worker.sessions.recordUsage(this.worker.cliTool, groupId, result.usage, result.model);
165
+ // Snapshot push is needed so master picks up the new usage/model.
166
+ // Coalesce with the set() push above by always sending here when
167
+ // we recorded anything.
168
+ this.worker.sendSessionSnapshot();
169
+ }
170
+ else if (groupId && result.sessionId) {
171
+ this.worker.sendSessionSnapshot();
172
+ }
173
+ }
174
+ if (task.aborted) {
175
+ // 用户中断:已积累的 partial content 落库(走 master 的 cancelled_at 路径),
176
+ // bubble 切到「已中断」状态。不暴露 executor 返回的 aborted 错误文案 ——
177
+ // 用户自己点的中断,不需要再看"turn was aborted" 之类的内部噪声。
178
+ this.worker.sendChatEnd(requestId, fullContent, conversation, cwd, undefined, { cancelled: true });
179
+ log.info(this.worker.tag, `Reply cancelled mid-stream to ${fromName} (kept ${fullContent.length} chars)`);
180
+ }
181
+ else {
182
+ // Provider-error path: executor detected a terminal model failure
183
+ // (e.g. hermes's "API call failed after N retries: …" reply, which
184
+ // is not a legitimate assistant message). Surface it as a clean
185
+ // [错误] notice instead of streaming the error string as the
186
+ // agent's "answer". The dashboard's status pill is already on
187
+ // "Failed" from the executor's [status:Failed] emit.
188
+ if (result.failed) {
189
+ const reason = result.errorMessage || "unknown provider error";
190
+ this.worker.sendChatEnd(requestId, `[错误] 模型调用失败:${reason}\n(已清空会话上下文,下一条消息将重新开始)`, conversation, cwd, composed);
191
+ log.error(this.worker.tag, `Provider error surfaced to ${fromName}: ${reason}`);
192
+ }
193
+ else {
194
+ this.worker.sendChatEnd(requestId, fullContent, conversation, cwd, composed);
195
+ log.info(this.worker.tag, `Reply sent to ${fromName} (${fullContent.length} chars)`);
196
+ }
197
+ }
198
+ }
199
+ catch (err) {
200
+ if (task.aborted) {
201
+ // 子进程被 SIGTERM/SIGKILL 时 executor 可能 throw(SIGNAL error),
202
+ // 这是用户主动取消的预期结果,走 cancelled 终态而不是 error。
203
+ this.worker.sendChatEnd(requestId, fullContent, conversation, cwd, undefined, { cancelled: true });
204
+ log.info(this.worker.tag, `Reply cancelled (executor threw on abort) to ${fromName} (kept ${fullContent.length} chars)`);
205
+ }
206
+ else {
207
+ this.worker.sendChatEnd(requestId, `[错误] ${err.message}`, conversation, cwd, composed);
208
+ log.error(this.worker.tag, "Reply error:", err.message);
209
+ }
210
+ }
211
+ finally {
212
+ this.worker.activeTasks.delete(taskKey);
213
+ this.dequeueNextChat(groupId);
214
+ }
215
+ }
216
+ /** 当前群 chat 任务结束,从队列取下一条处理。session 已存,新任务能复用。
217
+ * 积压合并:队列里有 ≥2 条待处理时,取最多 MAX_MERGE=3 条合并成一次 turn
218
+ * (首条 requestId 作主回复流,其余发系统文案 bubble 关闭 loading),省 LLM 调用。
219
+ */
220
+ dequeueNextChat(groupId) {
221
+ if (!groupId)
222
+ return;
223
+ this.groupChatActive.delete(groupId);
224
+ const queue = this.groupChatQueues.get(groupId);
225
+ if (!queue || queue.length === 0) {
226
+ this.groupChatQueues.delete(groupId);
227
+ return;
228
+ }
229
+ const MAX_MERGE = 3;
230
+ const batch = queue.splice(0, Math.min(MAX_MERGE, queue.length));
231
+ if (queue.length === 0)
232
+ this.groupChatQueues.delete(groupId);
233
+ if (batch.length === 1) {
234
+ const n = batch[0];
235
+ log.info(this.worker.tag, `Dequeue chat from ${n.fromName} for group ${groupId} (remaining=${queue.length})`);
236
+ this.runChatReply(n.requestId, n.content, n.fromName, n.conversation, n.cwdOverride, groupId, undefined, n.repoCtx).catch((err) => {
237
+ log.error(this.worker.tag, "Dequeued chat error:", err.message);
238
+ });
239
+ return;
240
+ }
241
+ // 合并:首条作主 requestId 流式回复,其余 sibling 在 runChatReply compose 完后
242
+ // 用同一份 composedPrompt 关闭 loading(让 dashboard 每个 sibling 都能查看 prompt)。
243
+ const primary = batch[0];
244
+ const mergedNames = batch.map((b) => b.fromName).join(", ");
245
+ const siblings = batch.slice(1).map((b) => ({ requestId: b.requestId, conversation: b.conversation }));
246
+ // 合并 body:每条标 [from=X] 让 agent 区分发送者;fromName 传 null 避免 composePrompt
247
+ // 再加单 sender 头(多 sender 已在 body 内标注)。
248
+ const mentionTag = `@${this.worker.config.name}`;
249
+ const mergedBody = batch
250
+ .map((b) => `[from=${b.fromName}]\n${b.content.replace(mentionTag, "").trim()}`)
251
+ .join("\n\n---\n\n");
252
+ log.info(this.worker.tag, `Dequeue merged chat for group ${groupId} (merged=${batch.length}, from=[${mergedNames}], remaining=${queue.length})`);
253
+ this.runChatReply(primary.requestId, mergedBody, null, primary.conversation, primary.cwdOverride, groupId, siblings, primary.repoCtx).catch((err) => {
254
+ log.error(this.worker.tag, "Dequeued merged chat error:", err.message);
255
+ });
256
+ }
257
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * WorkerConnection — WS lifecycle for ExecutorWorker.
3
+ *
4
+ * Owns heartbeat + reconnect timers. The underlying `ws` socket lives on the
5
+ * worker (shared with send helpers), this module just wires connect/reconnect
6
+ * and routes incoming messages back to `worker.handleMessage`.
7
+ */
8
+ import { WebSocket } from "ws";
9
+ import os from "node:os";
10
+ import { randomUUID } from "node:crypto";
11
+ import { decodeJson } from "../shared/json-codec.js";
12
+ import { createLogger } from "../shared/logger.js";
13
+ const log = createLogger("mesh-executor-worker-connection", { stream: "stderr" });
14
+ export class WorkerConnection {
15
+ worker;
16
+ heartbeatTimer = null;
17
+ reconnectTimer = null;
18
+ constructor(worker) {
19
+ this.worker = worker;
20
+ }
21
+ start() {
22
+ this.worker.stopped = false;
23
+ this.connect();
24
+ }
25
+ stop() {
26
+ this.worker.stopped = true;
27
+ if (this.heartbeatTimer)
28
+ clearInterval(this.heartbeatTimer);
29
+ if (this.reconnectTimer)
30
+ clearTimeout(this.reconnectTimer);
31
+ if (this.worker.ws)
32
+ this.worker.ws.close(1000, "shutdown");
33
+ }
34
+ /** Called from handleMessage on auth_ok. Starts the 10s heartbeat loop. */
35
+ startHeartbeat() {
36
+ this.heartbeatTimer = setInterval(() => {
37
+ if (this.worker.ws?.readyState === WebSocket.OPEN) {
38
+ this.worker.ws.send(JSON.stringify({ type: "heartbeat" }));
39
+ }
40
+ }, 10_000);
41
+ }
42
+ wsUrl() {
43
+ let url = this.worker.masterUrl;
44
+ if (!url.endsWith("/ws"))
45
+ url += "/ws";
46
+ return url;
47
+ }
48
+ connect() {
49
+ if (this.worker.stopped)
50
+ return;
51
+ const url = this.wsUrl();
52
+ const cliName = this.worker.config.cliTool || "auto";
53
+ log.info(this.worker.tag, "Connecting to", url, `(cli: ${cliName}, cwd: ${this.worker.workingDir})`);
54
+ this.worker.ws = new WebSocket(url);
55
+ this.worker.ws.on("open", () => {
56
+ this.worker.ws.send(JSON.stringify({
57
+ type: "auth",
58
+ name: this.worker.config.name,
59
+ token: this.worker.config.token,
60
+ version: 2,
61
+ profile: this.worker.config.profile || {},
62
+ cliTool: this.worker.cliTool,
63
+ instance: {
64
+ instanceId: `${os.hostname()}-${process.pid}-${randomUUID()}`,
65
+ hostname: os.hostname(),
66
+ platform: `${process.platform} ${process.arch}`,
67
+ endpoint: this.worker.masterUrl,
68
+ },
69
+ }));
70
+ });
71
+ this.worker.ws.on("message", (raw) => {
72
+ const msg = decodeJson(raw);
73
+ if (!msg)
74
+ return;
75
+ this.worker.handleMessage(msg);
76
+ });
77
+ this.worker.ws.on("close", () => {
78
+ log.info(this.worker.tag, "Disconnected, reconnecting in 3s...");
79
+ if (this.heartbeatTimer)
80
+ clearInterval(this.heartbeatTimer);
81
+ if (!this.worker.stopped) {
82
+ this.reconnectTimer = setTimeout(() => this.connect(), 3_000);
83
+ }
84
+ });
85
+ this.worker.ws.on("error", (err) => {
86
+ log.error(this.worker.tag, "WS error:", err.message);
87
+ });
88
+ }
89
+ }