botmux 2.85.1 → 2.87.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 (236) hide show
  1. package/README.en.md +26 -0
  2. package/README.md +26 -0
  3. package/dist/adapters/backend/herdr-backend.d.ts.map +1 -1
  4. package/dist/adapters/backend/herdr-backend.js +12 -1
  5. package/dist/adapters/backend/herdr-backend.js.map +1 -1
  6. package/dist/adapters/backend/pty-backend.d.ts.map +1 -1
  7. package/dist/adapters/backend/pty-backend.js +4 -1
  8. package/dist/adapters/backend/pty-backend.js.map +1 -1
  9. package/dist/adapters/backend/tmux-backend.d.ts +1 -1
  10. package/dist/adapters/backend/tmux-backend.d.ts.map +1 -1
  11. package/dist/adapters/backend/tmux-backend.js +22 -9
  12. package/dist/adapters/backend/tmux-backend.js.map +1 -1
  13. package/dist/adapters/backend/tmux-pipe-backend.js +1 -1
  14. package/dist/adapters/backend/tmux-pipe-backend.js.map +1 -1
  15. package/dist/adapters/backend/types.d.ts +9 -0
  16. package/dist/adapters/backend/types.d.ts.map +1 -1
  17. package/dist/adapters/backend/types.js.map +1 -1
  18. package/dist/adapters/backend/zellij-backend.js +1 -1
  19. package/dist/adapters/backend/zellij-backend.js.map +1 -1
  20. package/dist/adapters/cli/codex.d.ts.map +1 -1
  21. package/dist/adapters/cli/codex.js +8 -5
  22. package/dist/adapters/cli/codex.js.map +1 -1
  23. package/dist/adapters/cli/shared-hints.d.ts +2 -1
  24. package/dist/adapters/cli/shared-hints.d.ts.map +1 -1
  25. package/dist/adapters/cli/shared-hints.js +25 -3
  26. package/dist/adapters/cli/shared-hints.js.map +1 -1
  27. package/dist/bot-registry.d.ts +10 -0
  28. package/dist/bot-registry.d.ts.map +1 -1
  29. package/dist/bot-registry.js +6 -0
  30. package/dist/bot-registry.js.map +1 -1
  31. package/dist/cli.d.ts.map +1 -1
  32. package/dist/cli.js +362 -16
  33. package/dist/cli.js.map +1 -1
  34. package/dist/core/command-handler.d.ts.map +1 -1
  35. package/dist/core/command-handler.js +215 -5
  36. package/dist/core/command-handler.js.map +1 -1
  37. package/dist/core/cost-calculator.d.ts.map +1 -1
  38. package/dist/core/cost-calculator.js +7 -106
  39. package/dist/core/cost-calculator.js.map +1 -1
  40. package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
  41. package/dist/core/dashboard-ipc-server.js +285 -2
  42. package/dist/core/dashboard-ipc-server.js.map +1 -1
  43. package/dist/core/maintenance.d.ts +18 -0
  44. package/dist/core/maintenance.d.ts.map +1 -1
  45. package/dist/core/maintenance.js +58 -30
  46. package/dist/core/maintenance.js.map +1 -1
  47. package/dist/core/passthrough-commands.d.ts.map +1 -1
  48. package/dist/core/passthrough-commands.js +1 -1
  49. package/dist/core/passthrough-commands.js.map +1 -1
  50. package/dist/core/per-bot-env.d.ts +38 -0
  51. package/dist/core/per-bot-env.d.ts.map +1 -0
  52. package/dist/core/per-bot-env.js +79 -0
  53. package/dist/core/per-bot-env.js.map +1 -0
  54. package/dist/core/role-resolver.d.ts +1 -0
  55. package/dist/core/role-resolver.d.ts.map +1 -1
  56. package/dist/core/role-resolver.js +14 -0
  57. package/dist/core/role-resolver.js.map +1 -1
  58. package/dist/core/session-manager.d.ts +3 -0
  59. package/dist/core/session-manager.d.ts.map +1 -1
  60. package/dist/core/session-manager.js +70 -6
  61. package/dist/core/session-manager.js.map +1 -1
  62. package/dist/core/skills/discovery.d.ts +7 -0
  63. package/dist/core/skills/discovery.d.ts.map +1 -1
  64. package/dist/core/skills/discovery.js +51 -0
  65. package/dist/core/skills/discovery.js.map +1 -1
  66. package/dist/core/trigger-session.d.ts.map +1 -1
  67. package/dist/core/trigger-session.js +5 -2
  68. package/dist/core/trigger-session.js.map +1 -1
  69. package/dist/core/update-check.d.ts +67 -0
  70. package/dist/core/update-check.d.ts.map +1 -0
  71. package/dist/core/update-check.js +159 -0
  72. package/dist/core/update-check.js.map +1 -0
  73. package/dist/core/worker-pool.d.ts.map +1 -1
  74. package/dist/core/worker-pool.js +4 -0
  75. package/dist/core/worker-pool.js.map +1 -1
  76. package/dist/daemon.d.ts.map +1 -1
  77. package/dist/daemon.js +29 -6
  78. package/dist/daemon.js.map +1 -1
  79. package/dist/dashboard/bot-payload.d.ts +1 -0
  80. package/dist/dashboard/bot-payload.d.ts.map +1 -1
  81. package/dist/dashboard/bot-payload.js +1 -0
  82. package/dist/dashboard/bot-payload.js.map +1 -1
  83. package/dist/dashboard/skill-install-request.d.ts +10 -0
  84. package/dist/dashboard/skill-install-request.d.ts.map +1 -1
  85. package/dist/dashboard/skill-install-request.js +17 -0
  86. package/dist/dashboard/skill-install-request.js.map +1 -1
  87. package/dist/dashboard/web/app.d.ts.map +1 -1
  88. package/dist/dashboard/web/app.js +43 -4
  89. package/dist/dashboard/web/app.js.map +1 -1
  90. package/dist/dashboard/web/bot-defaults.d.ts.map +1 -1
  91. package/dist/dashboard/web/bot-defaults.js +178 -1
  92. package/dist/dashboard/web/bot-defaults.js.map +1 -1
  93. package/dist/dashboard/web/groups.d.ts +2 -0
  94. package/dist/dashboard/web/groups.d.ts.map +1 -1
  95. package/dist/dashboard/web/groups.js +419 -3
  96. package/dist/dashboard/web/groups.js.map +1 -1
  97. package/dist/dashboard/web/i18n.d.ts.map +1 -1
  98. package/dist/dashboard/web/i18n.js +745 -3
  99. package/dist/dashboard/web/i18n.js.map +1 -1
  100. package/dist/dashboard/web/insights.d.ts +2 -0
  101. package/dist/dashboard/web/insights.d.ts.map +1 -0
  102. package/dist/dashboard/web/insights.js +1523 -0
  103. package/dist/dashboard/web/insights.js.map +1 -0
  104. package/dist/dashboard/web/role-profile-match.d.ts +31 -0
  105. package/dist/dashboard/web/role-profile-match.d.ts.map +1 -0
  106. package/dist/dashboard/web/role-profile-match.js +58 -0
  107. package/dist/dashboard/web/role-profile-match.js.map +1 -0
  108. package/dist/dashboard/web/roles.d.ts +1 -0
  109. package/dist/dashboard/web/roles.d.ts.map +1 -1
  110. package/dist/dashboard/web/roles.js +520 -27
  111. package/dist/dashboard/web/roles.js.map +1 -1
  112. package/dist/dashboard/web/sessions.d.ts.map +1 -1
  113. package/dist/dashboard/web/sessions.js +84 -0
  114. package/dist/dashboard/web/sessions.js.map +1 -1
  115. package/dist/dashboard/web/settings.d.ts.map +1 -1
  116. package/dist/dashboard/web/settings.js +289 -0
  117. package/dist/dashboard/web/settings.js.map +1 -1
  118. package/dist/dashboard/web/skills.d.ts.map +1 -1
  119. package/dist/dashboard/web/skills.js +183 -10
  120. package/dist/dashboard/web/skills.js.map +1 -1
  121. package/dist/dashboard/web/whiteboards.d.ts +2 -0
  122. package/dist/dashboard/web/whiteboards.d.ts.map +1 -0
  123. package/dist/dashboard/web/whiteboards.js +267 -0
  124. package/dist/dashboard/web/whiteboards.js.map +1 -0
  125. package/dist/dashboard-web/app.js +1406 -833
  126. package/dist/dashboard-web/index.html +3 -1
  127. package/dist/dashboard-web/style.css +1382 -30
  128. package/dist/dashboard.js +474 -6
  129. package/dist/dashboard.js.map +1 -1
  130. package/dist/global-config.d.ts +6 -0
  131. package/dist/global-config.d.ts.map +1 -1
  132. package/dist/global-config.js +12 -0
  133. package/dist/global-config.js.map +1 -1
  134. package/dist/i18n/en.d.ts.map +1 -1
  135. package/dist/i18n/en.js +34 -1
  136. package/dist/i18n/en.js.map +1 -1
  137. package/dist/i18n/zh.d.ts.map +1 -1
  138. package/dist/i18n/zh.js +34 -1
  139. package/dist/i18n/zh.js.map +1 -1
  140. package/dist/im/lark/card-handler.d.ts.map +1 -1
  141. package/dist/im/lark/card-handler.js +7 -3
  142. package/dist/im/lark/card-handler.js.map +1 -1
  143. package/dist/services/bot-config-store.d.ts.map +1 -1
  144. package/dist/services/bot-config-store.js +19 -0
  145. package/dist/services/bot-config-store.js.map +1 -1
  146. package/dist/services/group-creator.d.ts +6 -0
  147. package/dist/services/group-creator.d.ts.map +1 -1
  148. package/dist/services/group-creator.js +54 -5
  149. package/dist/services/group-creator.js.map +1 -1
  150. package/dist/services/insight/antigravity-span-reader.d.ts +3 -0
  151. package/dist/services/insight/antigravity-span-reader.d.ts.map +1 -0
  152. package/dist/services/insight/antigravity-span-reader.js +249 -0
  153. package/dist/services/insight/antigravity-span-reader.js.map +1 -0
  154. package/dist/services/insight/classify.d.ts +7 -0
  155. package/dist/services/insight/classify.d.ts.map +1 -0
  156. package/dist/services/insight/classify.js +46 -0
  157. package/dist/services/insight/classify.js.map +1 -0
  158. package/dist/services/insight/claude-span-reader.d.ts +3 -0
  159. package/dist/services/insight/claude-span-reader.d.ts.map +1 -0
  160. package/dist/services/insight/claude-span-reader.js +257 -0
  161. package/dist/services/insight/claude-span-reader.js.map +1 -0
  162. package/dist/services/insight/codex-span-reader.d.ts +3 -0
  163. package/dist/services/insight/codex-span-reader.d.ts.map +1 -0
  164. package/dist/services/insight/codex-span-reader.js +290 -0
  165. package/dist/services/insight/codex-span-reader.js.map +1 -0
  166. package/dist/services/insight/intent.d.ts +5 -0
  167. package/dist/services/insight/intent.d.ts.map +1 -0
  168. package/dist/services/insight/intent.js +145 -0
  169. package/dist/services/insight/intent.js.map +1 -0
  170. package/dist/services/insight/jsonl.d.ts +10 -0
  171. package/dist/services/insight/jsonl.d.ts.map +1 -0
  172. package/dist/services/insight/jsonl.js +36 -0
  173. package/dist/services/insight/jsonl.js.map +1 -0
  174. package/dist/services/insight/prompt.d.ts +3 -0
  175. package/dist/services/insight/prompt.d.ts.map +1 -0
  176. package/dist/services/insight/prompt.js +99 -0
  177. package/dist/services/insight/prompt.js.map +1 -0
  178. package/dist/services/insight/redact.d.ts +4 -0
  179. package/dist/services/insight/redact.d.ts.map +1 -0
  180. package/dist/services/insight/redact.js +67 -0
  181. package/dist/services/insight/redact.js.map +1 -0
  182. package/dist/services/insight/report.d.ts +29 -0
  183. package/dist/services/insight/report.d.ts.map +1 -0
  184. package/dist/services/insight/report.js +1126 -0
  185. package/dist/services/insight/report.js.map +1 -0
  186. package/dist/services/insight/safe-detail.d.ts +5 -0
  187. package/dist/services/insight/safe-detail.d.ts.map +1 -0
  188. package/dist/services/insight/safe-detail.js +59 -0
  189. package/dist/services/insight/safe-detail.js.map +1 -0
  190. package/dist/services/insight/scrub.d.ts +22 -0
  191. package/dist/services/insight/scrub.d.ts.map +1 -0
  192. package/dist/services/insight/scrub.js +70 -0
  193. package/dist/services/insight/scrub.js.map +1 -0
  194. package/dist/services/insight/types.d.ts +394 -0
  195. package/dist/services/insight/types.d.ts.map +1 -0
  196. package/dist/services/insight/types.js +2 -0
  197. package/dist/services/insight/types.js.map +1 -0
  198. package/dist/services/resumable-session-discovery.d.ts.map +1 -1
  199. package/dist/services/resumable-session-discovery.js +21 -4
  200. package/dist/services/resumable-session-discovery.js.map +1 -1
  201. package/dist/services/role-profile-store.d.ts +25 -0
  202. package/dist/services/role-profile-store.d.ts.map +1 -0
  203. package/dist/services/role-profile-store.js +171 -0
  204. package/dist/services/role-profile-store.js.map +1 -0
  205. package/dist/services/skill-registry-store.d.ts +1 -0
  206. package/dist/services/skill-registry-store.d.ts.map +1 -1
  207. package/dist/services/skill-registry-store.js +32 -0
  208. package/dist/services/skill-registry-store.js.map +1 -1
  209. package/dist/services/transcript-resolver.d.ts +26 -0
  210. package/dist/services/transcript-resolver.d.ts.map +1 -0
  211. package/dist/services/transcript-resolver.js +111 -0
  212. package/dist/services/transcript-resolver.js.map +1 -0
  213. package/dist/services/whiteboard-store.d.ts +67 -0
  214. package/dist/services/whiteboard-store.d.ts.map +1 -0
  215. package/dist/services/whiteboard-store.js +450 -0
  216. package/dist/services/whiteboard-store.js.map +1 -0
  217. package/dist/setup/bot-config-editor.d.ts +8 -0
  218. package/dist/setup/bot-config-editor.d.ts.map +1 -1
  219. package/dist/setup/bot-config-editor.js +9 -0
  220. package/dist/setup/bot-config-editor.js.map +1 -1
  221. package/dist/skills/definitions.d.ts.map +1 -1
  222. package/dist/skills/definitions.js +118 -0
  223. package/dist/skills/definitions.js.map +1 -1
  224. package/dist/types.d.ts +3 -0
  225. package/dist/types.d.ts.map +1 -1
  226. package/dist/utils/install-diagnostics.d.ts +52 -0
  227. package/dist/utils/install-diagnostics.d.ts.map +1 -0
  228. package/dist/utils/install-diagnostics.js +155 -0
  229. package/dist/utils/install-diagnostics.js.map +1 -0
  230. package/dist/utils/install-info.d.ts +4 -0
  231. package/dist/utils/install-info.d.ts.map +1 -1
  232. package/dist/utils/install-info.js +6 -0
  233. package/dist/utils/install-info.js.map +1 -1
  234. package/dist/worker.js +12 -0
  235. package/dist/worker.js.map +1 -1
  236. package/package.json +1 -1
package/dist/dashboard.js CHANGED
@@ -27,18 +27,32 @@ import { BotOnboardingManager } from './dashboard/bot-onboarding.js';
27
27
  import { CLI_SELECT_OPTIONS, resolveCliSelection, isTtadkWrapper, ttadkAcceptsModel, TTADK_DEFAULT_MODEL, TTADK_MODEL_SUGGESTIONS, } from './setup/cli-selection.js';
28
28
  import { invalidWorkingDirs } from './utils/working-dir.js';
29
29
  import { mergeDashboardConfig, mergeGlobalConfig, mergeMaintenanceConfig, parseMaintenancePatch, readGlobalConfig, setGlobalLocale } from './global-config.js';
30
+ import { deleteWhiteboard, listWhiteboards, readWhiteboard, whiteboardEnabled } from './services/whiteboard-store.js';
30
31
  import { isLocale } from './i18n/types.js';
31
- import { isLocalDevInstall } from './utils/install-info.js';
32
+ import { isLocalDevInstall, botmuxVersion } from './utils/install-info.js';
33
+ import { checkNode, detectBotmuxInstalls, resolveCurrentVersion } from './utils/install-diagnostics.js';
34
+ import { fetchLatestVersion, fetchReleasesSince, isNewerVersion } from './core/update-check.js';
35
+ import { GITHUB_REPO } from './core/restart-report.js';
36
+ import { spawnDetachedRestart, npmGlobalUpdateLockTarget } from './core/maintenance.js';
37
+ import { writeRestartIntent } from './services/restart-intent-store.js';
38
+ import { withFileLock } from './utils/file-lock.js';
39
+ import { spawn } from 'node:child_process';
32
40
  import { listTeamReports, readTeamBoard, setTeamBoardEntry } from './services/team-board-store.js';
33
41
  import { hd2dAssetPath, hd2dStatus, startHd2dDownload } from './dashboard/hd2d-assets.js';
34
- import { readSkillRegistry, removeInstalledSkill, updateInstalledSkillAsync, } from './services/skill-registry-store.js';
42
+ import { installLocalSkillLinks, readSkillRegistry, removeInstalledSkill, updateInstalledSkillAsync, } from './services/skill-registry-store.js';
35
43
  import { redactGitUrlCredentials } from './core/skills/sources.js';
36
44
  import { loadBotConfigs } from './bot-registry.js';
45
+ import { discoverNativeCliSkillGroups } from './core/skills/discovery.js';
37
46
  import { analyzeSkillReferences } from './core/skills/references.js';
38
- import { installDashboardSkill, parseDashboardSkillInstallRequest } from './dashboard/skill-install-request.js';
47
+ import { installDashboardSkill, parseDashboardSkillInstallRequest, parseInstallLocalLinksSources, MAX_LOCAL_LINK_SOURCES } from './dashboard/skill-install-request.js';
39
48
  import { botDefaultsPayload, botSummaryPayload } from './dashboard/bot-payload.js';
49
+ import { isValidRoleProfileId } from './services/role-profile-store.js';
50
+ import { mergeSafeInsightOverviews } from './services/insight/report.js';
40
51
  const SECRET_PATH = join(homedir(), '.botmux', '.dashboard-secret');
41
52
  const TOKEN_PATH = join(homedir(), '.botmux', '.dashboard-token');
53
+ /** Per-daemon budget for the cross-daemon insight overview fan-out — bounds
54
+ * aggregate latency when one daemon's insight parse is slow or hung. */
55
+ const INSIGHT_FANOUT_TIMEOUT_MS = 10_000;
42
56
  const BOTS_JSON_PATH = join(homedir(), '.botmux', 'bots.json');
43
57
  const REGISTRY_DIR = join(homedir(), '.botmux', 'data', 'dashboard-daemons');
44
58
  // The dashboard probes upward if its configured port is busy (e.g. a second
@@ -111,6 +125,7 @@ function resolveDashboardSettings() {
111
125
  repoPickerMode: global.repoPickerMode ?? 'all',
112
126
  maintenance: global.maintenance ?? {},
113
127
  localDevInstall: isLocalDevInstall(),
128
+ whiteboard: { enabled: global.whiteboard?.enabled === true },
114
129
  };
115
130
  }
116
131
  async function readJsonBody(req) {
@@ -120,6 +135,67 @@ async function readJsonBody(req) {
120
135
  const raw = Buffer.concat(chunks).toString('utf8').trim();
121
136
  return raw ? JSON.parse(raw) : {};
122
137
  }
138
+ /** Fast in-process guard against double-clicks within this dashboard process.
139
+ * Cross-process serialization against the maintenance auto-update (a different
140
+ * process) is handled separately by the shared file lock in the run route. */
141
+ let updateInFlight = false;
142
+ // Cache the upstream version/changelog lookups so the nav-badge check + the
143
+ // Settings card don't hammer the npm registry / GitHub on every page load.
144
+ // GitHub's unauthenticated API is only 60 req/h per IP, so caching the changelog
145
+ // also keeps us from exhausting it. Failures cache briefly so they self-heal.
146
+ const LATEST_TTL_MS = 30 * 60_000;
147
+ const CHANGELOG_TTL_MS = 15 * 60_000;
148
+ const FAILURE_TTL_MS = 60_000;
149
+ let latestVersionCache = null;
150
+ let changelogCache = null;
151
+ async function cachedLatestVersion(now = Date.now()) {
152
+ const ttl = latestVersionCache?.value ? LATEST_TTL_MS : FAILURE_TTL_MS;
153
+ if (latestVersionCache && now - latestVersionCache.at < ttl)
154
+ return latestVersionCache.value;
155
+ const value = await fetchLatestVersion();
156
+ latestVersionCache = { value, at: now };
157
+ return value;
158
+ }
159
+ async function cachedChangelog(current, now = Date.now()) {
160
+ const ttl = changelogCache?.value.ok ? CHANGELOG_TTL_MS : FAILURE_TTL_MS;
161
+ if (changelogCache && changelogCache.key === current && now - changelogCache.at < ttl)
162
+ return changelogCache.value;
163
+ const value = await fetchReleasesSince(current);
164
+ changelogCache = { key: current, value, at: now };
165
+ return value;
166
+ }
167
+ /**
168
+ * Run `npm install -g botmux@latest` for the manual-update flow WITHOUT blocking
169
+ * the event loop (async spawn, not execSync — the dashboard must keep serving
170
+ * during the ~10-30s install). Resolves on exit 0; rejects with the tail of
171
+ * stdout/stderr on a non-zero exit, spawn error, or 3-minute timeout. Args are
172
+ * a fixed literal — no shell interpolation of untrusted input.
173
+ */
174
+ function runNpmInstallLatest() {
175
+ return new Promise((resolve, reject) => {
176
+ const child = spawn('npm', ['install', '-g', 'botmux@latest'], {
177
+ env: process.env,
178
+ stdio: ['ignore', 'pipe', 'pipe'],
179
+ shell: process.platform === 'win32', // resolve npm.cmd on Windows
180
+ });
181
+ let tail = '';
182
+ const capture = (d) => { tail = (tail + d.toString()).slice(-2000); };
183
+ child.stdout?.on('data', capture);
184
+ child.stderr?.on('data', capture);
185
+ const timer = setTimeout(() => {
186
+ child.kill('SIGKILL');
187
+ reject(new Error('npm install timed out after 180s'));
188
+ }, 180_000);
189
+ child.on('error', (e) => { clearTimeout(timer); reject(e); });
190
+ child.on('exit', (code) => {
191
+ clearTimeout(timer);
192
+ if (code === 0)
193
+ resolve();
194
+ else
195
+ reject(new Error(`npm exited ${code}: ${tail.trim().slice(-500)}`));
196
+ });
197
+ });
198
+ }
123
199
  /**
124
200
  * Attach to one daemon: hydrate its sessions/schedules into the aggregator,
125
201
  * THEN open the SSE subscription. Order matters — hydrating after subscribe
@@ -219,7 +295,7 @@ function serveFileAbs(res, fp) {
219
295
  createReadStream(fp).pipe(res);
220
296
  return true;
221
297
  }
222
- function serveStatic(_req, res, pathname) {
298
+ function serveStatic(req, res, pathname) {
223
299
  const rel = pathname === '/' ? 'index.html' : pathname.replace(/^\/+/, '');
224
300
  const fp = resolve(WEB_DIR, rel);
225
301
  const webRoot = resolve(WEB_DIR);
@@ -231,7 +307,23 @@ function serveStatic(_req, res, pathname) {
231
307
  const st = statSync(fp);
232
308
  if (!st.isFile())
233
309
  return false;
234
- res.writeHead(200, { 'content-type': MIME[extname(fp)] ?? 'application/octet-stream' });
310
+ // Bundle filenames are fixed (app.js/style.css), so without revalidation
311
+ // browsers heuristic-cache them and serve a stale build after a deploy
312
+ // (new JS + old CSS → broken layout). `no-cache` + an mtime/size ETag makes
313
+ // the browser revalidate every load: 304 when unchanged (cheap), fresh 200
314
+ // when the build changed. No manual hard-refresh needed after deploy.
315
+ const etag = `W/"${st.size.toString(16)}-${Math.floor(st.mtimeMs).toString(16)}"`;
316
+ const headers = {
317
+ 'content-type': MIME[extname(fp)] ?? 'application/octet-stream',
318
+ 'cache-control': 'no-cache',
319
+ etag,
320
+ };
321
+ if (req.headers['if-none-match'] === etag) {
322
+ res.writeHead(304, headers);
323
+ res.end();
324
+ return true;
325
+ }
326
+ res.writeHead(200, headers);
235
327
  res.end(readFileSync(fp));
236
328
  return true;
237
329
  }
@@ -302,7 +394,13 @@ async function createTeamGroup(args) {
302
394
  const upstream = await proxyToDaemon(plan.creatorLarkAppId, '/api/groups/create', {
303
395
  method: 'POST',
304
396
  headers: { 'content-type': 'application/json' },
305
- body: JSON.stringify({ name: args.name, larkAppIds: selectedIds, userOpenIds, ownerUnionIds: args.ownerUnionIds ?? [] }),
397
+ body: JSON.stringify({
398
+ name: args.name,
399
+ larkAppIds: selectedIds,
400
+ userOpenIds,
401
+ ownerUnionIds: args.ownerUnionIds ?? [],
402
+ ...(args.roleProfileId ? { roleProfileId: args.roleProfileId } : {}),
403
+ }),
306
404
  });
307
405
  const text = await upstream.text();
308
406
  let parsed = null;
@@ -480,12 +578,33 @@ function sanitizeSkillForDashboard(skill) {
480
578
  source: { ...skill.source, url: redactGitUrlCredentials(skill.source.url) },
481
579
  };
482
580
  }
581
+ function dashboardSkillCliIds() {
582
+ const ids = new Set();
583
+ try {
584
+ for (const cliId of configuredCliIds().values())
585
+ ids.add(cliId);
586
+ }
587
+ catch {
588
+ // Fall back to daemon descriptors below when persistent config is unavailable.
589
+ }
590
+ for (const bot of registry.list()) {
591
+ if (bot.cliId)
592
+ ids.add(bot.cliId);
593
+ }
594
+ return [...ids];
595
+ }
483
596
  function dashboardSkillsPayload() {
484
597
  const globalSkills = readGlobalConfig().skills ?? {};
598
+ const nativeSkillGroups = discoverNativeCliSkillGroups(dashboardSkillCliIds())
599
+ .map(group => ({
600
+ ...group,
601
+ skills: group.skills.map(sanitizeSkillForDashboard),
602
+ }));
485
603
  return {
486
604
  skills: Object.values(readSkillRegistry().skills)
487
605
  .sort((a, b) => a.name.localeCompare(b.name))
488
606
  .map(sanitizeSkillForDashboard),
607
+ nativeSkillGroups,
489
608
  trustProjectSkills: globalSkills.trustProjectSkills ?? 'off',
490
609
  delivery: globalSkills.delivery ?? 'auto',
491
610
  };
@@ -673,6 +792,29 @@ const server = createServer(async (req, res) => {
673
792
  });
674
793
  return jsonRes(res, 200, { sessions });
675
794
  }
795
+ if (req.method === 'GET' && url.pathname === '/api/insights/summary') {
796
+ const limit = Math.min(Math.max(parseInt(url.searchParams.get('limit') ?? '200', 10) || 200, 1), 500);
797
+ // Per-daemon timeout + isolate failures: an upstream insight parse can be
798
+ // heavy, so a slow/hung daemon must not stall the aggregated summary. A
799
+ // timed-out / errored chunk drops to null and is filtered out below.
800
+ const chunks = await Promise.all(registry.list().map(async (d) => {
801
+ try {
802
+ const upstream = await proxyToDaemon(d.larkAppId, `/api/insights/summary?limit=${limit}`, {
803
+ method: 'GET',
804
+ signal: AbortSignal.timeout(INSIGHT_FANOUT_TIMEOUT_MS),
805
+ });
806
+ if (!upstream.ok)
807
+ return null;
808
+ const body = await upstream.json().catch(() => null);
809
+ return body?.overview ?? null;
810
+ }
811
+ catch {
812
+ return null;
813
+ }
814
+ }));
815
+ const overview = mergeSafeInsightOverviews(chunks.filter((x) => !!x), { limit });
816
+ return jsonRes(res, 200, { ok: true, overview });
817
+ }
676
818
  if (req.method === 'GET' && url.pathname === '/api/schedules') {
677
819
  // Public-read carve-out: the row carries CONTENT (prompt = business
678
820
  // instructions) and a bound `workingDir` (repo/customer path) — strip
@@ -724,6 +866,16 @@ const server = createServer(async (req, res) => {
724
866
  mergeGlobalConfig({ repoPickerMode: v });
725
867
  touched = true;
726
868
  }
869
+ if ('whiteboard' in body) {
870
+ const raw = body.whiteboard;
871
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
872
+ return jsonRes(res, 400, { ok: false, error: 'invalid_whiteboard' });
873
+ const wb = raw;
874
+ if (typeof wb.enabled !== 'boolean')
875
+ return jsonRes(res, 400, { ok: false, error: 'invalid_whiteboard_enabled' });
876
+ mergeGlobalConfig({ whiteboard: { enabled: wb.enabled } });
877
+ touched = true;
878
+ }
727
879
  if ('maintenance' in body) {
728
880
  const r = parseMaintenancePatch(body.maintenance);
729
881
  if (!r.ok)
@@ -763,6 +915,96 @@ const server = createServer(async (req, res) => {
763
915
  return jsonRes(res, 400, { ok: false, error: 'empty_patch' });
764
916
  return jsonRes(res, 200, { ok: true, settings: resolveDashboardSettings() });
765
917
  }
918
+ // ─── Version & manual update ─────────────────────────────────────────────
919
+ // `npm install -g` and a host restart are privileged: none of these paths
920
+ // are on PUBLIC_READ_PATHS, so decideDashboardAuth already 401s an
921
+ // unauthenticated caller (in both normal and public-read mode). The explicit
922
+ // `authed` guards on the two mutations are defense-in-depth for host actions.
923
+ if (req.method === 'GET' && url.pathname === '/api/update/status') {
924
+ const current = resolveCurrentVersion();
925
+ // Compare against the npm `latest` dist-tag (always stable; the update
926
+ // button installs `@latest`). isNewerVersion uses semver precedence, so a
927
+ // canary running AHEAD of the latest stable (e.g. 2.87.0-canary.0 vs
928
+ // 2.86.0) is NOT flagged behind — exactly the canary case we want.
929
+ const latest = await cachedLatestVersion();
930
+ return jsonRes(res, 200, {
931
+ current,
932
+ latest,
933
+ behind: !!latest && isNewerVersion(latest, current),
934
+ localDevInstall: isLocalDevInstall(),
935
+ node: checkNode(),
936
+ installs: detectBotmuxInstalls(),
937
+ });
938
+ }
939
+ if (req.method === 'GET' && url.pathname === '/api/update/changelog') {
940
+ const current = resolveCurrentVersion();
941
+ const result = await cachedChangelog(current);
942
+ return jsonRes(res, 200, {
943
+ current,
944
+ ok: result.ok,
945
+ rateLimited: result.rateLimited === true,
946
+ releases: result.releases,
947
+ releasesUrl: `https://github.com/${GITHUB_REPO}/releases`,
948
+ });
949
+ }
950
+ if (req.method === 'POST' && url.pathname === '/api/update/run') {
951
+ if (!authed)
952
+ return jsonRes(res, 401, { ok: false, error: 'unauthorized' });
953
+ if (isLocalDevInstall())
954
+ return jsonRes(res, 400, { ok: false, error: 'local_dev_no_update' });
955
+ const node = checkNode();
956
+ if (!node.ok)
957
+ return jsonRes(res, 400, { ok: false, error: 'node_too_old', node });
958
+ if (updateInFlight)
959
+ return jsonRes(res, 409, { ok: false, error: 'update_in_flight' });
960
+ updateInFlight = true;
961
+ const oldVersion = botmuxVersion();
962
+ // Acquire the shared cross-process lock so a scheduled maintenance
963
+ // auto-update (running in the bot-0 daemon) can't `npm install -g` at the
964
+ // same time. `acquired` distinguishes "lock held by maintenance" (409)
965
+ // from "npm itself failed" (500). Short wait: don't block the request on a
966
+ // full in-progress install — report busy fast.
967
+ let acquired = false;
968
+ try {
969
+ await withFileLock(npmGlobalUpdateLockTarget(), async () => {
970
+ acquired = true;
971
+ await runNpmInstallLatest();
972
+ }, { maxWaitMs: 2_000 });
973
+ }
974
+ catch (e) {
975
+ if (!acquired)
976
+ return jsonRes(res, 409, { ok: false, error: 'update_in_flight' });
977
+ return jsonRes(res, 500, { ok: false, error: 'npm_failed', detail: e instanceof Error ? e.message : String(e) });
978
+ }
979
+ finally {
980
+ updateInFlight = false;
981
+ }
982
+ const newVersion = botmuxVersion();
983
+ return jsonRes(res, 200, { ok: true, oldVersion, newVersion, changed: newVersion !== oldVersion });
984
+ }
985
+ if (req.method === 'POST' && url.pathname === '/api/update/restart') {
986
+ if (!authed)
987
+ return jsonRes(res, 401, { ok: false, error: 'unauthorized' });
988
+ let body = {};
989
+ try {
990
+ const parsed = await readJsonBody(req);
991
+ if (parsed && typeof parsed === 'object')
992
+ body = parsed;
993
+ }
994
+ catch { /* empty / bad body → plain restart */ }
995
+ const upd = body.update && typeof body.update === 'object' ? body.update : null;
996
+ // After a manual update, leave an `update` breadcrumb so the fresh daemon
997
+ // DMs the owner the changelog (reuses the restart-report pipeline). A plain
998
+ // restart leaves none here; cmdRestart writes a `manual` breadcrumb itself.
999
+ if (upd && typeof upd.oldVersion === 'string' && typeof upd.newVersion === 'string' && upd.oldVersion !== upd.newVersion) {
1000
+ try {
1001
+ writeRestartIntent({ kind: 'update', oldVersion: upd.oldVersion, newVersion: upd.newVersion, at: new Date().toISOString() });
1002
+ }
1003
+ catch { /* breadcrumb is best-effort */ }
1004
+ }
1005
+ spawnDetachedRestart('dashboard');
1006
+ return jsonRes(res, 200, { ok: true });
1007
+ }
766
1008
  if (req.method === 'GET' && url.pathname === '/api/skills') {
767
1009
  return jsonRes(res, 200, dashboardSkillsPayload());
768
1010
  }
@@ -815,6 +1057,30 @@ const server = createServer(async (req, res) => {
815
1057
  return jsonRes(res, 400, { ok: false, error: redactGitUrlCredentials(err?.message ?? String(err)) });
816
1058
  }
817
1059
  }
1060
+ if (req.method === 'POST' && url.pathname === '/api/skills/install-local-links') {
1061
+ let parsed;
1062
+ try {
1063
+ parsed = await readJsonBody(req);
1064
+ }
1065
+ catch {
1066
+ return jsonRes(res, 400, { ok: false, error: 'bad_json' });
1067
+ }
1068
+ const sources = parseInstallLocalLinksSources(parsed);
1069
+ if (sources.length === 0)
1070
+ return jsonRes(res, 400, { ok: false, error: 'sources_required' });
1071
+ if (sources.length > MAX_LOCAL_LINK_SOURCES)
1072
+ return jsonRes(res, 400, { ok: false, error: 'too_many_sources' });
1073
+ try {
1074
+ const skills = installLocalSkillLinks(sources);
1075
+ // Frontend re-fetches /api/skills (refresh()) after success, so we keep
1076
+ // the response lean — no need to spread a full dashboardSkillsPayload()
1077
+ // (which would re-run the native-skill discovery scan a second time).
1078
+ return jsonRes(res, 200, { ok: true, installed: skills.map(sanitizeSkillForDashboard) });
1079
+ }
1080
+ catch (err) {
1081
+ return jsonRes(res, 400, { ok: false, error: redactGitUrlCredentials(err?.message ?? String(err)) });
1082
+ }
1083
+ }
818
1084
  let mSkillJob;
819
1085
  if (req.method === 'GET' && (mSkillJob = url.pathname.match(/^\/api\/skills\/jobs\/([^/]+)$/))) {
820
1086
  const job = skillJobs.get(decodeURIComponent(mSkillJob[1]));
@@ -858,6 +1124,28 @@ const server = createServer(async (req, res) => {
858
1124
  ...dashboardSkillsPayload(),
859
1125
  });
860
1126
  }
1127
+ if (req.method === 'GET' && url.pathname === '/api/whiteboards') {
1128
+ return jsonRes(res, 200, { enabled: whiteboardEnabled(), whiteboards: listWhiteboards() });
1129
+ }
1130
+ const mWhiteboard = url.pathname.match(/^\/api\/whiteboards\/([^/]+)$/);
1131
+ if (req.method === 'GET' && mWhiteboard) {
1132
+ try {
1133
+ const id = decodeURIComponent(mWhiteboard[1]);
1134
+ return jsonRes(res, 200, { enabled: whiteboardEnabled(), id, content: readWhiteboard(id, { allowDisabled: true }) });
1135
+ }
1136
+ catch (err) {
1137
+ return jsonRes(res, 404, { ok: false, error: err?.message ?? 'whiteboard_not_found' });
1138
+ }
1139
+ }
1140
+ if (req.method === 'DELETE' && mWhiteboard) {
1141
+ try {
1142
+ const id = decodeURIComponent(mWhiteboard[1]);
1143
+ return jsonRes(res, 200, deleteWhiteboard(id));
1144
+ }
1145
+ catch (err) {
1146
+ return jsonRes(res, 400, { ok: false, error: err?.message ?? 'whiteboard_delete_failed' });
1147
+ }
1148
+ }
861
1149
  if (await handleConnectorApi(req, res, url)) {
862
1150
  return;
863
1151
  }
@@ -1032,6 +1320,30 @@ const server = createServer(async (req, res) => {
1032
1320
  res.end(await upstream.text());
1033
1321
  return;
1034
1322
  }
1323
+ // 会话 insight(只读 trace 分析:动作 span / 失败聚合 / 规则建议)。
1324
+ // owner-only:不在公开读白名单 → decideDashboardAuth 已对只读访客 401,
1325
+ // 公开/联邦访客看不到 tab 也拿不到 span。代理到 owner daemon 的同名 IPC。
1326
+ if (req.method === 'GET' && (m = url.pathname.match(/^\/api\/sessions\/([^/]+)\/insight$/))) {
1327
+ const sid = decodeURIComponent(m[1]);
1328
+ const owner = aggregator.ownerOf(sid);
1329
+ if (!owner)
1330
+ return jsonRes(res, 404, { ok: false, error: 'unknown_session' });
1331
+ const upstream = await proxyToDaemon(owner, `/api/sessions/${sid}/insight${url.search ?? ''}`, { method: 'GET' });
1332
+ res.writeHead(upstream.status, { 'content-type': 'application/json' });
1333
+ res.end(await upstream.text());
1334
+ return;
1335
+ }
1336
+ if (req.method === 'GET' && (m = url.pathname.match(/^\/api\/sessions\/([^/]+)\/insight\/turn\/([^/]+)$/))) {
1337
+ const sid = decodeURIComponent(m[1]);
1338
+ const turnIndex = decodeURIComponent(m[2]);
1339
+ const owner = aggregator.ownerOf(sid);
1340
+ if (!owner)
1341
+ return jsonRes(res, 404, { ok: false, error: 'unknown_session' });
1342
+ const upstream = await proxyToDaemon(owner, `/api/sessions/${sid}/insight/turn/${turnIndex}${url.search ?? ''}`, { method: 'GET' });
1343
+ res.writeHead(upstream.status, { 'content-type': 'application/json' });
1344
+ res.end(await upstream.text());
1345
+ return;
1346
+ }
1035
1347
  // Writable web-terminal link (token-bearing). Not in any public allow-list,
1036
1348
  // so decideDashboardAuth has already 401'd unauthenticated callers before we
1037
1349
  // get here — the token only reaches authenticated dashboard sessions.
@@ -1202,6 +1514,135 @@ const server = createServer(async (req, res) => {
1202
1514
  return;
1203
1515
  }
1204
1516
  }
1517
+ // ─── Profiles (aggregate/proxy to daemon) ─────────────────────────────
1518
+ if (req.method === 'GET' && url.pathname === '/api/role-profiles') {
1519
+ const merged = new Map();
1520
+ await Promise.all(registry.list().map(async (d) => {
1521
+ try {
1522
+ const r = await fetch(`http://127.0.0.1:${d.ipcPort}/api/role-profiles`);
1523
+ if (!r.ok)
1524
+ return;
1525
+ const j = await r.json();
1526
+ for (const p of j.profiles ?? []) {
1527
+ if (typeof p.profileId !== 'string')
1528
+ continue;
1529
+ const cur = merged.get(p.profileId) ?? { profileId: p.profileId, entryCount: 0, updatedAt: null, botEntries: [] };
1530
+ cur.entryCount = Math.max(cur.entryCount, typeof p.entryCount === 'number' ? p.entryCount : 0);
1531
+ if (typeof p.updatedAt === 'number')
1532
+ cur.updatedAt = cur.updatedAt === null ? p.updatedAt : Math.max(cur.updatedAt, p.updatedAt);
1533
+ const larkAppId = j.larkAppId ?? d.larkAppId;
1534
+ if (!cur.botEntries.some(entry => entry.larkAppId === larkAppId)) {
1535
+ cur.botEntries.push({ larkAppId, hasEntry: p.hasCurrentBotEntry === true });
1536
+ }
1537
+ merged.set(p.profileId, cur);
1538
+ }
1539
+ }
1540
+ catch { /* skip offline/bad daemon */ }
1541
+ }));
1542
+ return jsonRes(res, 200, {
1543
+ profiles: [...merged.values()]
1544
+ .map(p => ({
1545
+ ...p,
1546
+ entryCount: Math.max(p.entryCount, p.botEntries.filter(entry => entry.hasEntry).length),
1547
+ }))
1548
+ .sort((a, b) => a.profileId.localeCompare(b.profileId)),
1549
+ });
1550
+ }
1551
+ let mRoleProfileApply;
1552
+ if (req.method === 'POST' && (mRoleProfileApply = url.pathname.match(/^\/api\/role-profiles\/([^/]+)\/apply$/))) {
1553
+ const profileId = decodeURIComponent(mRoleProfileApply[1]);
1554
+ if (!isValidRoleProfileId(profileId))
1555
+ return jsonRes(res, 400, { ok: false, error: 'invalid_role_profile_id' });
1556
+ let raw = '{}';
1557
+ let parsed;
1558
+ try {
1559
+ const chunks = [];
1560
+ for await (const c of req)
1561
+ chunks.push(c);
1562
+ raw = Buffer.concat(chunks).toString('utf8') || '{}';
1563
+ parsed = JSON.parse(raw);
1564
+ }
1565
+ catch {
1566
+ return jsonRes(res, 400, { ok: false, error: 'bad_json' });
1567
+ }
1568
+ const larkAppId = typeof parsed.larkAppId === 'string' ? parsed.larkAppId : '';
1569
+ if (!larkAppId)
1570
+ return jsonRes(res, 400, { ok: false, error: 'larkAppId_required' });
1571
+ const upstream = await proxyToDaemon(larkAppId, `/api/role-profiles/${encodeURIComponent(profileId)}/apply`, {
1572
+ method: 'POST',
1573
+ headers: { 'content-type': 'application/json' },
1574
+ body: raw,
1575
+ });
1576
+ res.writeHead(upstream.status, { 'content-type': 'application/json' });
1577
+ res.end(await upstream.text());
1578
+ return;
1579
+ }
1580
+ let mRoleProfileEntry;
1581
+ if ((mRoleProfileEntry = url.pathname.match(/^\/api\/role-profiles\/([^/]+)\/([^/]+)$/))) {
1582
+ const profileId = decodeURIComponent(mRoleProfileEntry[1]);
1583
+ const larkAppId = decodeURIComponent(mRoleProfileEntry[2]);
1584
+ if (!isValidRoleProfileId(profileId))
1585
+ return jsonRes(res, 400, { ok: false, error: 'invalid_role_profile_id' });
1586
+ if (req.method === 'GET') {
1587
+ const upstream = await proxyToDaemon(larkAppId, `/api/role-profiles/${encodeURIComponent(profileId)}/${encodeURIComponent(larkAppId)}`, { method: 'GET' });
1588
+ res.writeHead(upstream.status, { 'content-type': 'application/json' });
1589
+ res.end(await upstream.text());
1590
+ return;
1591
+ }
1592
+ if (req.method === 'PUT') {
1593
+ const chunks = [];
1594
+ for await (const c of req)
1595
+ chunks.push(c);
1596
+ const raw = Buffer.concat(chunks).toString('utf8') || '{}';
1597
+ const upstream = await proxyToDaemon(larkAppId, `/api/role-profiles/${encodeURIComponent(profileId)}/${encodeURIComponent(larkAppId)}`, {
1598
+ method: 'PUT',
1599
+ headers: { 'content-type': 'application/json' },
1600
+ body: raw,
1601
+ });
1602
+ res.writeHead(upstream.status, { 'content-type': 'application/json' });
1603
+ res.end(await upstream.text());
1604
+ return;
1605
+ }
1606
+ if (req.method === 'DELETE') {
1607
+ const upstream = await proxyToDaemon(larkAppId, `/api/role-profiles/${encodeURIComponent(profileId)}/${encodeURIComponent(larkAppId)}`, { method: 'DELETE' });
1608
+ res.writeHead(upstream.status, { 'content-type': 'application/json' });
1609
+ res.end(await upstream.text());
1610
+ return;
1611
+ }
1612
+ }
1613
+ let mRoleProfile;
1614
+ if (req.method === 'GET' && (mRoleProfile = url.pathname.match(/^\/api\/role-profiles\/([^/]+)$/))) {
1615
+ const profileId = decodeURIComponent(mRoleProfile[1]);
1616
+ if (!isValidRoleProfileId(profileId))
1617
+ return jsonRes(res, 400, { ok: false, error: 'invalid_role_profile_id' });
1618
+ const byBot = new Map();
1619
+ await Promise.all(registry.list().map(async (d) => {
1620
+ try {
1621
+ const r = await fetch(`http://127.0.0.1:${d.ipcPort}/api/role-profiles/${encodeURIComponent(profileId)}`);
1622
+ if (!r.ok)
1623
+ return;
1624
+ const j = await r.json();
1625
+ for (const entry of j.entries ?? []) {
1626
+ if (typeof entry.larkAppId !== 'string' || typeof entry.content !== 'string')
1627
+ continue;
1628
+ const updatedAt = typeof entry.updatedAt === 'number' ? entry.updatedAt : null;
1629
+ const current = byBot.get(entry.larkAppId);
1630
+ if (current && (current.updatedAt ?? 0) > (updatedAt ?? 0))
1631
+ continue;
1632
+ byBot.set(entry.larkAppId, {
1633
+ profileId,
1634
+ larkAppId: entry.larkAppId,
1635
+ content: entry.content,
1636
+ byteLength: typeof entry.byteLength === 'number' ? entry.byteLength : Buffer.byteLength(entry.content, 'utf-8'),
1637
+ updatedAt,
1638
+ });
1639
+ }
1640
+ }
1641
+ catch { /* skip */ }
1642
+ }));
1643
+ const entries = [...byBot.values()].sort((a, b) => a.larkAppId.localeCompare(b.larkAppId));
1644
+ return jsonRes(res, 200, { profileId, entries });
1645
+ }
1205
1646
  let m2;
1206
1647
  if (req.method === 'POST' && (m2 = url.pathname.match(/^\/api\/groups\/([^/]+)\/add-bots$/))) {
1207
1648
  const chatId = decodeURIComponent(m2[1]);
@@ -1455,6 +1896,24 @@ const server = createServer(async (req, res) => {
1455
1896
  res.end(await upstream.text());
1456
1897
  return;
1457
1898
  }
1899
+ // PUT /api/bots/:appId/env — proxy to that bot's daemon. Body
1900
+ // `{ env: string }` (raw JSON text; '' = clear).
1901
+ let mBotEnv;
1902
+ if (req.method === 'PUT' && (mBotEnv = url.pathname.match(/^\/api\/bots\/([^/]+)\/env$/))) {
1903
+ const appId = decodeURIComponent(mBotEnv[1]);
1904
+ const chunks = [];
1905
+ for await (const c of req)
1906
+ chunks.push(c);
1907
+ const raw = Buffer.concat(chunks).toString('utf8') || '{}';
1908
+ const upstream = await proxyToDaemon(appId, `/api/bot-env`, {
1909
+ method: 'PUT',
1910
+ headers: { 'content-type': 'application/json' },
1911
+ body: raw,
1912
+ });
1913
+ res.writeHead(upstream.status, { 'content-type': 'application/json' });
1914
+ res.end(await upstream.text());
1915
+ return;
1916
+ }
1458
1917
  // PUT /api/bots/:appId/sandbox — proxy to that bot's daemon. Body `{ enabled: boolean }`.
1459
1918
  let mBotSandbox;
1460
1919
  if (req.method === 'PUT' && (mBotSandbox = url.pathname.match(/^\/api\/bots\/([^/]+)\/sandbox$/))) {
@@ -1570,6 +2029,12 @@ const server = createServer(async (req, res) => {
1570
2029
  if (selectedIds.length === 0) {
1571
2030
  return jsonRes(res, 400, { ok: false, error: 'larkAppIds_required' });
1572
2031
  }
2032
+ const roleProfileId = typeof parsed.roleProfileId === 'string' && parsed.roleProfileId.trim()
2033
+ ? parsed.roleProfileId.trim()
2034
+ : null;
2035
+ if (roleProfileId && !isValidRoleProfileId(roleProfileId)) {
2036
+ return jsonRes(res, 400, { ok: false, error: 'invalid_role_profile_id' });
2037
+ }
1573
2038
  const explicit = Array.isArray(parsed.userOpenIds)
1574
2039
  ? parsed.userOpenIds.filter((x) => typeof x === 'string')
1575
2040
  : [];
@@ -1600,6 +2065,7 @@ const server = createServer(async (req, res) => {
1600
2065
  bindWorkingDir: typeof parsed.bindWorkingDir === 'string' && parsed.bindWorkingDir.trim()
1601
2066
  ? parsed.bindWorkingDir.trim()
1602
2067
  : undefined,
2068
+ roleProfileId: roleProfileId ?? undefined,
1603
2069
  };
1604
2070
  const upstream = await fetch(`http://127.0.0.1:${creator.ipcPort}/api/groups/create`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(forwardBody) });
1605
2071
  const upstreamText = await upstream.text();
@@ -1609,6 +2075,8 @@ const server = createServer(async (req, res) => {
1609
2075
  }
1610
2076
  catch { /* leave null */ }
1611
2077
  if (upstreamJson && typeof upstreamJson === 'object') {
2078
+ if (roleProfileId)
2079
+ upstreamJson.roleProfileId = roleProfileId;
1612
2080
  // If Lark rejected the invite (open_id wrong scope, banned user, etc.)
1613
2081
  // null out autoInvitedOpenId so the frontend doesn't falsely claim
1614
2082
  // success — the user actually isn't a member of the new chat.