botmux 2.86.0 → 2.88.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 (247) hide show
  1. package/README.en.md +27 -0
  2. package/README.md +27 -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/liveness-gate.d.ts +38 -0
  7. package/dist/adapters/backend/liveness-gate.d.ts.map +1 -0
  8. package/dist/adapters/backend/liveness-gate.js +53 -0
  9. package/dist/adapters/backend/liveness-gate.js.map +1 -0
  10. package/dist/adapters/backend/pty-backend.d.ts.map +1 -1
  11. package/dist/adapters/backend/pty-backend.js +4 -1
  12. package/dist/adapters/backend/pty-backend.js.map +1 -1
  13. package/dist/adapters/backend/tmux-backend.d.ts +22 -1
  14. package/dist/adapters/backend/tmux-backend.d.ts.map +1 -1
  15. package/dist/adapters/backend/tmux-backend.js +124 -36
  16. package/dist/adapters/backend/tmux-backend.js.map +1 -1
  17. package/dist/adapters/backend/tmux-pipe-backend.d.ts +20 -0
  18. package/dist/adapters/backend/tmux-pipe-backend.d.ts.map +1 -1
  19. package/dist/adapters/backend/tmux-pipe-backend.js +86 -20
  20. package/dist/adapters/backend/tmux-pipe-backend.js.map +1 -1
  21. package/dist/adapters/backend/types.d.ts +9 -0
  22. package/dist/adapters/backend/types.d.ts.map +1 -1
  23. package/dist/adapters/backend/types.js.map +1 -1
  24. package/dist/adapters/backend/zellij-backend.js +1 -1
  25. package/dist/adapters/backend/zellij-backend.js.map +1 -1
  26. package/dist/adapters/backend/zellij-observe-backend.d.ts +13 -0
  27. package/dist/adapters/backend/zellij-observe-backend.d.ts.map +1 -1
  28. package/dist/adapters/backend/zellij-observe-backend.js +51 -12
  29. package/dist/adapters/backend/zellij-observe-backend.js.map +1 -1
  30. package/dist/adapters/cli/codex.d.ts.map +1 -1
  31. package/dist/adapters/cli/codex.js +8 -5
  32. package/dist/adapters/cli/codex.js.map +1 -1
  33. package/dist/adapters/cli/shared-hints.d.ts +2 -1
  34. package/dist/adapters/cli/shared-hints.d.ts.map +1 -1
  35. package/dist/adapters/cli/shared-hints.js +25 -3
  36. package/dist/adapters/cli/shared-hints.js.map +1 -1
  37. package/dist/adapters/cli/traex.d.ts.map +1 -1
  38. package/dist/adapters/cli/traex.js +4 -0
  39. package/dist/adapters/cli/traex.js.map +1 -1
  40. package/dist/adapters/cli/types.d.ts +6 -0
  41. package/dist/adapters/cli/types.d.ts.map +1 -1
  42. package/dist/bot-registry.d.ts +23 -0
  43. package/dist/bot-registry.d.ts.map +1 -1
  44. package/dist/bot-registry.js +9 -0
  45. package/dist/bot-registry.js.map +1 -1
  46. package/dist/cli.d.ts.map +1 -1
  47. package/dist/cli.js +368 -16
  48. package/dist/cli.js.map +1 -1
  49. package/dist/core/command-handler.d.ts.map +1 -1
  50. package/dist/core/command-handler.js +7 -5
  51. package/dist/core/command-handler.js.map +1 -1
  52. package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
  53. package/dist/core/dashboard-ipc-server.js +210 -5
  54. package/dist/core/dashboard-ipc-server.js.map +1 -1
  55. package/dist/core/dashboard-rows.d.ts +3 -0
  56. package/dist/core/dashboard-rows.d.ts.map +1 -1
  57. package/dist/core/dashboard-rows.js +4 -1
  58. package/dist/core/dashboard-rows.js.map +1 -1
  59. package/dist/core/maintenance.d.ts +18 -0
  60. package/dist/core/maintenance.d.ts.map +1 -1
  61. package/dist/core/maintenance.js +58 -30
  62. package/dist/core/maintenance.js.map +1 -1
  63. package/dist/core/per-bot-env.d.ts +38 -0
  64. package/dist/core/per-bot-env.d.ts.map +1 -0
  65. package/dist/core/per-bot-env.js +79 -0
  66. package/dist/core/per-bot-env.js.map +1 -0
  67. package/dist/core/session-activity.d.ts +1 -0
  68. package/dist/core/session-activity.d.ts.map +1 -1
  69. package/dist/core/session-activity.js +7 -4
  70. package/dist/core/session-activity.js.map +1 -1
  71. package/dist/core/session-create.d.ts +62 -0
  72. package/dist/core/session-create.d.ts.map +1 -0
  73. package/dist/core/session-create.js +131 -0
  74. package/dist/core/session-create.js.map +1 -0
  75. package/dist/core/session-manager.d.ts +43 -0
  76. package/dist/core/session-manager.d.ts.map +1 -1
  77. package/dist/core/session-manager.js +306 -8
  78. package/dist/core/session-manager.js.map +1 -1
  79. package/dist/core/skills/discovery.d.ts +7 -0
  80. package/dist/core/skills/discovery.d.ts.map +1 -1
  81. package/dist/core/skills/discovery.js +51 -0
  82. package/dist/core/skills/discovery.js.map +1 -1
  83. package/dist/core/trigger-session.d.ts.map +1 -1
  84. package/dist/core/trigger-session.js +5 -2
  85. package/dist/core/trigger-session.js.map +1 -1
  86. package/dist/core/update-check.d.ts +67 -0
  87. package/dist/core/update-check.d.ts.map +1 -0
  88. package/dist/core/update-check.js +159 -0
  89. package/dist/core/update-check.js.map +1 -0
  90. package/dist/core/worker-pool.d.ts.map +1 -1
  91. package/dist/core/worker-pool.js +82 -4
  92. package/dist/core/worker-pool.js.map +1 -1
  93. package/dist/daemon.d.ts.map +1 -1
  94. package/dist/daemon.js +40 -11
  95. package/dist/daemon.js.map +1 -1
  96. package/dist/dashboard/bot-payload.d.ts +2 -0
  97. package/dist/dashboard/bot-payload.d.ts.map +1 -1
  98. package/dist/dashboard/bot-payload.js +2 -0
  99. package/dist/dashboard/bot-payload.js.map +1 -1
  100. package/dist/dashboard/session-cleanup.d.ts +30 -0
  101. package/dist/dashboard/session-cleanup.d.ts.map +1 -0
  102. package/dist/dashboard/session-cleanup.js +47 -0
  103. package/dist/dashboard/session-cleanup.js.map +1 -0
  104. package/dist/dashboard/skill-install-request.d.ts +10 -0
  105. package/dist/dashboard/skill-install-request.d.ts.map +1 -1
  106. package/dist/dashboard/skill-install-request.js +17 -0
  107. package/dist/dashboard/skill-install-request.js.map +1 -1
  108. package/dist/dashboard/web/app.d.ts.map +1 -1
  109. package/dist/dashboard/web/app.js +29 -1
  110. package/dist/dashboard/web/app.js.map +1 -1
  111. package/dist/dashboard/web/bot-defaults.d.ts.map +1 -1
  112. package/dist/dashboard/web/bot-defaults.js +82 -6
  113. package/dist/dashboard/web/bot-defaults.js.map +1 -1
  114. package/dist/dashboard/web/i18n.d.ts.map +1 -1
  115. package/dist/dashboard/web/i18n.js +312 -2
  116. package/dist/dashboard/web/i18n.js.map +1 -1
  117. package/dist/dashboard/web/insights.d.ts.map +1 -1
  118. package/dist/dashboard/web/insights.js +355 -30
  119. package/dist/dashboard/web/insights.js.map +1 -1
  120. package/dist/dashboard/web/sessions.d.ts +2 -0
  121. package/dist/dashboard/web/sessions.d.ts.map +1 -1
  122. package/dist/dashboard/web/sessions.js +457 -30
  123. package/dist/dashboard/web/sessions.js.map +1 -1
  124. package/dist/dashboard/web/settings.d.ts.map +1 -1
  125. package/dist/dashboard/web/settings.js +289 -0
  126. package/dist/dashboard/web/settings.js.map +1 -1
  127. package/dist/dashboard/web/skills.d.ts.map +1 -1
  128. package/dist/dashboard/web/skills.js +183 -10
  129. package/dist/dashboard/web/skills.js.map +1 -1
  130. package/dist/dashboard/web/whiteboards.d.ts +2 -0
  131. package/dist/dashboard/web/whiteboards.d.ts.map +1 -0
  132. package/dist/dashboard/web/whiteboards.js +267 -0
  133. package/dist/dashboard/web/whiteboards.js.map +1 -0
  134. package/dist/dashboard-web/app.js +879 -591
  135. package/dist/dashboard-web/index.html +1 -0
  136. package/dist/dashboard-web/style.css +528 -8
  137. package/dist/dashboard.js +472 -5
  138. package/dist/dashboard.js.map +1 -1
  139. package/dist/global-config.d.ts +6 -0
  140. package/dist/global-config.d.ts.map +1 -1
  141. package/dist/global-config.js +12 -0
  142. package/dist/global-config.js.map +1 -1
  143. package/dist/i18n/en.d.ts.map +1 -1
  144. package/dist/i18n/en.js +21 -1
  145. package/dist/i18n/en.js.map +1 -1
  146. package/dist/i18n/zh.d.ts.map +1 -1
  147. package/dist/i18n/zh.js +21 -1
  148. package/dist/i18n/zh.js.map +1 -1
  149. package/dist/im/lark/card-builder.d.ts +4 -5
  150. package/dist/im/lark/card-builder.d.ts.map +1 -1
  151. package/dist/im/lark/card-builder.js +113 -11
  152. package/dist/im/lark/card-builder.js.map +1 -1
  153. package/dist/im/lark/card-handler.d.ts +3 -2
  154. package/dist/im/lark/card-handler.d.ts.map +1 -1
  155. package/dist/im/lark/card-handler.js +174 -14
  156. package/dist/im/lark/card-handler.js.map +1 -1
  157. package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
  158. package/dist/im/lark/event-dispatcher.js +19 -5
  159. package/dist/im/lark/event-dispatcher.js.map +1 -1
  160. package/dist/im/lark/grant-pending.d.ts.map +1 -1
  161. package/dist/im/lark/grant-pending.js +9 -2
  162. package/dist/im/lark/grant-pending.js.map +1 -1
  163. package/dist/index-daemon.js +5 -0
  164. package/dist/index-daemon.js.map +1 -1
  165. package/dist/platform/bind.d.ts +2 -0
  166. package/dist/platform/bind.d.ts.map +1 -0
  167. package/dist/platform/bind.js +91 -0
  168. package/dist/platform/bind.js.map +1 -0
  169. package/dist/platform/binding.d.ts +22 -0
  170. package/dist/platform/binding.d.ts.map +1 -0
  171. package/dist/platform/binding.js +33 -0
  172. package/dist/platform/binding.js.map +1 -0
  173. package/dist/platform/tunnel-client.d.ts +15 -0
  174. package/dist/platform/tunnel-client.d.ts.map +1 -0
  175. package/dist/platform/tunnel-client.js +193 -0
  176. package/dist/platform/tunnel-client.js.map +1 -0
  177. package/dist/services/bot-config-store.d.ts.map +1 -1
  178. package/dist/services/bot-config-store.js +20 -0
  179. package/dist/services/bot-config-store.js.map +1 -1
  180. package/dist/services/git-worktree.d.ts +10 -0
  181. package/dist/services/git-worktree.d.ts.map +1 -1
  182. package/dist/services/git-worktree.js +71 -26
  183. package/dist/services/git-worktree.js.map +1 -1
  184. package/dist/services/grant-prefs-store.d.ts +3 -0
  185. package/dist/services/grant-prefs-store.d.ts.map +1 -1
  186. package/dist/services/grant-prefs-store.js +17 -2
  187. package/dist/services/grant-prefs-store.js.map +1 -1
  188. package/dist/services/insight/claude-span-reader.d.ts.map +1 -1
  189. package/dist/services/insight/claude-span-reader.js +18 -2
  190. package/dist/services/insight/claude-span-reader.js.map +1 -1
  191. package/dist/services/insight/codex-span-reader.d.ts.map +1 -1
  192. package/dist/services/insight/codex-span-reader.js +17 -1
  193. package/dist/services/insight/codex-span-reader.js.map +1 -1
  194. package/dist/services/insight/report.d.ts.map +1 -1
  195. package/dist/services/insight/report.js +48 -6
  196. package/dist/services/insight/report.js.map +1 -1
  197. package/dist/services/insight/types.d.ts +9 -0
  198. package/dist/services/insight/types.d.ts.map +1 -1
  199. package/dist/services/insight/types.js.map +1 -1
  200. package/dist/services/resumable-session-discovery.d.ts.map +1 -1
  201. package/dist/services/resumable-session-discovery.js +21 -4
  202. package/dist/services/resumable-session-discovery.js.map +1 -1
  203. package/dist/services/skill-registry-store.d.ts +1 -0
  204. package/dist/services/skill-registry-store.d.ts.map +1 -1
  205. package/dist/services/skill-registry-store.js +32 -0
  206. package/dist/services/skill-registry-store.js.map +1 -1
  207. package/dist/services/whiteboard-store.d.ts +67 -0
  208. package/dist/services/whiteboard-store.d.ts.map +1 -0
  209. package/dist/services/whiteboard-store.js +450 -0
  210. package/dist/services/whiteboard-store.js.map +1 -0
  211. package/dist/services/worktree-slug-ai.d.ts.map +1 -1
  212. package/dist/services/worktree-slug-ai.js +3 -1
  213. package/dist/services/worktree-slug-ai.js.map +1 -1
  214. package/dist/setup/bot-config-editor.d.ts +8 -0
  215. package/dist/setup/bot-config-editor.d.ts.map +1 -1
  216. package/dist/setup/bot-config-editor.js +9 -0
  217. package/dist/setup/bot-config-editor.js.map +1 -1
  218. package/dist/skills/definitions.d.ts.map +1 -1
  219. package/dist/skills/definitions.js +118 -0
  220. package/dist/skills/definitions.js.map +1 -1
  221. package/dist/types.d.ts +15 -0
  222. package/dist/types.d.ts.map +1 -1
  223. package/dist/utils/crash-log.d.ts +22 -0
  224. package/dist/utils/crash-log.d.ts.map +1 -0
  225. package/dist/utils/crash-log.js +37 -0
  226. package/dist/utils/crash-log.js.map +1 -0
  227. package/dist/utils/input-gate.d.ts +10 -0
  228. package/dist/utils/input-gate.d.ts.map +1 -1
  229. package/dist/utils/input-gate.js +7 -0
  230. package/dist/utils/input-gate.js.map +1 -1
  231. package/dist/utils/install-diagnostics.d.ts +52 -0
  232. package/dist/utils/install-diagnostics.d.ts.map +1 -0
  233. package/dist/utils/install-diagnostics.js +155 -0
  234. package/dist/utils/install-diagnostics.js.map +1 -0
  235. package/dist/utils/install-info.d.ts +4 -0
  236. package/dist/utils/install-info.d.ts.map +1 -1
  237. package/dist/utils/install-info.js +6 -0
  238. package/dist/utils/install-info.js.map +1 -1
  239. package/dist/utils/stdio-epipe-guard.d.ts +39 -0
  240. package/dist/utils/stdio-epipe-guard.d.ts.map +1 -0
  241. package/dist/utils/stdio-epipe-guard.js +67 -0
  242. package/dist/utils/stdio-epipe-guard.js.map +1 -0
  243. package/dist/worker.js +222 -26
  244. package/dist/worker.js.map +1 -1
  245. package/dist/workflows/daemon-spawn.d.ts +2 -0
  246. package/dist/workflows/daemon-spawn.d.ts.map +1 -1
  247. package/package.json +1 -1
package/dist/dashboard.js CHANGED
@@ -27,18 +27,30 @@ 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';
40
49
  import { isValidRoleProfileId } from './services/role-profile-store.js';
41
50
  import { mergeSafeInsightOverviews } from './services/insight/report.js';
51
+ import { readPlatformBinding } from './platform/binding.js';
52
+ import { startPlatformTunnelClient } from './platform/tunnel-client.js';
53
+ import { cleanupIdleSessions, parseIdleCleanupHours } from './dashboard/session-cleanup.js';
42
54
  const SECRET_PATH = join(homedir(), '.botmux', '.dashboard-secret');
43
55
  const TOKEN_PATH = join(homedir(), '.botmux', '.dashboard-token');
44
56
  /** Per-daemon budget for the cross-daemon insight overview fan-out — bounds
@@ -116,6 +128,7 @@ function resolveDashboardSettings() {
116
128
  repoPickerMode: global.repoPickerMode ?? 'all',
117
129
  maintenance: global.maintenance ?? {},
118
130
  localDevInstall: isLocalDevInstall(),
131
+ whiteboard: { enabled: global.whiteboard?.enabled === true },
119
132
  };
120
133
  }
121
134
  async function readJsonBody(req) {
@@ -125,6 +138,67 @@ async function readJsonBody(req) {
125
138
  const raw = Buffer.concat(chunks).toString('utf8').trim();
126
139
  return raw ? JSON.parse(raw) : {};
127
140
  }
141
+ /** Fast in-process guard against double-clicks within this dashboard process.
142
+ * Cross-process serialization against the maintenance auto-update (a different
143
+ * process) is handled separately by the shared file lock in the run route. */
144
+ let updateInFlight = false;
145
+ // Cache the upstream version/changelog lookups so the nav-badge check + the
146
+ // Settings card don't hammer the npm registry / GitHub on every page load.
147
+ // GitHub's unauthenticated API is only 60 req/h per IP, so caching the changelog
148
+ // also keeps us from exhausting it. Failures cache briefly so they self-heal.
149
+ const LATEST_TTL_MS = 30 * 60_000;
150
+ const CHANGELOG_TTL_MS = 15 * 60_000;
151
+ const FAILURE_TTL_MS = 60_000;
152
+ let latestVersionCache = null;
153
+ let changelogCache = null;
154
+ async function cachedLatestVersion(now = Date.now()) {
155
+ const ttl = latestVersionCache?.value ? LATEST_TTL_MS : FAILURE_TTL_MS;
156
+ if (latestVersionCache && now - latestVersionCache.at < ttl)
157
+ return latestVersionCache.value;
158
+ const value = await fetchLatestVersion();
159
+ latestVersionCache = { value, at: now };
160
+ return value;
161
+ }
162
+ async function cachedChangelog(current, now = Date.now()) {
163
+ const ttl = changelogCache?.value.ok ? CHANGELOG_TTL_MS : FAILURE_TTL_MS;
164
+ if (changelogCache && changelogCache.key === current && now - changelogCache.at < ttl)
165
+ return changelogCache.value;
166
+ const value = await fetchReleasesSince(current);
167
+ changelogCache = { key: current, value, at: now };
168
+ return value;
169
+ }
170
+ /**
171
+ * Run `npm install -g botmux@latest` for the manual-update flow WITHOUT blocking
172
+ * the event loop (async spawn, not execSync — the dashboard must keep serving
173
+ * during the ~10-30s install). Resolves on exit 0; rejects with the tail of
174
+ * stdout/stderr on a non-zero exit, spawn error, or 3-minute timeout. Args are
175
+ * a fixed literal — no shell interpolation of untrusted input.
176
+ */
177
+ function runNpmInstallLatest() {
178
+ return new Promise((resolve, reject) => {
179
+ const child = spawn('npm', ['install', '-g', 'botmux@latest'], {
180
+ env: process.env,
181
+ stdio: ['ignore', 'pipe', 'pipe'],
182
+ shell: process.platform === 'win32', // resolve npm.cmd on Windows
183
+ });
184
+ let tail = '';
185
+ const capture = (d) => { tail = (tail + d.toString()).slice(-2000); };
186
+ child.stdout?.on('data', capture);
187
+ child.stderr?.on('data', capture);
188
+ const timer = setTimeout(() => {
189
+ child.kill('SIGKILL');
190
+ reject(new Error('npm install timed out after 180s'));
191
+ }, 180_000);
192
+ child.on('error', (e) => { clearTimeout(timer); reject(e); });
193
+ child.on('exit', (code) => {
194
+ clearTimeout(timer);
195
+ if (code === 0)
196
+ resolve();
197
+ else
198
+ reject(new Error(`npm exited ${code}: ${tail.trim().slice(-500)}`));
199
+ });
200
+ });
201
+ }
128
202
  /**
129
203
  * Attach to one daemon: hydrate its sessions/schedules into the aggregator,
130
204
  * THEN open the SSE subscription. Order matters — hydrating after subscribe
@@ -507,12 +581,33 @@ function sanitizeSkillForDashboard(skill) {
507
581
  source: { ...skill.source, url: redactGitUrlCredentials(skill.source.url) },
508
582
  };
509
583
  }
584
+ function dashboardSkillCliIds() {
585
+ const ids = new Set();
586
+ try {
587
+ for (const cliId of configuredCliIds().values())
588
+ ids.add(cliId);
589
+ }
590
+ catch {
591
+ // Fall back to daemon descriptors below when persistent config is unavailable.
592
+ }
593
+ for (const bot of registry.list()) {
594
+ if (bot.cliId)
595
+ ids.add(bot.cliId);
596
+ }
597
+ return [...ids];
598
+ }
510
599
  function dashboardSkillsPayload() {
511
600
  const globalSkills = readGlobalConfig().skills ?? {};
601
+ const nativeSkillGroups = discoverNativeCliSkillGroups(dashboardSkillCliIds())
602
+ .map(group => ({
603
+ ...group,
604
+ skills: group.skills.map(sanitizeSkillForDashboard),
605
+ }));
512
606
  return {
513
607
  skills: Object.values(readSkillRegistry().skills)
514
608
  .sort((a, b) => a.name.localeCompare(b.name))
515
609
  .map(sanitizeSkillForDashboard),
610
+ nativeSkillGroups,
516
611
  trustProjectSkills: globalSkills.trustProjectSkills ?? 'off',
517
612
  delivery: globalSkills.delivery ?? 'auto',
518
613
  };
@@ -700,6 +795,54 @@ const server = createServer(async (req, res) => {
700
795
  });
701
796
  return jsonRes(res, 200, { sessions });
702
797
  }
798
+ if (req.method === 'POST' && url.pathname === '/api/sessions/cleanup-idle') {
799
+ let body;
800
+ try {
801
+ body = await readJsonBody(req);
802
+ }
803
+ catch {
804
+ return jsonRes(res, 400, { ok: false, error: 'bad_json' });
805
+ }
806
+ const olderThanHours = parseIdleCleanupHours(body?.olderThanHours);
807
+ if (!olderThanHours)
808
+ return jsonRes(res, 400, { ok: false, error: 'invalid_threshold' });
809
+ // WYSIWYG: the UI scopes cleanup to the rows currently visible under the
810
+ // page filters and sends their sessionIds, so the closed set matches the
811
+ // confirmed count. We still hand the scoped rows to cleanupIdleSessions,
812
+ // which re-validates each is a genuine idle candidate — a stale/forged id
813
+ // can never close a non-idle session. Omitting sessionIds (e.g. an older
814
+ // client) falls back to a deployment-wide sweep. Cap the id set so a giant
815
+ // body can't blow up the Set build.
816
+ const idScope = Array.isArray(body?.sessionIds)
817
+ ? new Set(body.sessionIds.slice(0, 10000).map(String))
818
+ : null;
819
+ const rows = aggregator.getSessions();
820
+ const scoped = idScope ? rows.filter(s => idScope.has(s.sessionId)) : rows;
821
+ const result = await cleanupIdleSessions(scoped, olderThanHours, async (s) => {
822
+ try {
823
+ const upstream = await proxyToDaemon(s.larkAppId, `/api/sessions/${encodeURIComponent(s.sessionId)}/close`, { method: 'POST' });
824
+ const text = await upstream.text();
825
+ let parsed = null;
826
+ try {
827
+ parsed = JSON.parse(text);
828
+ }
829
+ catch { /* tolerate */ }
830
+ // The daemon close route always replies 200 {ok:true}; treat anything
831
+ // else (incl. an unparseable/missing body) as a failure rather than a
832
+ // silent success.
833
+ const ok = upstream.ok && parsed?.ok === true;
834
+ return {
835
+ sessionId: s.sessionId,
836
+ ok,
837
+ error: ok ? undefined : (parsed?.error ?? `http_${upstream.status}`),
838
+ };
839
+ }
840
+ catch (e) {
841
+ return { sessionId: s.sessionId, ok: false, error: e?.message ?? String(e) };
842
+ }
843
+ });
844
+ return jsonRes(res, 200, result);
845
+ }
703
846
  if (req.method === 'GET' && url.pathname === '/api/insights/summary') {
704
847
  const limit = Math.min(Math.max(parseInt(url.searchParams.get('limit') ?? '200', 10) || 200, 1), 500);
705
848
  // Per-daemon timeout + isolate failures: an upstream insight parse can be
@@ -774,6 +917,16 @@ const server = createServer(async (req, res) => {
774
917
  mergeGlobalConfig({ repoPickerMode: v });
775
918
  touched = true;
776
919
  }
920
+ if ('whiteboard' in body) {
921
+ const raw = body.whiteboard;
922
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
923
+ return jsonRes(res, 400, { ok: false, error: 'invalid_whiteboard' });
924
+ const wb = raw;
925
+ if (typeof wb.enabled !== 'boolean')
926
+ return jsonRes(res, 400, { ok: false, error: 'invalid_whiteboard_enabled' });
927
+ mergeGlobalConfig({ whiteboard: { enabled: wb.enabled } });
928
+ touched = true;
929
+ }
777
930
  if ('maintenance' in body) {
778
931
  const r = parseMaintenancePatch(body.maintenance);
779
932
  if (!r.ok)
@@ -813,6 +966,96 @@ const server = createServer(async (req, res) => {
813
966
  return jsonRes(res, 400, { ok: false, error: 'empty_patch' });
814
967
  return jsonRes(res, 200, { ok: true, settings: resolveDashboardSettings() });
815
968
  }
969
+ // ─── Version & manual update ─────────────────────────────────────────────
970
+ // `npm install -g` and a host restart are privileged: none of these paths
971
+ // are on PUBLIC_READ_PATHS, so decideDashboardAuth already 401s an
972
+ // unauthenticated caller (in both normal and public-read mode). The explicit
973
+ // `authed` guards on the two mutations are defense-in-depth for host actions.
974
+ if (req.method === 'GET' && url.pathname === '/api/update/status') {
975
+ const current = resolveCurrentVersion();
976
+ // Compare against the npm `latest` dist-tag (always stable; the update
977
+ // button installs `@latest`). isNewerVersion uses semver precedence, so a
978
+ // canary running AHEAD of the latest stable (e.g. 2.87.0-canary.0 vs
979
+ // 2.86.0) is NOT flagged behind — exactly the canary case we want.
980
+ const latest = await cachedLatestVersion();
981
+ return jsonRes(res, 200, {
982
+ current,
983
+ latest,
984
+ behind: !!latest && isNewerVersion(latest, current),
985
+ localDevInstall: isLocalDevInstall(),
986
+ node: checkNode(),
987
+ installs: detectBotmuxInstalls(),
988
+ });
989
+ }
990
+ if (req.method === 'GET' && url.pathname === '/api/update/changelog') {
991
+ const current = resolveCurrentVersion();
992
+ const result = await cachedChangelog(current);
993
+ return jsonRes(res, 200, {
994
+ current,
995
+ ok: result.ok,
996
+ rateLimited: result.rateLimited === true,
997
+ releases: result.releases,
998
+ releasesUrl: `https://github.com/${GITHUB_REPO}/releases`,
999
+ });
1000
+ }
1001
+ if (req.method === 'POST' && url.pathname === '/api/update/run') {
1002
+ if (!authed)
1003
+ return jsonRes(res, 401, { ok: false, error: 'unauthorized' });
1004
+ if (isLocalDevInstall())
1005
+ return jsonRes(res, 400, { ok: false, error: 'local_dev_no_update' });
1006
+ const node = checkNode();
1007
+ if (!node.ok)
1008
+ return jsonRes(res, 400, { ok: false, error: 'node_too_old', node });
1009
+ if (updateInFlight)
1010
+ return jsonRes(res, 409, { ok: false, error: 'update_in_flight' });
1011
+ updateInFlight = true;
1012
+ const oldVersion = botmuxVersion();
1013
+ // Acquire the shared cross-process lock so a scheduled maintenance
1014
+ // auto-update (running in the bot-0 daemon) can't `npm install -g` at the
1015
+ // same time. `acquired` distinguishes "lock held by maintenance" (409)
1016
+ // from "npm itself failed" (500). Short wait: don't block the request on a
1017
+ // full in-progress install — report busy fast.
1018
+ let acquired = false;
1019
+ try {
1020
+ await withFileLock(npmGlobalUpdateLockTarget(), async () => {
1021
+ acquired = true;
1022
+ await runNpmInstallLatest();
1023
+ }, { maxWaitMs: 2_000 });
1024
+ }
1025
+ catch (e) {
1026
+ if (!acquired)
1027
+ return jsonRes(res, 409, { ok: false, error: 'update_in_flight' });
1028
+ return jsonRes(res, 500, { ok: false, error: 'npm_failed', detail: e instanceof Error ? e.message : String(e) });
1029
+ }
1030
+ finally {
1031
+ updateInFlight = false;
1032
+ }
1033
+ const newVersion = botmuxVersion();
1034
+ return jsonRes(res, 200, { ok: true, oldVersion, newVersion, changed: newVersion !== oldVersion });
1035
+ }
1036
+ if (req.method === 'POST' && url.pathname === '/api/update/restart') {
1037
+ if (!authed)
1038
+ return jsonRes(res, 401, { ok: false, error: 'unauthorized' });
1039
+ let body = {};
1040
+ try {
1041
+ const parsed = await readJsonBody(req);
1042
+ if (parsed && typeof parsed === 'object')
1043
+ body = parsed;
1044
+ }
1045
+ catch { /* empty / bad body → plain restart */ }
1046
+ const upd = body.update && typeof body.update === 'object' ? body.update : null;
1047
+ // After a manual update, leave an `update` breadcrumb so the fresh daemon
1048
+ // DMs the owner the changelog (reuses the restart-report pipeline). A plain
1049
+ // restart leaves none here; cmdRestart writes a `manual` breadcrumb itself.
1050
+ if (upd && typeof upd.oldVersion === 'string' && typeof upd.newVersion === 'string' && upd.oldVersion !== upd.newVersion) {
1051
+ try {
1052
+ writeRestartIntent({ kind: 'update', oldVersion: upd.oldVersion, newVersion: upd.newVersion, at: new Date().toISOString() });
1053
+ }
1054
+ catch { /* breadcrumb is best-effort */ }
1055
+ }
1056
+ spawnDetachedRestart('dashboard');
1057
+ return jsonRes(res, 200, { ok: true });
1058
+ }
816
1059
  if (req.method === 'GET' && url.pathname === '/api/skills') {
817
1060
  return jsonRes(res, 200, dashboardSkillsPayload());
818
1061
  }
@@ -865,6 +1108,30 @@ const server = createServer(async (req, res) => {
865
1108
  return jsonRes(res, 400, { ok: false, error: redactGitUrlCredentials(err?.message ?? String(err)) });
866
1109
  }
867
1110
  }
1111
+ if (req.method === 'POST' && url.pathname === '/api/skills/install-local-links') {
1112
+ let parsed;
1113
+ try {
1114
+ parsed = await readJsonBody(req);
1115
+ }
1116
+ catch {
1117
+ return jsonRes(res, 400, { ok: false, error: 'bad_json' });
1118
+ }
1119
+ const sources = parseInstallLocalLinksSources(parsed);
1120
+ if (sources.length === 0)
1121
+ return jsonRes(res, 400, { ok: false, error: 'sources_required' });
1122
+ if (sources.length > MAX_LOCAL_LINK_SOURCES)
1123
+ return jsonRes(res, 400, { ok: false, error: 'too_many_sources' });
1124
+ try {
1125
+ const skills = installLocalSkillLinks(sources);
1126
+ // Frontend re-fetches /api/skills (refresh()) after success, so we keep
1127
+ // the response lean — no need to spread a full dashboardSkillsPayload()
1128
+ // (which would re-run the native-skill discovery scan a second time).
1129
+ return jsonRes(res, 200, { ok: true, installed: skills.map(sanitizeSkillForDashboard) });
1130
+ }
1131
+ catch (err) {
1132
+ return jsonRes(res, 400, { ok: false, error: redactGitUrlCredentials(err?.message ?? String(err)) });
1133
+ }
1134
+ }
868
1135
  let mSkillJob;
869
1136
  if (req.method === 'GET' && (mSkillJob = url.pathname.match(/^\/api\/skills\/jobs\/([^/]+)$/))) {
870
1137
  const job = skillJobs.get(decodeURIComponent(mSkillJob[1]));
@@ -908,6 +1175,28 @@ const server = createServer(async (req, res) => {
908
1175
  ...dashboardSkillsPayload(),
909
1176
  });
910
1177
  }
1178
+ if (req.method === 'GET' && url.pathname === '/api/whiteboards') {
1179
+ return jsonRes(res, 200, { enabled: whiteboardEnabled(), whiteboards: listWhiteboards() });
1180
+ }
1181
+ const mWhiteboard = url.pathname.match(/^\/api\/whiteboards\/([^/]+)$/);
1182
+ if (req.method === 'GET' && mWhiteboard) {
1183
+ try {
1184
+ const id = decodeURIComponent(mWhiteboard[1]);
1185
+ return jsonRes(res, 200, { enabled: whiteboardEnabled(), id, content: readWhiteboard(id, { allowDisabled: true }) });
1186
+ }
1187
+ catch (err) {
1188
+ return jsonRes(res, 404, { ok: false, error: err?.message ?? 'whiteboard_not_found' });
1189
+ }
1190
+ }
1191
+ if (req.method === 'DELETE' && mWhiteboard) {
1192
+ try {
1193
+ const id = decodeURIComponent(mWhiteboard[1]);
1194
+ return jsonRes(res, 200, deleteWhiteboard(id));
1195
+ }
1196
+ catch (err) {
1197
+ return jsonRes(res, 400, { ok: false, error: err?.message ?? 'whiteboard_delete_failed' });
1198
+ }
1199
+ }
911
1200
  if (await handleConnectorApi(req, res, url)) {
912
1201
  return;
913
1202
  }
@@ -1011,7 +1300,7 @@ const server = createServer(async (req, res) => {
1011
1300
  return jsonRes(res, 200, { job });
1012
1301
  }
1013
1302
  let m;
1014
- if (req.method === 'POST' && (m = url.pathname.match(/^\/api\/sessions\/([^/]+)\/(close|locate|resume)$/))) {
1303
+ if (req.method === 'POST' && (m = url.pathname.match(/^\/api\/sessions\/([^/]+)\/(close|locate|resume|restart|start)$/))) {
1015
1304
  const sid = decodeURIComponent(m[1]);
1016
1305
  const op = m[2];
1017
1306
  const owner = aggregator.ownerOf(sid);
@@ -1658,6 +1947,24 @@ const server = createServer(async (req, res) => {
1658
1947
  res.end(await upstream.text());
1659
1948
  return;
1660
1949
  }
1950
+ // PUT /api/bots/:appId/env — proxy to that bot's daemon. Body
1951
+ // `{ env: string }` (raw JSON text; '' = clear).
1952
+ let mBotEnv;
1953
+ if (req.method === 'PUT' && (mBotEnv = url.pathname.match(/^\/api\/bots\/([^/]+)\/env$/))) {
1954
+ const appId = decodeURIComponent(mBotEnv[1]);
1955
+ const chunks = [];
1956
+ for await (const c of req)
1957
+ chunks.push(c);
1958
+ const raw = Buffer.concat(chunks).toString('utf8') || '{}';
1959
+ const upstream = await proxyToDaemon(appId, `/api/bot-env`, {
1960
+ method: 'PUT',
1961
+ headers: { 'content-type': 'application/json' },
1962
+ body: raw,
1963
+ });
1964
+ res.writeHead(upstream.status, { 'content-type': 'application/json' });
1965
+ res.end(await upstream.text());
1966
+ return;
1967
+ }
1661
1968
  // PUT /api/bots/:appId/sandbox — proxy to that bot's daemon. Body `{ enabled: boolean }`.
1662
1969
  let mBotSandbox;
1663
1970
  if (req.method === 'PUT' && (mBotSandbox = url.pathname.match(/^\/api\/bots\/([^/]+)\/sandbox$/))) {
@@ -1713,7 +2020,8 @@ const server = createServer(async (req, res) => {
1713
2020
  return;
1714
2021
  }
1715
2022
  // PUT /api/bots/:appId/grant-prefs — proxy to that bot's daemon. Body carries
1716
- // any subset of `{ restrictGrantCommands?: boolean, messageQuotaDefaultLimit?: number|null }`.
2023
+ // any subset of `{ restrictGrantCommands?: boolean, autoGrantRequestCards?: boolean,
2024
+ // messageQuotaDefaultLimit?: number|null }`.
1717
2025
  let mBotGrantPrefs;
1718
2026
  if (req.method === 'PUT' && (mBotGrantPrefs = url.pathname.match(/^\/api\/bots\/([^/]+)\/grant-prefs$/))) {
1719
2027
  const appId = decodeURIComponent(mBotGrantPrefs[1]);
@@ -1841,6 +2149,133 @@ const server = createServer(async (req, res) => {
1841
2149
  res.end(upstreamJson ? JSON.stringify(upstreamJson) : upstreamText);
1842
2150
  return;
1843
2151
  }
2152
+ // Dashboard「创建会话」:建飞书群 + 拉选中的 bot,然后按协作模式给各 bot 拉起/暂存
2153
+ // 一条 chat-scope 会话。一起开工=每个被选 bot 各起一条;lead 分配=只起 lead,由它
2154
+ // 在群里 @ 拉起 sub bot。in_progress=立即开跑;backlog=入待办池(parked,等激活)。
2155
+ if (req.method === 'POST' && url.pathname === '/api/sessions/create') {
2156
+ let parsed;
2157
+ try {
2158
+ const chunks = [];
2159
+ for await (const c of req)
2160
+ chunks.push(c);
2161
+ parsed = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
2162
+ }
2163
+ catch {
2164
+ return jsonRes(res, 400, { ok: false, error: 'bad_json' });
2165
+ }
2166
+ const content = typeof parsed.content === 'string' ? parsed.content.replace(/\s+$/u, '') : '';
2167
+ if (!content.trim())
2168
+ return jsonRes(res, 400, { ok: false, error: 'empty_content' });
2169
+ if (content.length > 8000)
2170
+ return jsonRes(res, 400, { ok: false, error: 'content_too_long' });
2171
+ const selectedIds = Array.isArray(parsed.larkAppIds)
2172
+ ? Array.from(new Set(parsed.larkAppIds.filter((x) => typeof x === 'string')))
2173
+ : [];
2174
+ if (selectedIds.length === 0)
2175
+ return jsonRes(res, 400, { ok: false, error: 'larkAppIds_required' });
2176
+ const mode = parsed.mode === 'lead' ? 'lead' : parsed.mode === 'all' ? 'all' : null;
2177
+ if (!mode)
2178
+ return jsonRes(res, 400, { ok: false, error: 'bad_mode' });
2179
+ const column = parsed.column === 'backlog' ? 'backlog' : parsed.column === 'in_progress' ? 'in_progress' : null;
2180
+ if (!column)
2181
+ return jsonRes(res, 400, { ok: false, error: 'bad_column' });
2182
+ const bindWorkingDir = typeof parsed.bindWorkingDir === 'string' && parsed.bindWorkingDir.trim()
2183
+ ? parsed.bindWorkingDir.trim() : undefined;
2184
+ const name = typeof parsed.name === 'string' && parsed.name.trim() ? parsed.name.trim().slice(0, 60) : undefined;
2185
+ // 解析 creator:lead 模式 = lead bot;一起开工 = pickCreatorForGroup 在选中里挑一个在线的。
2186
+ let creatorLarkAppId;
2187
+ if (mode === 'lead') {
2188
+ const leadLarkAppId = typeof parsed.leadLarkAppId === 'string' ? parsed.leadLarkAppId : '';
2189
+ if (!leadLarkAppId || !selectedIds.includes(leadLarkAppId)) {
2190
+ return jsonRes(res, 400, { ok: false, error: 'bad_lead' });
2191
+ }
2192
+ if (!registry.getByAppId(leadLarkAppId))
2193
+ return jsonRes(res, 503, { ok: false, error: 'lead_offline' });
2194
+ creatorLarkAppId = leadLarkAppId;
2195
+ }
2196
+ else {
2197
+ const pick = pickCreatorForGroup(selectedIds, (id) => {
2198
+ const d = registry.getByAppId(id);
2199
+ return d ? { larkAppId: d.larkAppId, resolvedAllowedUsers: d.resolvedAllowedUsers ?? [] } : undefined;
2200
+ });
2201
+ if (!pick)
2202
+ return jsonRes(res, 503, { ok: false, error: 'no_online_daemon' });
2203
+ creatorLarkAppId = pick.creatorLarkAppId;
2204
+ }
2205
+ // creator 作用域里的操作者 open_id(首个 ou_ allowedUser)——用于邀请进群 + 转群主 + @通知。
2206
+ // 同时取 on_(union_id,租户内跨 app 稳定)做兜底邀请:lead 模式强制 creator=lead,
2207
+ // 万一 lead 的 allowlist 没有 ou_ 条目,open_id 解析不到、操作者就进不了群——union_id
2208
+ // 不受 app 作用域影响,仍能把人拉进来(createGroupWithBots 走 ownerUnionIds 通道)。
2209
+ const creatorDesc = registry.getByAppId(creatorLarkAppId);
2210
+ const allowed = creatorDesc.resolvedAllowedUsers ?? [];
2211
+ const userOpenId = allowed.find(u => u.startsWith('ou_'));
2212
+ const ownerUnionIds = allowed.filter(u => u.startsWith('on_'));
2213
+ // 建群(拉所有选中 bot + 邀请操作者 + 转群主 + @通知 + 可选绑 oncall 工作目录)。
2214
+ let groupResp = null;
2215
+ try {
2216
+ const groupUpstream = await proxyToDaemon(creatorLarkAppId, '/api/groups/create', {
2217
+ method: 'POST',
2218
+ headers: { 'content-type': 'application/json' },
2219
+ body: JSON.stringify({
2220
+ name,
2221
+ larkAppIds: selectedIds,
2222
+ userOpenIds: userOpenId ? [userOpenId] : [],
2223
+ ownerUnionIds,
2224
+ transferOwnerTo: userOpenId,
2225
+ notifyOwnerOpenId: userOpenId,
2226
+ bindWorkingDir,
2227
+ }),
2228
+ });
2229
+ groupResp = await groupUpstream.json().catch(() => null);
2230
+ if (!groupUpstream.ok || !groupResp?.ok || typeof groupResp.chatId !== 'string') {
2231
+ return jsonRes(res, 502, { ok: false, error: groupResp?.error ?? `group_create_http_${groupUpstream.status}` });
2232
+ }
2233
+ }
2234
+ catch {
2235
+ return jsonRes(res, 502, { ok: false, error: 'group_create_proxy_failed' });
2236
+ }
2237
+ const chatId = groupResp.chatId;
2238
+ const invalidBotIds = Array.isArray(groupResp.invalidBotIds) ? groupResp.invalidBotIds : [];
2239
+ // spawn 目标:lead 模式只有 lead;一起开工是所有成功入群的选中 bot。
2240
+ const joinedIds = selectedIds.filter(id => !invalidBotIds.includes(id) && !!registry.getByAppId(id));
2241
+ const targets = mode === 'lead'
2242
+ ? (joinedIds.includes(creatorLarkAppId) ? [creatorLarkAppId] : [])
2243
+ : joinedIds;
2244
+ if (targets.length === 0) {
2245
+ return jsonRes(res, 200, { ok: true, chatId, shareLink: groupResp.shareLink, spawned: [], failed: [], warning: 'no_spawn_target' });
2246
+ }
2247
+ const bots = liveBots();
2248
+ const nameOf = (id) => bots.find(b => b.larkAppId === id)?.botName ?? id;
2249
+ const spawned = [];
2250
+ const failed = [];
2251
+ await Promise.all(targets.map(async (appId) => {
2252
+ const role = mode === 'lead' ? 'lead' : (targets.length > 1 ? 'collab' : 'solo');
2253
+ // lead 的 coworker = 所有 sub(除自己);collab 的 coworker = 其它并列 bot(除自己)。
2254
+ const coworkerIds = (mode === 'lead' ? selectedIds : targets).filter(id => id !== appId);
2255
+ const coworkers = coworkerIds.map(id => ({ name: nameOf(id) }));
2256
+ try {
2257
+ const up = await proxyToDaemon(appId, '/api/sessions/spawn', {
2258
+ method: 'POST',
2259
+ headers: { 'content-type': 'application/json' },
2260
+ body: JSON.stringify({
2261
+ chatId, content, column, role, coworkers,
2262
+ postBanner: appId === creatorLarkAppId,
2263
+ }),
2264
+ });
2265
+ const b = await up.json().catch(() => null);
2266
+ if (up.ok && b?.ok)
2267
+ spawned.push(appId);
2268
+ else
2269
+ failed.push({ larkAppId: appId, error: b?.error ?? `http_${up.status}` });
2270
+ }
2271
+ catch (e) {
2272
+ failed.push({ larkAppId: appId, error: e?.message ?? String(e) });
2273
+ }
2274
+ }));
2275
+ return jsonRes(res, 200, {
2276
+ ok: true, chatId, shareLink: groupResp.shareLink, mode, column, spawned, failed,
2277
+ });
2278
+ }
1844
2279
  // Public SSE — relays aggregator's listener events
1845
2280
  if (req.method === 'GET' && url.pathname === '/events') {
1846
2281
  res.writeHead(200, {
@@ -1899,6 +2334,7 @@ listenWithProbe({
1899
2334
  logger.warn(`[dashboard] Failed to persist port to ${PORT_PATH}: ${e.message}`);
1900
2335
  }
1901
2336
  logger.info(`[dashboard] listening on ${config.dashboard.host}:${port}`);
2337
+ startPlatformTunnelIfBound();
1902
2338
  }).catch((err) => {
1903
2339
  logger.error(`[dashboard] could not bind near ${config.dashboard.host}:${config.dashboard.port} after probing — set BOTMUX_DASHBOARD_PORT to a free port. ${err.message}`);
1904
2340
  process.exit(1);
@@ -1913,12 +2349,43 @@ const federationSync = setInterval(() => {
1913
2349
  .catch(() => { });
1914
2350
  }, 2 * 60 * 1000);
1915
2351
  federationSync.unref();
2352
+ // 中心化平台隧道(已绑定才启动;每台机器一个,跑在 dashboard 进程里)
2353
+ let platformTunnel = null;
2354
+ function readBotmuxVersion() {
2355
+ try {
2356
+ const pkg = JSON.parse(readFileSync(join(dirname(__dirname), 'package.json'), 'utf8'));
2357
+ return pkg.version || 'unknown';
2358
+ }
2359
+ catch {
2360
+ return 'unknown';
2361
+ }
2362
+ }
2363
+ function startPlatformTunnelIfBound() {
2364
+ try {
2365
+ const binding = readPlatformBinding();
2366
+ if (!binding)
2367
+ return;
2368
+ const version = readBotmuxVersion();
2369
+ platformTunnel = startPlatformTunnelClient({
2370
+ binding,
2371
+ getDashboardPort: () => boundDashboardPort,
2372
+ getDashboardToken: () => activeToken,
2373
+ getVersion: () => version,
2374
+ log: (msg, extra) => logger.info(`[platform-tunnel] ${msg}${extra ? ' ' + JSON.stringify(extra) : ''}`),
2375
+ });
2376
+ logger.info(`[platform-tunnel] 绑定到 ${binding.platformUrl},启动隧道`);
2377
+ }
2378
+ catch (e) {
2379
+ logger.warn(`[platform-tunnel] 启动失败: ${e.message}`);
2380
+ }
2381
+ }
1916
2382
  // Graceful shutdown
1917
2383
  function shutdown() {
1918
2384
  for (const off of subs.values())
1919
2385
  off();
1920
2386
  subs.clear();
1921
2387
  registry.stop();
2388
+ platformTunnel?.stop();
1922
2389
  server.close(() => process.exit(0));
1923
2390
  // Hard-exit fallback after 5s
1924
2391
  setTimeout(() => process.exit(0), 5_000).unref();