@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,635 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import fs from "node:fs";
5
+ import { spawnSync } from "node:child_process";
6
+ import { defaultGroupWorkingDir } from "../group-paths.js";
7
+ import { scanAllRepos, resolveGroupWorktreeInfo } from "../repo-scan.js";
8
+ import { createLogger } from "../../shared/logger.js";
9
+ import { parseAgentProfile, mergeGroupProfile } from "../../shared/agent-profile.js";
10
+ import { extractMentions } from "../../shared/mention.js";
11
+ import { validateWorkingDir } from "../util/paths.js";
12
+ import { bootstrapPatrolGroup } from "../services/patrol-bootstrap.js";
13
+ import { bootstrapLinkPatrolGroup } from "../services/link-patrol-bootstrap.js";
14
+ import { collectLinksFromText } from "../services/link-collector.js";
15
+ const log = createLogger("mesh-api");
16
+ function ensureDir(p) {
17
+ fs.mkdirSync(p, { recursive: true });
18
+ }
19
+ export function registerGroupRoutes(apiRouter, db, _auth, hub) {
20
+ // Backfill legacy groups with no working_dir
21
+ {
22
+ const filled = db.backfillGroupDefaultWorkingDir(defaultGroupWorkingDir);
23
+ for (const { workingDir } of filled) {
24
+ try {
25
+ ensureDir(workingDir);
26
+ }
27
+ catch (err) {
28
+ log.warn(`Backfill mkdir failed for ${workingDir}: ${err?.code ?? err?.message ?? err}`);
29
+ }
30
+ }
31
+ if (filled.length > 0) {
32
+ log.info(`Backfilled working_dir for ${filled.length} legacy group(s)`);
33
+ }
34
+ }
35
+ apiRouter.get("/groups", (_req, res) => {
36
+ res.json(db.listGroupsWithMembers());
37
+ });
38
+ apiRouter.post("/groups", (req, res) => {
39
+ const { name, memberNames, workingDir, type } = req.body;
40
+ if (!name || typeof name !== "string" || !name.trim()) {
41
+ res.status(400).json({ error: "name is required" });
42
+ return;
43
+ }
44
+ // 巡检群:全局限 1 个未归档 + 仅 1 个 agent(除 creator 外)
45
+ if (type === "patrol") {
46
+ const existing = db.listGroupsByType("patrol").filter((g) => g.archived_at == null);
47
+ if (existing.length > 0) {
48
+ res.status(400).json({
49
+ error: `已存在未归档的巡检群 "${existing[0].name}",需归档或删除后才能再建`,
50
+ });
51
+ return;
52
+ }
53
+ const picked = Array.isArray(memberNames) ? memberNames.filter((n) => typeof n === "string" && !!n.trim()) : [];
54
+ if (picked.length !== 1) {
55
+ res.status(400).json({ error: "巡检群限选 1 个 agent(该 agent 即巡检员)" });
56
+ return;
57
+ }
58
+ }
59
+ // 链接分类巡检群(type=patrol-link):同 patrol 约束,但独立计数(允许和 patrol 共存)
60
+ if (type === "patrol-link") {
61
+ const existing = db.listGroupsByType("patrol-link").filter((g) => g.archived_at == null);
62
+ if (existing.length > 0) {
63
+ res.status(400).json({
64
+ error: `已存在未归档的链接分类巡检群 "${existing[0].name}",需归档或删除后才能再建`,
65
+ });
66
+ return;
67
+ }
68
+ const picked = Array.isArray(memberNames) ? memberNames.filter((n) => typeof n === "string" && !!n.trim()) : [];
69
+ if (picked.length !== 1) {
70
+ res.status(400).json({ error: "链接分类巡检群限选 1 个 agent(该 agent 即巡检员)" });
71
+ return;
72
+ }
73
+ }
74
+ // 单播群(unicast):消息不广播、worker 不被消息自动唤醒,只通过 CLI
75
+ // --need-reply 显式点名叫醒对方回话。建群 ≥2 成员,不限上限。
76
+ if (type === "a2a_direct") {
77
+ const picked = Array.isArray(memberNames) ? memberNames.filter((n) => typeof n === "string" && !!n.trim()) : [];
78
+ if (picked.length < 2) {
79
+ res.status(400).json({ error: "单播群至少需要 2 个成员" });
80
+ return;
81
+ }
82
+ if (new Set(picked).size !== picked.length) {
83
+ res.status(400).json({ error: "单播群成员不能重复" });
84
+ return;
85
+ }
86
+ }
87
+ const id = randomUUID();
88
+ let workDir;
89
+ if (typeof workingDir === "string" && workingDir.trim()) {
90
+ const v = validateWorkingDir(workingDir);
91
+ if (!v.ok) {
92
+ res.status(400).json({ error: v.error });
93
+ return;
94
+ }
95
+ workDir = v.path;
96
+ }
97
+ else {
98
+ workDir = defaultGroupWorkingDir(id);
99
+ try {
100
+ ensureDir(workDir);
101
+ }
102
+ catch (err) {
103
+ res.status(500).json({ error: `创建默认工作目录失败: ${workDir} (${err?.code ?? err?.message ?? "unknown"})` });
104
+ return;
105
+ }
106
+ }
107
+ if (type && typeof type === "string") {
108
+ db.createGroupTyped({ id, name: name.trim(), type, workingDir: workDir });
109
+ }
110
+ else {
111
+ db.createGroup(id, name.trim(), undefined, workDir);
112
+ }
113
+ if (Array.isArray(memberNames) && memberNames.length > 0) {
114
+ db.addGroupMembers(id, memberNames);
115
+ }
116
+ log.info(`Group created: "${name.trim()}" (${id}) type=${type || "default"} cwd=${workDir}`);
117
+ // 巡检群:建群后自动建 issue-patrol 定时任务 + 绑定规则 skill
118
+ if (type === "patrol") {
119
+ const patrolAgentName = (Array.isArray(memberNames) ? memberNames : []).find((n) => typeof n === "string" && !!n.trim());
120
+ if (patrolAgentName) {
121
+ bootstrapPatrolGroup(db, log, id, patrolAgentName);
122
+ }
123
+ }
124
+ // 链接分类巡检群:建群后自动建 link-patrol 定时任务 + 绑定 link-patrol-rules skill
125
+ if (type === "patrol-link") {
126
+ const patrolAgentName = (Array.isArray(memberNames) ? memberNames : []).find((n) => typeof n === "string" && !!n.trim());
127
+ if (patrolAgentName) {
128
+ bootstrapLinkPatrolGroup(db, log, id, patrolAgentName);
129
+ }
130
+ }
131
+ res.status(201).json({ id, name: name.trim(), working_dir: workDir, type: type || null, memberCount: Array.isArray(memberNames) ? memberNames.length : 0 });
132
+ });
133
+ apiRouter.patch("/groups/:id", (req, res) => {
134
+ const group = db.getGroupById(req.params.id);
135
+ if (!group) {
136
+ res.status(404).json({ error: "Group not found" });
137
+ return;
138
+ }
139
+ const { name, workingDir, pinned, archived, starred, guidancePrompt, repoUrl, repoDefaultBranch, extraRepos, worktreeMode } = req.body;
140
+ if (name !== undefined && name !== null) {
141
+ db.updateGroupName(req.params.id, String(name));
142
+ log.info(`Group ${req.params.id} name → ${name}`);
143
+ }
144
+ if (workingDir === undefined && name === undefined && pinned === undefined && archived === undefined && starred === undefined && guidancePrompt === undefined && repoUrl === undefined && repoDefaultBranch === undefined && extraRepos === undefined && worktreeMode === undefined) {
145
+ res.status(400).json({ error: "no updatable fields" });
146
+ return;
147
+ }
148
+ if (pinned !== undefined) {
149
+ const next = db.updateGroupPinned(req.params.id, Boolean(pinned));
150
+ log.info(`Group ${req.params.id} pinned_at → ${next ?? "null"}`);
151
+ }
152
+ if (archived !== undefined) {
153
+ const next = db.updateGroupArchived(req.params.id, Boolean(archived));
154
+ log.info(`Group ${req.params.id} archived_at → ${next ?? "null"}`);
155
+ }
156
+ if (starred !== undefined) {
157
+ const next = db.updateGroupStarred(req.params.id, Boolean(starred));
158
+ log.info(`Group ${req.params.id} starred_at → ${next ?? "null"}`);
159
+ }
160
+ if (guidancePrompt !== undefined) {
161
+ const v = typeof guidancePrompt === "string" ? guidancePrompt : null;
162
+ db.updateGroupGuidancePrompt(req.params.id, v);
163
+ log.info(`Group ${req.params.id} guidance_prompt → ${v ? `(${v.length} chars)` : "(cleared)"}`);
164
+ }
165
+ // 内置 repo(migration 051):三列独立 patch。任一字段 undefined 时不改动该列;
166
+ // 显式传 null/空串则清空。extraRepos 接收数组或字符串(JSON),统一规整成 JSON 字符串存库。
167
+ if (repoUrl !== undefined || repoDefaultBranch !== undefined || extraRepos !== undefined) {
168
+ const url = typeof repoUrl === "string" ? repoUrl.trim() : null;
169
+ const branch = typeof repoDefaultBranch === "string" ? repoDefaultBranch.trim() : null;
170
+ let extraJson = null;
171
+ if (extraRepos !== undefined && extraRepos !== null) {
172
+ let arr;
173
+ if (typeof extraRepos === "string") {
174
+ try {
175
+ arr = JSON.parse(extraRepos);
176
+ }
177
+ catch {
178
+ res.status(400).json({ error: "extraRepos 不是合法 JSON" });
179
+ return;
180
+ }
181
+ }
182
+ else {
183
+ arr = extraRepos;
184
+ }
185
+ if (!Array.isArray(arr)) {
186
+ res.status(400).json({ error: "extraRepos 必须是数组" });
187
+ return;
188
+ }
189
+ const cleaned = arr.filter((e) => !!e && typeof e === "object"
190
+ && typeof e.id === "string" && e.id
191
+ && typeof e.url === "string" && e.url
192
+ && typeof e.mountPath === "string" && e.mountPath);
193
+ extraJson = cleaned.length > 0 ? JSON.stringify(cleaned) : null;
194
+ }
195
+ db.updateGroupRepo(req.params.id, url, branch, extraJson, typeof worktreeMode === "string" ? worktreeMode : null);
196
+ log.info(`Group ${req.params.id} repo → url=${url || "(cleared)"} branch=${branch || "(default)"} extras=${extraJson ? `(${JSON.parse(extraJson).length})` : "(none)"} mode=${worktreeMode === "issue" ? "issue" : "group"}`);
197
+ }
198
+ if (workingDir !== undefined) {
199
+ let next;
200
+ if (typeof workingDir === "string" && workingDir.trim()) {
201
+ const v = validateWorkingDir(workingDir);
202
+ if (!v.ok) {
203
+ res.status(400).json({ error: v.error });
204
+ return;
205
+ }
206
+ next = v.path;
207
+ }
208
+ else {
209
+ next = defaultGroupWorkingDir(req.params.id);
210
+ try {
211
+ ensureDir(next);
212
+ }
213
+ catch (err) {
214
+ res.status(500).json({ error: `创建默认工作目录失败: ${next} (${err?.code ?? err?.message ?? "unknown"})` });
215
+ return;
216
+ }
217
+ }
218
+ db.updateGroupWorkingDir(req.params.id, next);
219
+ log.info(`Group ${req.params.id} working_dir → ${next}`);
220
+ }
221
+ res.json({ ok: true });
222
+ });
223
+ apiRouter.get("/groups/:id", (req, res) => {
224
+ const group = db.getGroupById(req.params.id);
225
+ if (!group) {
226
+ res.status(404).json({ error: "Group not found" });
227
+ return;
228
+ }
229
+ const members = db.getGroupMembers(req.params.id).map((m) => {
230
+ const agent = db.getAgentByName(m.agent_name);
231
+ const base = agent?.profile ? parseAgentProfile(agent.profile) : null;
232
+ const override = parseAgentProfile(m.profile);
233
+ const effective = mergeGroupProfile(base, override);
234
+ return {
235
+ agent_name: m.agent_name,
236
+ joined_at: m.joined_at,
237
+ status: agent?.status ?? "offline",
238
+ profile: effective,
239
+ };
240
+ });
241
+ res.json({ ...group, members });
242
+ });
243
+ apiRouter.delete("/groups/:id", (req, res) => {
244
+ const group = db.getGroupById(req.params.id);
245
+ if (!group) {
246
+ res.status(404).json({ error: "Group not found" });
247
+ return;
248
+ }
249
+ db.deleteGroup(req.params.id);
250
+ log.info(`Group deleted: "${group.name}" (${req.params.id})`);
251
+ res.json({ ok: true });
252
+ });
253
+ apiRouter.post("/groups/:id/members", (req, res) => {
254
+ const group = db.getGroupById(req.params.id);
255
+ if (!group) {
256
+ res.status(404).json({ error: "Group not found" });
257
+ return;
258
+ }
259
+ if (group.archived_at) {
260
+ res.status(403).json({ error: "Group is archived, cannot modify members" });
261
+ return;
262
+ }
263
+ const { agentNames } = req.body;
264
+ if (!Array.isArray(agentNames) || agentNames.length === 0) {
265
+ res.status(400).json({ error: "agentNames array is required" });
266
+ return;
267
+ }
268
+ db.addGroupMembers(req.params.id, agentNames);
269
+ res.json({ ok: true });
270
+ });
271
+ apiRouter.delete("/groups/:id/members", (req, res) => {
272
+ const group = db.getGroupById(req.params.id);
273
+ if (!group) {
274
+ res.status(404).json({ error: "Group not found" });
275
+ return;
276
+ }
277
+ if (group.archived_at) {
278
+ res.status(403).json({ error: "Group is archived, cannot modify members" });
279
+ return;
280
+ }
281
+ const { agentNames } = req.body;
282
+ if (!Array.isArray(agentNames) || agentNames.length === 0) {
283
+ res.status(400).json({ error: "agentNames array is required" });
284
+ return;
285
+ }
286
+ db.removeGroupMembers(req.params.id, agentNames);
287
+ res.json({ ok: true });
288
+ });
289
+ // Set or update the per-(group, agent) working_dir override.
290
+ // Body: { workingDir: "<absolute path>" }
291
+ apiRouter.put("/groups/:id/members/:agentName/working-dir", (req, res) => {
292
+ const group = db.getGroupById(req.params.id);
293
+ if (!group) {
294
+ res.status(404).json({ error: "Group not found" });
295
+ return;
296
+ }
297
+ if (group.archived_at) {
298
+ res.status(403).json({ error: "Group is archived, cannot modify settings" });
299
+ return;
300
+ }
301
+ const agentName = String(req.params.agentName);
302
+ const members = db.getGroupMembers(req.params.id);
303
+ if (!members.some(m => m.agent_name === agentName)) {
304
+ res.status(404).json({ error: `Agent "${agentName}" is not a member of this group` });
305
+ return;
306
+ }
307
+ const v = validateWorkingDir(req.body?.workingDir);
308
+ if (!v.ok) {
309
+ res.status(400).json({ error: v.error });
310
+ return;
311
+ }
312
+ db.upsertGroupMemberSetting(req.params.id, agentName, v.path);
313
+ log.info(`Group ${req.params.id} member ${agentName} working_dir → ${v.path}`);
314
+ res.json({ ok: true, working_dir: v.path });
315
+ });
316
+ // Clear the per-(group, agent) working_dir override. Falls back to
317
+ // group.working_dir for resolution.
318
+ apiRouter.delete("/groups/:id/members/:agentName/working-dir", (req, res) => {
319
+ const group = db.getGroupById(req.params.id);
320
+ if (!group) {
321
+ res.status(404).json({ error: "Group not found" });
322
+ return;
323
+ }
324
+ if (group.archived_at) {
325
+ res.status(403).json({ error: "Group is archived, cannot modify settings" });
326
+ return;
327
+ }
328
+ const agentName = String(req.params.agentName);
329
+ const members = db.getGroupMembers(req.params.id);
330
+ if (!members.some(m => m.agent_name === agentName)) {
331
+ res.status(404).json({ error: `Agent "${agentName}" is not a member of this group` });
332
+ return;
333
+ }
334
+ const removed = db.clearGroupMemberSetting(req.params.id, agentName);
335
+ log.info(`Group ${req.params.id} member ${agentName} working_dir cleared (removed=${removed})`);
336
+ res.json({ ok: true, removed });
337
+ });
338
+ // Set or update the per-(group, agent) profile override.
339
+ // Body: { position?, bio?, category? } — fields with undefined/null are
340
+ // dropped from the override (not stored). An empty body clears the override.
341
+ // Stored as JSON in group_member_settings.profile; dispatch-enrich merges it
342
+ // onto the agent's global profile (group-level fields win).
343
+ apiRouter.put("/groups/:id/members/:agentName/profile", (req, res) => {
344
+ const group = db.getGroupById(req.params.id);
345
+ if (!group) {
346
+ res.status(404).json({ error: "Group not found" });
347
+ return;
348
+ }
349
+ if (group.archived_at) {
350
+ res.status(403).json({ error: "Group is archived, cannot modify settings" });
351
+ return;
352
+ }
353
+ const agentName = String(req.params.agentName);
354
+ const members = db.getGroupMembers(req.params.id);
355
+ if (!members.some(m => m.agent_name === agentName)) {
356
+ res.status(404).json({ error: `Agent "${agentName}" is not a member of this group` });
357
+ return;
358
+ }
359
+ const body = req.body ?? {};
360
+ const profile = {};
361
+ if (typeof body.position === "string" && body.position.trim())
362
+ profile.position = body.position.trim();
363
+ if (typeof body.bio === "string" && body.bio.trim())
364
+ profile.bio = body.bio.trim();
365
+ if (typeof body.category === "string" && body.category.trim())
366
+ profile.category = body.category.trim();
367
+ const profileJson = Object.keys(profile).length > 0 ? JSON.stringify(profile) : null;
368
+ db.upsertGroupMemberProfile(req.params.id, agentName, profileJson);
369
+ log.info(`Group ${req.params.id} member ${agentName} profile → ${profileJson ?? "(cleared)"}`);
370
+ res.json({ ok: true, profile });
371
+ });
372
+ apiRouter.get("/groups/:id/messages", (req, res) => {
373
+ const group = db.getGroupById(req.params.id);
374
+ if (!group) {
375
+ res.status(404).json({ error: "Group not found" });
376
+ return;
377
+ }
378
+ // since=<ISO>:按时间过滤(轮询用),不走 head/tail 截断。
379
+ // 接受 UTC ISO(带 Z)或北京时间字符串(无 Z),字符串字典序比较都生效。
380
+ if (typeof req.query.since === "string" && req.query.since.trim()) {
381
+ res.json(db.getGroupMessagesSince(req.params.id, req.query.since.trim()));
382
+ return;
383
+ }
384
+ // 新签名是 (headKeep, tailKeep)。?limit= 仍接受,当作 head+tail 总预算
385
+ // 分配(head 固定 5);不带 limit 时走 db 层默认 head=5 / tail=295。
386
+ if (Object.prototype.hasOwnProperty.call(req.query, "limit")) {
387
+ const total = Math.min(parseInt(req.query.limit) || 300, 500);
388
+ res.json(db.getGroupMessages(req.params.id, 5, Math.max(total - 5, 0)));
389
+ }
390
+ else {
391
+ res.json(db.getGroupMessages(req.params.id));
392
+ }
393
+ });
394
+ apiRouter.post("/groups/:id/messages", (req, res) => {
395
+ const group = db.getGroupById(req.params.id);
396
+ if (!group) {
397
+ res.status(404).json({ error: "Group not found" });
398
+ return;
399
+ }
400
+ if (group.archived_at) {
401
+ res.status(403).json({ error: "Group is archived, cannot send messages" });
402
+ return;
403
+ }
404
+ const { sender, content, mentions } = req.body;
405
+ if (!sender || !content) {
406
+ res.status(400).json({ error: "sender and content are required" });
407
+ return;
408
+ }
409
+ // 解析 mentions:优先用 body 里的,否则从 content 抽(同 ws-hub.ts:390 正则)
410
+ const resolvedMentions = Array.isArray(mentions) && mentions.length > 0
411
+ ? mentions
412
+ : extractMentions(content);
413
+ const msgId = db.addGroupMessage(req.params.id, sender, content, resolvedMentions);
414
+ // 链接采集(inline hook,失败不影响主路径)
415
+ try {
416
+ collectLinksFromText(content, {
417
+ sourceType: "group_message",
418
+ sourceId: String(msgId),
419
+ sourceGroupId: req.params.id,
420
+ sourceSender: sender,
421
+ }, db);
422
+ }
423
+ catch (err) {
424
+ log.warn(`POST /groups/:id/messages: collectLinksFromText failed: ${err?.message ?? err}`);
425
+ }
426
+ // ── 真人发群消息:广播给所有群成员 ─────────────────────────────
427
+ // 行为对齐 ws-hub.ts:462-465 (a2a_reply 对群消息做的 broadcastToGroup)。
428
+ if (hub) {
429
+ const senderAgent = db.getAgentByName(sender);
430
+ if (!senderAgent) {
431
+ // 兜底:sender 不是注册 agent,DB 已入库但 WS 不广播,仍 200。
432
+ log.warn(`POST /groups/:id/messages: sender "${sender}" not registered; skip broadcast`);
433
+ }
434
+ else {
435
+ // 兜底:真人不在 group_members 时补 addMembers(防"自激丢消息" +
436
+ // "多 tab 真人看不到自己的消息")。INSERT OR IGNORE 幂等。
437
+ const members = db.getGroupMembers(req.params.id);
438
+ if (!members.some((m) => m.agent_name === sender)) {
439
+ db.addGroupMembers(req.params.id, [sender]);
440
+ log.info(`POST /groups/:id/messages: auto-joined sender "${sender}" as group member`);
441
+ }
442
+ const wireMsg = {
443
+ type: "a2a_message",
444
+ requestId: `grp_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`,
445
+ from: { name: sender, domain: senderAgent.domain || undefined, status: "online" },
446
+ payload: { message: content },
447
+ routeType: "exact",
448
+ conversation: { type: "group", groupId: req.params.id, groupName: group.name },
449
+ };
450
+ // 排除 @mentioned agent:这些目标会由 Dashboard 后续 a2a_send 直投,
451
+ // 这里再广播一次会导致目标 agent 重复处理(同 ws-hub.ts:445 行为)。
452
+ const mentionAgentIds = resolvedMentions
453
+ .map((name) => db.getAgentByName(name)?.id)
454
+ .filter((id) => !!id);
455
+ hub.broadcastToGroupPublic(req.params.id, wireMsg, [senderAgent.id, ...mentionAgentIds]);
456
+ }
457
+ // 对齐 ws-hub a2a_reply 路径(connection.ts:403-404):
458
+ // 真人 @ 回复也要走 bridge 检测,否则 pending bridge 不会在入库时 mark answered,
459
+ // 20s 后 handler 兜底会再发一条 system 复述,跟真人原始 @ 重复(见群 34dd5eee)。
460
+ hub.autoCreateBridgeOnMention(req.params.id, sender, resolvedMentions, msgId);
461
+ hub.checkAndCancelBridgesForMessage(req.params.id, sender, resolvedMentions, msgId);
462
+ }
463
+ db.logMessage({
464
+ requestId: `grp_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`,
465
+ fromName: sender,
466
+ direction: "send",
467
+ payload: JSON.stringify({ message: content, mentions: resolvedMentions, groupName: group.name }),
468
+ status: "group_message",
469
+ groupId: group.id,
470
+ source: "api",
471
+ });
472
+ const issueMatch = content.match(/\[ISSUE\]\s*(.+?)(?:\n([\s\S]*))?$/);
473
+ if (issueMatch) {
474
+ const issueId = randomUUID();
475
+ const issueTitle = issueMatch[1].trim();
476
+ const issueDesc = issueMatch[2]?.trim() || "";
477
+ db.createIssue({
478
+ id: issueId,
479
+ groupId: req.params.id,
480
+ title: issueTitle,
481
+ description: issueDesc,
482
+ createdBy: sender,
483
+ });
484
+ log.info(`Issue auto-created from message: "${issueTitle}" (${issueId})`);
485
+ if (hub) {
486
+ hub.notifyIssueChanged(issueId, req.params.id, "created");
487
+ }
488
+ }
489
+ res.status(201).json({ ok: true });
490
+ });
491
+ // Real persons (agents with category "真人")
492
+ apiRouter.get("/real-persons", (_req, res) => {
493
+ const allAgents = db.listAgents();
494
+ const realPersonAgents = allAgents
495
+ .filter(a => {
496
+ try {
497
+ const profile = a.profile ? JSON.parse(a.profile) : {};
498
+ return profile.category === "真人";
499
+ }
500
+ catch {
501
+ return false;
502
+ }
503
+ })
504
+ .map(a => ({ name: a.name, id: a.id }));
505
+ res.json(realPersonAgents);
506
+ });
507
+ // Cross-domain rules
508
+ apiRouter.get("/cross-domain", (_req, res) => {
509
+ const rules = db.listCrossDomainRules();
510
+ const domains = db.listDomains().map(d => d.name);
511
+ res.json({ rules, domains });
512
+ });
513
+ apiRouter.post("/cross-domain", (req, res) => {
514
+ const { from, to, bidirectional } = req.body;
515
+ if (!from || !to) {
516
+ res.status(400).json({ error: "from and to required" });
517
+ return;
518
+ }
519
+ if (from === to) {
520
+ res.status(400).json({ error: "from and to must be different" });
521
+ return;
522
+ }
523
+ try {
524
+ db.addCrossDomainRule(from, to, bidirectional === true);
525
+ res.json({ ok: true });
526
+ }
527
+ catch (e) {
528
+ res.status(409).json({ error: e.message });
529
+ }
530
+ });
531
+ apiRouter.delete("/cross-domain", (req, res) => {
532
+ const { from, to } = req.body;
533
+ if (!from || !to) {
534
+ res.status(400).json({ error: "from and to required" });
535
+ return;
536
+ }
537
+ const deleted = db.deleteCrossDomainRule(from, to);
538
+ res.json({ ok: true, deleted });
539
+ });
540
+ /** GET /api/groups/:id/asks?status=pending —— 列出群里的 bridge */
541
+ apiRouter.get("/groups/:id/asks", (req, res) => {
542
+ const status = typeof req.query.status === "string" ? req.query.status : undefined;
543
+ const bridges = db.listAskBridges({ groupId: req.params.id, status });
544
+ res.json(bridges);
545
+ });
546
+ /** GET /api/asks/:id —— 单条 bridge 详情 */
547
+ apiRouter.get("/asks/:id", (req, res) => {
548
+ const bridge = db.getAskBridge(req.params.id);
549
+ if (!bridge) {
550
+ res.status(404).json({ error: "Bridge not found" });
551
+ return;
552
+ }
553
+ res.json(bridge);
554
+ });
555
+ /** POST /api/asks/:id/cancel —— A 主动 cancel(收到非@回复,自己判断是回复了) */
556
+ apiRouter.post("/asks/:id/cancel", (req, res) => {
557
+ const ok = db.cancelBridge(req.params.id);
558
+ if (!ok) {
559
+ res.status(409).json({ error: "Bridge not pending (already resolved)" });
560
+ return;
561
+ }
562
+ log.info(`Ask bridge cancelled: ${req.params.id}`);
563
+ res.json({ ok: true });
564
+ });
565
+ // ── Worktree 视图(供 Dashboard 展示)───────────────────────────────────
566
+ /** GET /api/groups/:id/worktree —— 当前 group 的 worktree 推算信息。
567
+ * 返回 primary + extras 的路径(本机 FS 是否已存在)。没配 repo → 404。 */
568
+ apiRouter.get("/groups/:id/worktree", (req, res) => {
569
+ const info = resolveGroupWorktreeInfo(db, req.params.id);
570
+ if (!info) {
571
+ res.status(404).json({ error: "group has no repo configured" });
572
+ return;
573
+ }
574
+ res.json(info);
575
+ });
576
+ /** GET /api/repos/worktrees —— 全局所有 repo + worktree 列表(工具箱视图用)。
577
+ * 扫描本机 ~/.rotom/repos/,跨机器部署时返回空。 */
578
+ apiRouter.get("/repos/worktrees", (_req, res) => {
579
+ res.json(scanAllRepos());
580
+ });
581
+ /** DELETE /api/repos/worktrees —— 删除指定 worktree(孤儿清理)。
582
+ * body: { path: "<worktree 绝对路径>" }。只删 worktree,bare clone 保留。
583
+ * 路径必须在 ~/.rotom/repos/ 下,防止误删。 */
584
+ apiRouter.delete("/repos/worktrees", (req, res) => {
585
+ const wtPath = typeof req.body?.path === "string" ? req.body.path : "";
586
+ if (!wtPath) {
587
+ res.status(400).json({ error: "path required" });
588
+ return;
589
+ }
590
+ const reposRoot = path.join(os.homedir(), ".rotom", "repos");
591
+ const resolved = path.resolve(wtPath);
592
+ if (!resolved.startsWith(reposRoot + path.sep)) {
593
+ res.status(403).json({ error: "path must be under ~/.rotom/repos/" });
594
+ return;
595
+ }
596
+ if (!fs.existsSync(resolved)) {
597
+ res.json({ ok: true, note: "already gone" });
598
+ return;
599
+ }
600
+ // 找 bare clone(worktree 的 gitdir 指向它),用 git worktree remove
601
+ const gitdirFile = path.join(resolved, ".git");
602
+ let barePath = null;
603
+ try {
604
+ if (fs.existsSync(gitdirFile)) {
605
+ const content = fs.readFileSync(gitdirFile, "utf-8").trim();
606
+ const m = content.match(/^gitdir:\s*(.+)$/);
607
+ if (m) {
608
+ // m[1] 形如 /Users/.../repos/<repo>.git/worktrees/<slot>
609
+ // bare 是 .../repos/<repo>.git
610
+ const wtMeta = path.resolve(resolved, m[1]);
611
+ barePath = path.resolve(wtMeta, "../.."); // 上两级到 .git
612
+ }
613
+ }
614
+ }
615
+ catch { /* ignore */ }
616
+ if (barePath && fs.existsSync(barePath)) {
617
+ try {
618
+ const r = spawnSync("git", ["worktree", "remove", "--force", resolved], { cwd: barePath, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] });
619
+ if (r.status !== 0) {
620
+ // git 拒绝(可能 worktree 有改动),兜底物理删
621
+ fs.rmSync(resolved, { recursive: true, force: true });
622
+ spawnSync("git", ["worktree", "prune"], { cwd: barePath });
623
+ }
624
+ }
625
+ catch {
626
+ fs.rmSync(resolved, { recursive: true, force: true });
627
+ }
628
+ }
629
+ else {
630
+ fs.rmSync(resolved, { recursive: true, force: true });
631
+ }
632
+ log.info(`Worktree removed: ${resolved}`);
633
+ res.json({ ok: true });
634
+ });
635
+ }