botmux 2.88.0 → 2.89.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 (178) hide show
  1. package/dist/bot-registry.d.ts +2 -0
  2. package/dist/bot-registry.d.ts.map +1 -1
  3. package/dist/bot-registry.js +4 -0
  4. package/dist/bot-registry.js.map +1 -1
  5. package/dist/cli/dashboard-endpoint.d.ts.map +1 -1
  6. package/dist/cli/dashboard-endpoint.js +9 -3
  7. package/dist/cli/dashboard-endpoint.js.map +1 -1
  8. package/dist/cli.d.ts.map +1 -1
  9. package/dist/cli.js +11 -3
  10. package/dist/cli.js.map +1 -1
  11. package/dist/core/command-handler.d.ts.map +1 -1
  12. package/dist/core/command-handler.js +9 -1
  13. package/dist/core/command-handler.js.map +1 -1
  14. package/dist/core/dashboard-command/groups.d.ts +20 -0
  15. package/dist/core/dashboard-command/groups.d.ts.map +1 -0
  16. package/dist/core/dashboard-command/groups.js +52 -0
  17. package/dist/core/dashboard-command/groups.js.map +1 -0
  18. package/dist/core/dashboard-command/index.d.ts +30 -0
  19. package/dist/core/dashboard-command/index.d.ts.map +1 -0
  20. package/dist/core/dashboard-command/index.js +81 -0
  21. package/dist/core/dashboard-command/index.js.map +1 -0
  22. package/dist/core/dashboard-command/overview.d.ts +29 -0
  23. package/dist/core/dashboard-command/overview.d.ts.map +1 -0
  24. package/dist/core/dashboard-command/overview.js +61 -0
  25. package/dist/core/dashboard-command/overview.js.map +1 -0
  26. package/dist/core/dashboard-command/owner-gate.d.ts +41 -0
  27. package/dist/core/dashboard-command/owner-gate.d.ts.map +1 -0
  28. package/dist/core/dashboard-command/owner-gate.js +40 -0
  29. package/dist/core/dashboard-command/owner-gate.js.map +1 -0
  30. package/dist/core/dashboard-command/schedules.d.ts +20 -0
  31. package/dist/core/dashboard-command/schedules.d.ts.map +1 -0
  32. package/dist/core/dashboard-command/schedules.js +47 -0
  33. package/dist/core/dashboard-command/schedules.js.map +1 -0
  34. package/dist/core/dashboard-command/sessions.d.ts +22 -0
  35. package/dist/core/dashboard-command/sessions.d.ts.map +1 -0
  36. package/dist/core/dashboard-command/sessions.js +43 -0
  37. package/dist/core/dashboard-command/sessions.js.map +1 -0
  38. package/dist/core/dashboard-command/settings.d.ts +21 -0
  39. package/dist/core/dashboard-command/settings.d.ts.map +1 -0
  40. package/dist/core/dashboard-command/settings.js +55 -0
  41. package/dist/core/dashboard-command/settings.js.map +1 -0
  42. package/dist/core/dashboard-command/stub.d.ts +16 -0
  43. package/dist/core/dashboard-command/stub.d.ts.map +1 -0
  44. package/dist/core/dashboard-command/stub.js +28 -0
  45. package/dist/core/dashboard-command/stub.js.map +1 -0
  46. package/dist/core/dashboard-command/workflows.d.ts +20 -0
  47. package/dist/core/dashboard-command/workflows.d.ts.map +1 -0
  48. package/dist/core/dashboard-command/workflows.js +45 -0
  49. package/dist/core/dashboard-command/workflows.js.map +1 -0
  50. package/dist/core/passthrough-commands.d.ts.map +1 -1
  51. package/dist/core/passthrough-commands.js +1 -1
  52. package/dist/core/passthrough-commands.js.map +1 -1
  53. package/dist/core/session-create.d.ts +0 -1
  54. package/dist/core/session-create.d.ts.map +1 -1
  55. package/dist/core/session-create.js +0 -3
  56. package/dist/core/session-create.js.map +1 -1
  57. package/dist/core/session-manager.d.ts.map +1 -1
  58. package/dist/core/session-manager.js +3 -2
  59. package/dist/core/session-manager.js.map +1 -1
  60. package/dist/daemon-internal-client-wrapper.d.ts +41 -0
  61. package/dist/daemon-internal-client-wrapper.d.ts.map +1 -0
  62. package/dist/daemon-internal-client-wrapper.js +65 -0
  63. package/dist/daemon-internal-client-wrapper.js.map +1 -0
  64. package/dist/dashboard/aggregator.d.ts +12 -0
  65. package/dist/dashboard/aggregator.d.ts.map +1 -1
  66. package/dist/dashboard/aggregator.js +16 -0
  67. package/dist/dashboard/aggregator.js.map +1 -1
  68. package/dist/dashboard/auth.d.ts +7 -0
  69. package/dist/dashboard/auth.d.ts.map +1 -1
  70. package/dist/dashboard/auth.js +21 -0
  71. package/dist/dashboard/auth.js.map +1 -1
  72. package/dist/dashboard/card-model-types.d.ts +50 -0
  73. package/dist/dashboard/card-model-types.d.ts.map +1 -0
  74. package/dist/dashboard/card-model-types.js +9 -0
  75. package/dist/dashboard/card-model-types.js.map +1 -0
  76. package/dist/dashboard/daemon-internal-api.d.ts +90 -0
  77. package/dist/dashboard/daemon-internal-api.d.ts.map +1 -0
  78. package/dist/dashboard/daemon-internal-api.js +534 -0
  79. package/dist/dashboard/daemon-internal-api.js.map +1 -0
  80. package/dist/dashboard/daemon-internal-auth.d.ts +124 -0
  81. package/dist/dashboard/daemon-internal-auth.d.ts.map +1 -0
  82. package/dist/dashboard/daemon-internal-auth.js +188 -0
  83. package/dist/dashboard/daemon-internal-auth.js.map +1 -0
  84. package/dist/dashboard/daemon-internal-client.d.ts +67 -0
  85. package/dist/dashboard/daemon-internal-client.d.ts.map +1 -0
  86. package/dist/dashboard/daemon-internal-client.js +157 -0
  87. package/dist/dashboard/daemon-internal-client.js.map +1 -0
  88. package/dist/dashboard/dashboard-admins.d.ts +8 -0
  89. package/dist/dashboard/dashboard-admins.d.ts.map +1 -0
  90. package/dist/dashboard/dashboard-admins.js +15 -0
  91. package/dist/dashboard/dashboard-admins.js.map +1 -0
  92. package/dist/dashboard/federation-spoke-api.d.ts +3 -0
  93. package/dist/dashboard/federation-spoke-api.d.ts.map +1 -1
  94. package/dist/dashboard/federation-spoke-api.js +11 -8
  95. package/dist/dashboard/federation-spoke-api.js.map +1 -1
  96. package/dist/dashboard/groups-action-helpers.d.ts +69 -0
  97. package/dist/dashboard/groups-action-helpers.d.ts.map +1 -0
  98. package/dist/dashboard/groups-action-helpers.js +141 -0
  99. package/dist/dashboard/groups-action-helpers.js.map +1 -0
  100. package/dist/dashboard/groups-card-model.d.ts +120 -0
  101. package/dist/dashboard/groups-card-model.d.ts.map +1 -0
  102. package/dist/dashboard/groups-card-model.js +175 -0
  103. package/dist/dashboard/groups-card-model.js.map +1 -0
  104. package/dist/dashboard/schedule-card-model.d.ts +172 -0
  105. package/dist/dashboard/schedule-card-model.d.ts.map +1 -0
  106. package/dist/dashboard/schedule-card-model.js +290 -0
  107. package/dist/dashboard/schedule-card-model.js.map +1 -0
  108. package/dist/dashboard/session-card-model.d.ts +83 -0
  109. package/dist/dashboard/session-card-model.d.ts.map +1 -0
  110. package/dist/dashboard/session-card-model.js +196 -0
  111. package/dist/dashboard/session-card-model.js.map +1 -0
  112. package/dist/dashboard/settings-card-model.d.ts +73 -0
  113. package/dist/dashboard/settings-card-model.d.ts.map +1 -0
  114. package/dist/dashboard/settings-card-model.js +94 -0
  115. package/dist/dashboard/settings-card-model.js.map +1 -0
  116. package/dist/dashboard/settings-owner-resolver.d.ts +32 -0
  117. package/dist/dashboard/settings-owner-resolver.d.ts.map +1 -0
  118. package/dist/dashboard/settings-owner-resolver.js +43 -0
  119. package/dist/dashboard/settings-owner-resolver.js.map +1 -0
  120. package/dist/dashboard/settings-write-applier.d.ts +84 -0
  121. package/dist/dashboard/settings-write-applier.d.ts.map +1 -0
  122. package/dist/dashboard/settings-write-applier.js +119 -0
  123. package/dist/dashboard/settings-write-applier.js.map +1 -0
  124. package/dist/dashboard/workflow-api.d.ts.map +1 -1
  125. package/dist/dashboard/workflow-api.js +38 -119
  126. package/dist/dashboard/workflow-api.js.map +1 -1
  127. package/dist/dashboard/workflow-card-model.d.ts +127 -0
  128. package/dist/dashboard/workflow-card-model.d.ts.map +1 -0
  129. package/dist/dashboard/workflow-card-model.js +186 -0
  130. package/dist/dashboard/workflow-card-model.js.map +1 -0
  131. package/dist/dashboard/workflows-action-helpers.d.ts +105 -0
  132. package/dist/dashboard/workflows-action-helpers.d.ts.map +1 -0
  133. package/dist/dashboard/workflows-action-helpers.js +196 -0
  134. package/dist/dashboard/workflows-action-helpers.js.map +1 -0
  135. package/dist/dashboard.js +196 -271
  136. package/dist/dashboard.js.map +1 -1
  137. package/dist/i18n/en.d.ts.map +1 -1
  138. package/dist/i18n/en.js +266 -0
  139. package/dist/i18n/en.js.map +1 -1
  140. package/dist/i18n/zh.d.ts.map +1 -1
  141. package/dist/i18n/zh.js +266 -0
  142. package/dist/i18n/zh.js.map +1 -1
  143. package/dist/im/lark/card-builder.d.ts +2 -0
  144. package/dist/im/lark/card-builder.d.ts.map +1 -1
  145. package/dist/im/lark/card-builder.js +2 -1
  146. package/dist/im/lark/card-builder.js.map +1 -1
  147. package/dist/im/lark/card-handler.d.ts +54 -2
  148. package/dist/im/lark/card-handler.d.ts.map +1 -1
  149. package/dist/im/lark/card-handler.js +127 -1
  150. package/dist/im/lark/card-handler.js.map +1 -1
  151. package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
  152. package/dist/im/lark/event-dispatcher.js +22 -0
  153. package/dist/im/lark/event-dispatcher.js.map +1 -1
  154. package/dist/im/lark/groups-card.d.ts +90 -0
  155. package/dist/im/lark/groups-card.d.ts.map +1 -0
  156. package/dist/im/lark/groups-card.js +832 -0
  157. package/dist/im/lark/groups-card.js.map +1 -0
  158. package/dist/im/lark/overview-card.d.ts +101 -0
  159. package/dist/im/lark/overview-card.d.ts.map +1 -0
  160. package/dist/im/lark/overview-card.js +475 -0
  161. package/dist/im/lark/overview-card.js.map +1 -0
  162. package/dist/im/lark/schedules-card.d.ts +96 -0
  163. package/dist/im/lark/schedules-card.d.ts.map +1 -0
  164. package/dist/im/lark/schedules-card.js +892 -0
  165. package/dist/im/lark/schedules-card.js.map +1 -0
  166. package/dist/im/lark/sessions-card.d.ts +117 -0
  167. package/dist/im/lark/sessions-card.d.ts.map +1 -0
  168. package/dist/im/lark/sessions-card.js +870 -0
  169. package/dist/im/lark/sessions-card.js.map +1 -0
  170. package/dist/im/lark/settings-card.d.ts +125 -0
  171. package/dist/im/lark/settings-card.d.ts.map +1 -0
  172. package/dist/im/lark/settings-card.js +433 -0
  173. package/dist/im/lark/settings-card.js.map +1 -0
  174. package/dist/im/lark/workflows-card.d.ts +102 -0
  175. package/dist/im/lark/workflows-card.d.ts.map +1 -0
  176. package/dist/im/lark/workflows-card.js +737 -0
  177. package/dist/im/lark/workflows-card.js.map +1 -0
  178. package/package.json +1 -1
package/dist/dashboard.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // src/dashboard.ts
2
2
  import { createServer } from 'node:http';
3
3
  import { createServer as createTcpServer } from 'node:net';
4
- import { readFileSync, existsSync, chmodSync, mkdirSync, statSync, createReadStream, } from 'node:fs';
4
+ import { readFileSync, existsSync, mkdirSync, statSync, createReadStream, } from 'node:fs';
5
5
  import { atomicWriteFileSync } from './utils/atomic-write.js';
6
6
  import { join, dirname, extname, resolve, relative, isAbsolute } from 'node:path';
7
7
  import { homedir } from 'node:os';
@@ -10,7 +10,7 @@ import { randomBytes, createHmac } from 'node:crypto';
10
10
  import { logger } from './utils/logger.js';
11
11
  import { config } from './config.js';
12
12
  import { listenWithProbe } from './utils/listen-with-probe.js';
13
- import { generateToken, parseCookie, buildSetCookie, verifyHmac, cliAuthBind, decideDashboardAuth, loadPersistedToken, persistToken, } from './dashboard/auth.js';
13
+ import { generateToken, parseCookie, buildSetCookie, verifyHmac, cliAuthBind, decideDashboardAuth, loadPersistedToken, persistToken, loadDashboardSecret, loadOrCreateDashboardSecret, } from './dashboard/auth.js';
14
14
  import { DaemonRegistry } from './dashboard/registry.js';
15
15
  import { Aggregator, subscribeDaemon } from './dashboard/aggregator.js';
16
16
  import { pickCreatorForGroup } from './dashboard/operator-selector.js';
@@ -26,9 +26,8 @@ import { getRunsDir } from './workflows/runs-dir.js';
26
26
  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
- import { mergeDashboardConfig, mergeGlobalConfig, mergeMaintenanceConfig, parseMaintenancePatch, readGlobalConfig, setGlobalLocale } from './global-config.js';
29
+ import { mergeGlobalConfig, readGlobalConfig } from './global-config.js';
30
30
  import { deleteWhiteboard, listWhiteboards, readWhiteboard, whiteboardEnabled } from './services/whiteboard-store.js';
31
- import { isLocale } from './i18n/types.js';
32
31
  import { isLocalDevInstall, botmuxVersion } from './utils/install-info.js';
33
32
  import { checkNode, detectBotmuxInstalls, resolveCurrentVersion } from './utils/install-diagnostics.js';
34
33
  import { fetchLatestVersion, fetchReleasesSince, isNewerVersion } from './core/update-check.js';
@@ -37,6 +36,11 @@ import { spawnDetachedRestart, npmGlobalUpdateLockTarget } from './core/maintena
37
36
  import { writeRestartIntent } from './services/restart-intent-store.js';
38
37
  import { withFileLock } from './utils/file-lock.js';
39
38
  import { spawn } from 'node:child_process';
39
+ import { applySettingsWrite, defaultSettingsWriteApplierDeps, } from './dashboard/settings-write-applier.js';
40
+ import { addBotsToGroup, bindOncall, disbandGroup, leaveGroup, unbindOncall, } from './dashboard/groups-action-helpers.js';
41
+ import { defaultWorkflowsActionDeps } from './dashboard/workflows-action-helpers.js';
42
+ import { listRuns as listRunsImpl, readRunSnapshot as readRunSnapshotImpl, scrubSnapshotForUnauthed as scrubSnapshotImpl, isValidRunId as isValidRunIdImpl, TERMINAL_RUN_STATUSES as TERMINAL_RUN_STATUSES_IMPL, } from './workflows/ops-projection.js';
43
+ import { createDaemonInternalApi } from './dashboard/daemon-internal-api.js';
40
44
  import { listTeamReports, readTeamBoard, setTeamBoardEntry } from './services/team-board-store.js';
41
45
  import { hd2dAssetPath, hd2dStatus, startHd2dDownload } from './dashboard/hd2d-assets.js';
42
46
  import { installLocalSkillLinks, readSkillRegistry, removeInstalledSkill, updateInstalledSkillAsync, } from './services/skill-registry-store.js';
@@ -63,14 +67,26 @@ const REGISTRY_DIR = join(homedir(), '.botmux', 'data', 'dashboard-daemons');
63
67
  // the `botmux dashboard` CLI can reach /__cli/rotate without guessing.
64
68
  const PORT_PATH = join(homedir(), '.botmux', '.dashboard-port');
65
69
  function loadOrCreateSecret() {
66
- if (existsSync(SECRET_PATH))
67
- return readFileSync(SECRET_PATH, 'utf8').trim();
68
- const s = randomBytes(32).toString('base64url');
69
- mkdirSync(dirname(SECRET_PATH), { recursive: true });
70
- atomicWriteFileSync(SECRET_PATH, s, { mode: 0o600 });
71
- chmodSync(SECRET_PATH, 0o600);
72
- logger.info(`[dashboard] Generated dashboard secret at ${SECRET_PATH}`);
73
- return s;
70
+ let existing;
71
+ try {
72
+ existing = loadDashboardSecret(SECRET_PATH);
73
+ }
74
+ catch (e) {
75
+ logger.error(`[dashboard] Failed to read dashboard secret at ${SECRET_PATH}: ${e.message}`);
76
+ process.exit(1);
77
+ }
78
+ if (existing)
79
+ return existing;
80
+ const existed = existsSync(SECRET_PATH);
81
+ try {
82
+ const secret = loadOrCreateDashboardSecret(SECRET_PATH);
83
+ logger.info(`[dashboard] ${existed ? 'Regenerated empty' : 'Generated'} dashboard secret at ${SECRET_PATH}`);
84
+ return secret;
85
+ }
86
+ catch (e) {
87
+ logger.error(`[dashboard] Failed to create dashboard secret at ${SECRET_PATH}: ${e.message}`);
88
+ process.exit(1);
89
+ }
74
90
  }
75
91
  // The active dashboard token is persisted to disk so a previously-issued
76
92
  // dashboard URL survives `botmux restart`; only `botmux dashboard` (the
@@ -131,6 +147,65 @@ function resolveDashboardSettings() {
131
147
  whiteboard: { enabled: global.whiteboard?.enabled === true },
132
148
  };
133
149
  }
150
+ // Single shared deps object for `applySettingsWrite` — both the browser
151
+ // `PUT /api/settings` route and (PR2 C6) the HMAC-gated `PUT /__daemon/settings-write`
152
+ // route call through this so error codes / merge semantics stay identical.
153
+ async function reloadLocaleOnAllDaemons() {
154
+ await Promise.all(registry.list().map(d => fetch(`http://127.0.0.1:${d.ipcPort}/api/locale/reload`, { method: 'POST' }).catch(() => undefined)));
155
+ }
156
+ const settingsWriteApplierDeps = defaultSettingsWriteApplierDeps(resolveDashboardSettings, reloadLocaleOnAllDaemons);
157
+ /** Helper to render a {status, body} HandlerResult through `res`. */
158
+ function writeHandlerResult(res, result) {
159
+ const headers = { 'content-type': 'application/json', ...(result.headers ?? {}) };
160
+ res.writeHead(result.status, headers);
161
+ res.end(typeof result.body === 'string' ? result.body : JSON.stringify(result.body));
162
+ }
163
+ // Shared deps for groups-action-helpers — both the browser
164
+ // `/api/groups/*` routes and (PR2 C6) the HMAC-gated `/__daemon/groups/*`
165
+ // routes use these helpers so response shapes / cascade-close semantics
166
+ // stay identical.
167
+ const groupsActionDeps = {
168
+ registryList: () => registry.list(),
169
+ registryGetByAppId: (id) => registry.getByAppId(id),
170
+ proxyToDaemon,
171
+ closeSessionsMatching,
172
+ };
173
+ // ─── PR2 C8: Route B internal API (`/__daemon/*`) ───────────────────────────
174
+ // HMAC + loopback + ts ±60s + nonce TTL, signed-request envelope = full
175
+ // (ts, nonce, method, pathWithQuery, sha256(body)). Reuses `.dashboard-secret`
176
+ // for the HMAC key — the same secret `/__cli/rotate` already uses — but the
177
+ // signing material is wider so a `/__cli/rotate` signature cannot be replayed
178
+ // here and vice versa (different protocols, same secret, no cross-replay).
179
+ //
180
+ // SECRET fail-closed: `loadOrCreateSecret()` returns a 32-byte base64url
181
+ // string and never empty; we still guard below at server-startup time.
182
+ if (!SECRET || SECRET.length === 0) {
183
+ logger.error('[dashboard] SECRET is empty — refusing to mount /__daemon/* dispatcher');
184
+ process.exit(1);
185
+ }
186
+ const daemonInternalApi = createDaemonInternalApi({
187
+ secret: SECRET,
188
+ getSessions: () => aggregator.getSessions(),
189
+ getSchedules: () => aggregator.getSchedules(),
190
+ resolveDashboardSettings,
191
+ buildGroupsMatrix,
192
+ settingsApplierDeps: settingsWriteApplierDeps,
193
+ groupsActionDeps,
194
+ workflowsActionDeps: defaultWorkflowsActionDeps({
195
+ runsDir: getRunsDir(),
196
+ proxyToDaemon,
197
+ listRuns: listRunsImpl,
198
+ readRunSnapshot: readRunSnapshotImpl,
199
+ scrubSnapshotForUnauthed: scrubSnapshotImpl,
200
+ TERMINAL_RUN_STATUSES: TERMINAL_RUN_STATUSES_IMPL,
201
+ isValidRunId: isValidRunIdImpl,
202
+ }),
203
+ proxyToDaemon,
204
+ ownerOf: (sid) => aggregator.ownerOf(sid),
205
+ scheduleOwnerOf: (id) => aggregator.scheduleOwnerOf(id),
206
+ scheduleExists: (id) => aggregator.scheduleExists(id),
207
+ sessionExists: (sessionId) => aggregator.sessionExists(sessionId),
208
+ });
134
209
  async function readJsonBody(req) {
135
210
  const chunks = [];
136
211
  for await (const c of req)
@@ -468,6 +543,73 @@ async function createLifecycleGroupForWebhook(connector, args) {
468
543
  }
469
544
  return { chatId: parsed.chatId, creatorLarkAppId: parsed.creator ?? pick.creatorLarkAppId };
470
545
  }
546
+ /**
547
+ * Build the per-(chat × bot) coverage matrix shared by `GET /api/groups`
548
+ * (browser) and `GET /__daemon/groups-matrix` (Route B). Pure aggregation,
549
+ * always returns the raw (unscrubbed) view — the browser route applies its
550
+ * `redactGroupsForPublic` scrub on top when the caller is unauthed.
551
+ */
552
+ async function buildGroupsMatrix() {
553
+ const out = new Map();
554
+ const cliIds = configuredCliIds();
555
+ const onlineBots = [...registry.list()]
556
+ .map(b => withConfiguredCliId(b, cliIds))
557
+ .sort((a, b) => a.botIndex - b.botIndex);
558
+ await Promise.all(onlineBots.map(async (d) => {
559
+ try {
560
+ const r = await fetch(`http://127.0.0.1:${d.ipcPort}/api/groups`);
561
+ if (!r.ok)
562
+ return;
563
+ const j = await r.json();
564
+ for (const c of j.chats ?? []) {
565
+ const { oncallChat, firstSeenAt, hasRole, observedBotNames, ...chatBase } = c;
566
+ const cur = out.get(c.chatId) ?? {
567
+ ...chatBase,
568
+ memberBots: [],
569
+ _firstSeenAt: null,
570
+ observedBotNames: [],
571
+ };
572
+ if (Array.isArray(observedBotNames) && observedBotNames.length > 0) {
573
+ cur.observedBotNames = [...new Set([...(cur.observedBotNames ?? []), ...observedBotNames])];
574
+ }
575
+ cur.memberBots.push({
576
+ larkAppId: d.larkAppId,
577
+ botName: d.botName,
578
+ cliId: d.cliId,
579
+ inChat: true,
580
+ oncallChat: oncallChat ?? null,
581
+ hasRole: hasRole ?? false,
582
+ });
583
+ if (typeof firstSeenAt === 'number') {
584
+ cur._firstSeenAt = cur._firstSeenAt === null
585
+ ? firstSeenAt
586
+ : Math.min(cur._firstSeenAt, firstSeenAt);
587
+ }
588
+ out.set(c.chatId, cur);
589
+ }
590
+ }
591
+ catch { /* skip offline daemons silently — best-effort */ }
592
+ }));
593
+ for (const c of out.values()) {
594
+ const present = new Set(c.memberBots.map((mb) => mb.larkAppId));
595
+ for (const b of onlineBots) {
596
+ if (!present.has(b.larkAppId)) {
597
+ c.memberBots.push({ larkAppId: b.larkAppId, botName: b.botName, cliId: b.cliId, inChat: false, oncallChat: null, hasRole: false });
598
+ }
599
+ }
600
+ }
601
+ const chats = [...out.values()]
602
+ .sort((a, b) => {
603
+ const ta = a._firstSeenAt ?? 0;
604
+ const tb = b._firstSeenAt ?? 0;
605
+ if (tb !== ta)
606
+ return tb - ta;
607
+ return (a.name ?? a.chatId).localeCompare(b.name ?? b.chatId);
608
+ })
609
+ .map(({ _firstSeenAt, ...rest }) => rest);
610
+ const bots = onlineBots.map(botSummaryPayload);
611
+ return { chats, bots };
612
+ }
471
613
  /**
472
614
  * Close every active session matching `pred` by routing to its owning daemon.
473
615
  * Used after disband (close all sessions in chat) and leave (close only the
@@ -680,6 +822,16 @@ const server = createServer(async (req, res) => {
680
822
  if (await handleFederationApi(req, res, url, { createTeamGroup, liveBots })) {
681
823
  return;
682
824
  }
825
+ // Route B: daemon internal API (`/__daemon/*`) — HMAC + loopback,
826
+ // mounted BEFORE the browser cookie/token gate because this protocol is
827
+ // entirely self-contained (the daemon caller has the shared secret and
828
+ // the signing-envelope already binds method/path/body to the timestamp).
829
+ // Letting the auth gate touch these paths would be wrong: there is no
830
+ // cookie or token to set/check; the gate would either 401 the daemon
831
+ // (false negative) or grant cross-protocol access (false positive).
832
+ if (await daemonInternalApi.handle(req, res, url)) {
833
+ return;
834
+ }
683
835
  // CLI rotate (HMAC + loopback only) — for `botmux dashboard`. Mints a fresh
684
836
  // token, invalidating any previously-issued link.
685
837
  if (req.method === 'POST' && url.pathname === '/__cli/rotate') {
@@ -893,78 +1045,10 @@ const server = createServer(async (req, res) => {
893
1045
  catch {
894
1046
  return jsonRes(res, 400, { ok: false, error: 'bad_json' });
895
1047
  }
896
- const body = parsed && typeof parsed === 'object' ? parsed : {};
897
- const patch = {};
898
- if ('publicReadOnly' in body) {
899
- if (typeof body.publicReadOnly !== 'boolean')
900
- return jsonRes(res, 400, { ok: false, error: 'invalid_publicReadOnly' });
901
- patch.publicReadOnly = body.publicReadOnly;
902
- }
903
- if ('openTerminalInFeishu' in body) {
904
- if (typeof body.openTerminalInFeishu !== 'boolean')
905
- return jsonRes(res, 400, { ok: false, error: 'invalid_openTerminalInFeishu' });
906
- patch.openTerminalInFeishu = body.openTerminalInFeishu;
907
- }
908
- let touched = false;
909
- if (Object.keys(patch).length > 0) {
910
- mergeDashboardConfig(patch);
911
- touched = true;
912
- }
913
- if ('repoPickerMode' in body) {
914
- const v = body.repoPickerMode;
915
- if (v !== 'all' && v !== 'repos')
916
- return jsonRes(res, 400, { ok: false, error: 'invalid_repoPickerMode' });
917
- mergeGlobalConfig({ repoPickerMode: v });
918
- touched = true;
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
- }
930
- if ('maintenance' in body) {
931
- const r = parseMaintenancePatch(body.maintenance);
932
- if (!r.ok)
933
- return jsonRes(res, 400, { ok: false, error: r.error });
934
- // Auto-update is npm-global only; refuse enabling it on a source checkout.
935
- if (r.patch.autoUpdate?.enabled && isLocalDevInstall()) {
936
- return jsonRes(res, 400, { ok: false, error: 'local_dev_no_autoupdate' });
937
- }
938
- // Auto-restart only applies an auto-update — it's meaningless without it.
939
- // Refuse enabling auto-restart unless auto-update is (or is being) on.
940
- if (r.patch.autoRestart?.enabled) {
941
- const autoUpdateOn = r.patch.autoUpdate?.enabled
942
- ?? readGlobalConfig().maintenance?.autoUpdate?.enabled
943
- ?? false;
944
- if (!autoUpdateOn)
945
- return jsonRes(res, 400, { ok: false, error: 'autoupdate_required' });
946
- }
947
- mergeMaintenanceConfig(r.patch);
948
- touched = true;
949
- }
950
- if ('lang' in body) {
951
- // Global UI locale — single source of truth shared with `botmux lang`
952
- // and the Feishu cards. Persist to ~/.botmux/config.json, then fan out
953
- // to every online daemon over the same IPC bus the per-bot config
954
- // writes use, so running cards switch language live (no restart).
955
- const v = body.lang;
956
- if (v === null)
957
- setGlobalLocale(null);
958
- else if (isLocale(v))
959
- setGlobalLocale(v);
960
- else
961
- return jsonRes(res, 400, { ok: false, error: 'invalid_lang' });
962
- await Promise.all(registry.list().map(d => fetch(`http://127.0.0.1:${d.ipcPort}/api/locale/reload`, { method: 'POST' }).catch(() => undefined)));
963
- touched = true;
964
- }
965
- if (!touched)
966
- return jsonRes(res, 400, { ok: false, error: 'empty_patch' });
967
- return jsonRes(res, 200, { ok: true, settings: resolveDashboardSettings() });
1048
+ const result = await applySettingsWrite(parsed, settingsWriteApplierDeps);
1049
+ if (!result.ok)
1050
+ return jsonRes(res, 400, { ok: false, error: result.error });
1051
+ return jsonRes(res, 200, { ok: true, settings: result.settings });
968
1052
  }
969
1053
  // ─── Version & manual update ─────────────────────────────────────────────
970
1054
  // `npm install -g` and a host restart are privileged: none of these paths
@@ -1457,77 +1541,14 @@ const server = createServer(async (req, res) => {
1457
1541
  }
1458
1542
  // ─── Groups (Phase B) ────────────────────────────────────────────────────
1459
1543
  if (req.method === 'GET' && url.pathname === '/api/groups') {
1460
- // Fan out: each online daemon returns the chats its bot is in.
1461
- // Merge by chatId; populate memberBots with inChat flags for every configured bot.
1462
- const out = new Map();
1463
- // Sort by botIndex so the matrix columns + the create-group bot picker
1464
- // both match the order in bots.json (fs.readdir order is unstable).
1465
- const cliIds = configuredCliIds();
1466
- const onlineBots = [...registry.list()].map(b => withConfiguredCliId(b, cliIds)).sort((a, b) => a.botIndex - b.botIndex);
1467
- await Promise.all(onlineBots.map(async (d) => {
1468
- try {
1469
- const r = await fetch(`http://127.0.0.1:${d.ipcPort}/api/groups`);
1470
- if (!r.ok)
1471
- return;
1472
- const j = await r.json();
1473
- for (const c of j.chats ?? []) {
1474
- // Strip per-bot fields from chat-level so the merged record stays
1475
- // bot-agnostic. oncallChat lives inside memberBots; firstSeenAt is
1476
- // accumulated as the earliest observation across all bots.
1477
- const { oncallChat, firstSeenAt, hasRole, observedBotNames, ...chatBase } = c;
1478
- const cur = out.get(c.chatId) ?? { ...chatBase, memberBots: [], _firstSeenAt: null, observedBotNames: [] };
1479
- // /introduce 记录按观察者(bot)分文件——跨 daemon 取并集(按名字去重)
1480
- if (Array.isArray(observedBotNames) && observedBotNames.length) {
1481
- cur.observedBotNames = [...new Set([...(cur.observedBotNames ?? []), ...observedBotNames])];
1482
- }
1483
- cur.memberBots.push({
1484
- larkAppId: d.larkAppId,
1485
- botName: d.botName,
1486
- cliId: d.cliId,
1487
- inChat: true,
1488
- oncallChat: oncallChat ?? null,
1489
- hasRole: hasRole ?? false,
1490
- });
1491
- if (typeof firstSeenAt === 'number') {
1492
- cur._firstSeenAt = cur._firstSeenAt === null
1493
- ? firstSeenAt
1494
- : Math.min(cur._firstSeenAt, firstSeenAt);
1495
- }
1496
- out.set(c.chatId, cur);
1497
- }
1498
- }
1499
- catch { /* skip offline daemons silently — best-effort */ }
1500
- }));
1501
- // Fill in inChat:false slots for bots NOT returned for a given chat (matrix view)
1502
- for (const c of out.values()) {
1503
- const present = new Set(c.memberBots.map((mb) => mb.larkAppId));
1504
- for (const b of onlineBots) {
1505
- if (!present.has(b.larkAppId)) {
1506
- c.memberBots.push({ larkAppId: b.larkAppId, botName: b.botName, cliId: b.cliId, inChat: false, oncallChat: null, hasRole: false });
1507
- }
1508
- }
1509
- }
1510
- // Sort newest-first by client-side firstSeenAt (Lark exposes no chat
1511
- // create_time, so daemon stamps timestamps the first time it lists each
1512
- // chat). Tie-break by name asc so chats backfilled in the same listChats
1513
- // pass — typically every chat on first deploy — get a stable order.
1514
- const sorted = [...out.values()]
1515
- .sort((a, b) => {
1516
- const ta = a._firstSeenAt ?? 0;
1517
- const tb = b._firstSeenAt ?? 0;
1518
- if (tb !== ta)
1519
- return tb - ta;
1520
- return (a.name ?? a.chatId).localeCompare(b.name ?? b.chatId);
1521
- })
1522
- .map(({ _firstSeenAt, ...rest }) => rest);
1523
- // Public-read carve-out: oncall bindings carry workingDir (repo/customer
1524
- // paths). The read-only board only needs chat/bot names; the oncall
1525
- // editor that consumes oncallChat is authed-only. Strip for anon so the
1526
- // bound dirs don't leak via /api/groups (mirrors the /api/schedules
1527
- // prompt strip + keeps /api/bots oncall removal honest).
1544
+ // Fan out via the shared `buildGroupsMatrix` helper so the browser
1545
+ // route and the Route B `/__daemon/groups-matrix` endpoint return the
1546
+ // same matrix shape. Public-read carve-out: oncall bindings carry
1547
+ // workingDir (repo/customer paths) so we scrub when unauthed.
1548
+ const matrix = await buildGroupsMatrix();
1528
1549
  return jsonRes(res, 200, {
1529
- chats: authed ? sorted : redactGroupsForPublic(sorted),
1530
- bots: onlineBots.map(botSummaryPayload),
1550
+ chats: authed ? matrix.chats : redactGroupsForPublic(matrix.chats),
1551
+ bots: matrix.bots,
1531
1552
  });
1532
1553
  }
1533
1554
  // ─── Roles (proxy to daemon) ────────────────────────────────────────────
@@ -1697,45 +1718,15 @@ const server = createServer(async (req, res) => {
1697
1718
  let m2;
1698
1719
  if (req.method === 'POST' && (m2 = url.pathname.match(/^\/api\/groups\/([^/]+)\/add-bots$/))) {
1699
1720
  const chatId = decodeURIComponent(m2[1]);
1700
- // Read body once; we'll forward it to the proxy daemon
1701
- let raw;
1702
- try {
1703
- const chunks = [];
1704
- for await (const c of req)
1705
- chunks.push(c);
1706
- raw = Buffer.concat(chunks).toString('utf8') || '{}';
1707
- JSON.parse(raw); // validate is JSON
1708
- }
1709
- catch {
1710
- return jsonRes(res, 400, { ok: false, error: 'bad_json' });
1711
- }
1712
- // Find a daemon whose bot is already in this chat
1713
- let proxy;
1714
- for (const d of registry.list()) {
1715
- try {
1716
- const r = await fetch(`http://127.0.0.1:${d.ipcPort}/api/groups/${encodeURIComponent(chatId)}/membership`);
1717
- if (!r.ok)
1718
- continue;
1719
- const j = await r.json();
1720
- if (j.inChat) {
1721
- proxy = d;
1722
- break;
1723
- }
1724
- }
1725
- catch { /* skip */ }
1726
- }
1727
- if (!proxy)
1728
- return jsonRes(res, 200, { ok: false, error: 'no_proxy_bot' });
1729
- const upstream = await fetch(`http://127.0.0.1:${proxy.ipcPort}/api/groups/${encodeURIComponent(chatId)}/add-bots`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: raw });
1730
- res.writeHead(upstream.status, { 'content-type': 'application/json' });
1731
- res.end(await upstream.text());
1732
- return;
1721
+ const chunks = [];
1722
+ for await (const c of req)
1723
+ chunks.push(c);
1724
+ const raw = Buffer.concat(chunks).toString('utf8') || '{}';
1725
+ const result = await addBotsToGroup(chatId, raw, groupsActionDeps);
1726
+ return writeHandlerResult(res, result);
1733
1727
  }
1734
1728
  // Disband a chat. Body: `{ larkAppId }` — the bot whose daemon should
1735
- // perform the delete. Disband only succeeds when that bot is currently
1736
- // the chat owner (or creator with operate_as_owner scope, which botmux
1737
- // doesn't request by default), so the frontend is responsible for picking
1738
- // a viable bot. The route just proxies and surfaces Lark's error verbatim.
1729
+ // perform the delete. See `dashboard/groups-action-helpers.ts:disbandGroup`.
1739
1730
  let mDisband;
1740
1731
  if (req.method === 'POST' && (mDisband = url.pathname.match(/^\/api\/groups\/([^/]+)\/disband$/))) {
1741
1732
  const chatId = decodeURIComponent(mDisband[1]);
@@ -1749,30 +1740,12 @@ const server = createServer(async (req, res) => {
1749
1740
  catch {
1750
1741
  return jsonRes(res, 400, { ok: false, error: 'bad_json' });
1751
1742
  }
1752
- const appId = typeof parsed.larkAppId === 'string' ? parsed.larkAppId : '';
1753
- if (!appId)
1754
- return jsonRes(res, 400, { ok: false, error: 'larkAppId_required' });
1755
- const upstream = await proxyToDaemon(appId, `/api/groups/${encodeURIComponent(chatId)}/disband`, { method: 'POST' });
1756
- const upstreamText = await upstream.text();
1757
- let upstreamJson = null;
1758
- try {
1759
- upstreamJson = JSON.parse(upstreamText);
1760
- }
1761
- catch { /* tolerate */ }
1762
- // On successful disband, the chat is gone for everyone — every bot's
1763
- // session in this chat becomes a zombie (worker still alive, can't post).
1764
- // Close them all so the UI / Sessions list don't keep them as active.
1765
- let closedSessions = [];
1766
- if (upstreamJson?.ok) {
1767
- closedSessions = await closeSessionsMatching(s => s.chatId === chatId);
1768
- }
1769
- res.writeHead(upstream.status, { 'content-type': 'application/json' });
1770
- res.end(JSON.stringify({ ...(upstreamJson ?? {}), closedSessions }));
1771
- return;
1743
+ const result = await disbandGroup(chatId, parsed, groupsActionDeps);
1744
+ return writeHandlerResult(res, result);
1772
1745
  }
1773
- // Make selected bots leave a chat. Body: `{ larkAppIds: string[] }`. Each
1774
- // bot is removed via its own daemon (Lark allows self-removal under any
1775
- // role). Per-bot results returned so the UI can show partial successes.
1746
+ // Make selected bots leave a chat. Body: `{ larkAppIds: string[] }`. See
1747
+ // `dashboard/groups-action-helpers.ts:leaveGroup` for membership probe +
1748
+ // cascade-close semantics.
1776
1749
  let mLeave;
1777
1750
  if (req.method === 'POST' && (mLeave = url.pathname.match(/^\/api\/groups\/([^/]+)\/leave$/))) {
1778
1751
  const chatId = decodeURIComponent(mLeave[1]);
@@ -1786,54 +1759,12 @@ const server = createServer(async (req, res) => {
1786
1759
  catch {
1787
1760
  return jsonRes(res, 400, { ok: false, error: 'bad_json' });
1788
1761
  }
1789
- const ids = Array.isArray(parsed.larkAppIds)
1790
- ? parsed.larkAppIds.filter((x) => typeof x === 'string')
1791
- : [];
1792
- if (ids.length === 0)
1793
- return jsonRes(res, 400, { ok: false, error: 'larkAppIds_required' });
1794
- // Re-check membership on the daemon side before issuing leave — UI cache
1795
- // can be stale, and Lark's bot-self-remove returns a confusing error if
1796
- // the bot isn't actually in the chat. Skipping such bots up-front keeps
1797
- // the per-bot result useful (`not_in_chat`) instead of a vague API error.
1798
- const result = await Promise.all(ids.map(async (appId) => {
1799
- const d = registry.getByAppId(appId);
1800
- if (!d)
1801
- return { larkAppId: appId, ok: false, error: 'daemon_offline' };
1802
- try {
1803
- const memRes = await fetch(`http://127.0.0.1:${d.ipcPort}/api/groups/${encodeURIComponent(chatId)}/membership`);
1804
- const memJson = await memRes.json();
1805
- if (!memJson.inChat)
1806
- return { larkAppId: appId, ok: false, error: 'not_in_chat' };
1807
- }
1808
- catch (e) {
1809
- return { larkAppId: appId, ok: false, error: `membership_check_failed: ${e?.message ?? e}` };
1810
- }
1811
- const upstream = await proxyToDaemon(appId, `/api/groups/${encodeURIComponent(chatId)}/leave`, { method: 'POST' });
1812
- const text = await upstream.text();
1813
- let body = null;
1814
- try {
1815
- body = JSON.parse(text);
1816
- }
1817
- catch { /* tolerate */ }
1818
- // On successful leave, the leaving bot can no longer post into the
1819
- // chat — its sessions there are stranded. Close only THIS bot's
1820
- // sessions for THIS chat (other bots may still be in the chat with
1821
- // their own active sessions).
1822
- const closedSessions = body?.ok
1823
- ? await closeSessionsMatching(s => s.chatId === chatId && s.larkAppId === appId)
1824
- : [];
1825
- return {
1826
- larkAppId: appId,
1827
- ok: !!body?.ok,
1828
- error: body?.ok ? undefined : (body?.error ?? `http_${upstream.status}`),
1829
- closedSessions,
1830
- };
1831
- }));
1832
- return jsonRes(res, 200, { result });
1762
+ const result = await leaveGroup(chatId, parsed, groupsActionDeps);
1763
+ return writeHandlerResult(res, result);
1833
1764
  }
1834
1765
  // ─── Oncall bindings (per chat × bot) ────────────────────────────────────
1835
- // PUT /api/groups/:chatId/oncall/:larkAppId body: {workingDir}
1836
- // DELETE /api/groups/:chatId/oncall/:larkAppId
1766
+ // External: PUT/DELETE /api/groups/:chatId/oncall/:larkAppId
1767
+ // Internal: PUT/DELETE /api/oncall/:chatId (on the named bot's daemon).
1837
1768
  let mOncall;
1838
1769
  if ((mOncall = url.pathname.match(/^\/api\/groups\/([^/]+)\/oncall\/([^/]+)$/))) {
1839
1770
  const chatId = decodeURIComponent(mOncall[1]);
@@ -1842,17 +1773,13 @@ const server = createServer(async (req, res) => {
1842
1773
  const chunks = [];
1843
1774
  for await (const c of req)
1844
1775
  chunks.push(c);
1845
- const raw = Buffer.concat(chunks).toString('utf8') || '{}';
1846
- const upstream = await proxyToDaemon(appId, `/api/oncall/${encodeURIComponent(chatId)}`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: raw });
1847
- res.writeHead(upstream.status, { 'content-type': 'application/json' });
1848
- res.end(await upstream.text());
1849
- return;
1776
+ const raw = Buffer.concat(chunks).toString('utf8');
1777
+ const result = await bindOncall(chatId, appId, raw, groupsActionDeps);
1778
+ return writeHandlerResult(res, result);
1850
1779
  }
1851
1780
  if (req.method === 'DELETE') {
1852
- const upstream = await proxyToDaemon(appId, `/api/oncall/${encodeURIComponent(chatId)}`, { method: 'DELETE' });
1853
- res.writeHead(upstream.status, { 'content-type': 'application/json' });
1854
- res.end(await upstream.text());
1855
- return;
1781
+ const result = await unbindOncall(chatId, appId, groupsActionDeps);
1782
+ return writeHandlerResult(res, result);
1856
1783
  }
1857
1784
  }
1858
1785
  // ─── Per-bot defaults (Bot Defaults tab) ─────────────────────────────────
@@ -2166,8 +2093,6 @@ const server = createServer(async (req, res) => {
2166
2093
  const content = typeof parsed.content === 'string' ? parsed.content.replace(/\s+$/u, '') : '';
2167
2094
  if (!content.trim())
2168
2095
  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
2096
  const selectedIds = Array.isArray(parsed.larkAppIds)
2172
2097
  ? Array.from(new Set(parsed.larkAppIds.filter((x) => typeof x === 'string')))
2173
2098
  : [];