botmux 3.1.1 → 3.2.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 (190) hide show
  1. package/dist/adapters/backend/sandbox.d.ts +23 -0
  2. package/dist/adapters/backend/sandbox.d.ts.map +1 -1
  3. package/dist/adapters/backend/sandbox.js +68 -1
  4. package/dist/adapters/backend/sandbox.js.map +1 -1
  5. package/dist/adapters/cli/read-isolation.d.ts +1 -1
  6. package/dist/adapters/cli/read-isolation.d.ts.map +1 -1
  7. package/dist/adapters/cli/read-isolation.js +15 -7
  8. package/dist/adapters/cli/read-isolation.js.map +1 -1
  9. package/dist/cli.d.ts.map +1 -1
  10. package/dist/cli.js +117 -25
  11. package/dist/cli.js.map +1 -1
  12. package/dist/core/command-handler.d.ts.map +1 -1
  13. package/dist/core/command-handler.js +11 -4
  14. package/dist/core/command-handler.js.map +1 -1
  15. package/dist/core/dashboard-ipc-server.d.ts +1 -0
  16. package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
  17. package/dist/core/dashboard-ipc-server.js +132 -0
  18. package/dist/core/dashboard-ipc-server.js.map +1 -1
  19. package/dist/core/maintenance.d.ts +23 -1
  20. package/dist/core/maintenance.d.ts.map +1 -1
  21. package/dist/core/maintenance.js +63 -25
  22. package/dist/core/maintenance.js.map +1 -1
  23. package/dist/core/plugins/cli-generation.d.ts.map +1 -1
  24. package/dist/core/plugins/cli-generation.js +7 -0
  25. package/dist/core/plugins/cli-generation.js.map +1 -1
  26. package/dist/core/plugins/convention-scanner.d.ts +3 -3
  27. package/dist/core/plugins/convention-scanner.d.ts.map +1 -1
  28. package/dist/core/plugins/convention-scanner.js.map +1 -1
  29. package/dist/core/plugins/install.d.ts.map +1 -1
  30. package/dist/core/plugins/install.js +84 -26
  31. package/dist/core/plugins/install.js.map +1 -1
  32. package/dist/core/plugins/mcp/environment.d.ts +12 -0
  33. package/dist/core/plugins/mcp/environment.d.ts.map +1 -0
  34. package/dist/core/plugins/mcp/environment.js +17 -0
  35. package/dist/core/plugins/mcp/environment.js.map +1 -0
  36. package/dist/core/plugins/mcp/gateway-installer.d.ts.map +1 -1
  37. package/dist/core/plugins/mcp/gateway-installer.js +9 -3
  38. package/dist/core/plugins/mcp/gateway-installer.js.map +1 -1
  39. package/dist/core/plugins/mcp/gateway.d.ts +13 -1
  40. package/dist/core/plugins/mcp/gateway.d.ts.map +1 -1
  41. package/dist/core/plugins/mcp/gateway.js +143 -23
  42. package/dist/core/plugins/mcp/gateway.js.map +1 -1
  43. package/dist/core/plugins/mcp/host.d.ts +18 -0
  44. package/dist/core/plugins/mcp/host.d.ts.map +1 -0
  45. package/dist/core/plugins/mcp/host.js +126 -0
  46. package/dist/core/plugins/mcp/host.js.map +1 -0
  47. package/dist/core/plugins/mcp/private-store.d.ts +15 -0
  48. package/dist/core/plugins/mcp/private-store.d.ts.map +1 -0
  49. package/dist/core/plugins/mcp/private-store.js +162 -0
  50. package/dist/core/plugins/mcp/private-store.js.map +1 -0
  51. package/dist/core/plugins/mcp/session-runtime.d.ts +25 -0
  52. package/dist/core/plugins/mcp/session-runtime.d.ts.map +1 -0
  53. package/dist/core/plugins/mcp/session-runtime.js +76 -0
  54. package/dist/core/plugins/mcp/session-runtime.js.map +1 -0
  55. package/dist/core/plugins/mcp/socket-auth.d.ts +9 -0
  56. package/dist/core/plugins/mcp/socket-auth.d.ts.map +1 -0
  57. package/dist/core/plugins/mcp/socket-auth.js +94 -0
  58. package/dist/core/plugins/mcp/socket-auth.js.map +1 -0
  59. package/dist/core/plugins/paths.d.ts +3 -0
  60. package/dist/core/plugins/paths.d.ts.map +1 -1
  61. package/dist/core/plugins/paths.js +9 -0
  62. package/dist/core/plugins/paths.js.map +1 -1
  63. package/dist/core/plugins/pm2.d.ts.map +1 -1
  64. package/dist/core/plugins/pm2.js +3 -1
  65. package/dist/core/plugins/pm2.js.map +1 -1
  66. package/dist/core/plugins/runtime.d.ts +2 -0
  67. package/dist/core/plugins/runtime.d.ts.map +1 -1
  68. package/dist/core/plugins/runtime.js.map +1 -1
  69. package/dist/core/plugins/service-manager.d.ts +22 -0
  70. package/dist/core/plugins/service-manager.d.ts.map +1 -1
  71. package/dist/core/plugins/service-manager.js +189 -52
  72. package/dist/core/plugins/service-manager.js.map +1 -1
  73. package/dist/core/plugins/types.d.ts +15 -1
  74. package/dist/core/plugins/types.d.ts.map +1 -1
  75. package/dist/core/restart-report.d.ts +1 -1
  76. package/dist/core/restart-report.d.ts.map +1 -1
  77. package/dist/core/restart-report.js +5 -3
  78. package/dist/core/restart-report.js.map +1 -1
  79. package/dist/core/scheduler.d.ts +47 -0
  80. package/dist/core/scheduler.d.ts.map +1 -1
  81. package/dist/core/scheduler.js +117 -0
  82. package/dist/core/scheduler.js.map +1 -1
  83. package/dist/core/session-manager.d.ts +7 -0
  84. package/dist/core/session-manager.d.ts.map +1 -1
  85. package/dist/core/session-manager.js +130 -33
  86. package/dist/core/session-manager.js.map +1 -1
  87. package/dist/core/silent-schedule-turns.d.ts +5 -0
  88. package/dist/core/silent-schedule-turns.d.ts.map +1 -0
  89. package/dist/core/silent-schedule-turns.js +47 -0
  90. package/dist/core/silent-schedule-turns.js.map +1 -0
  91. package/dist/core/types.d.ts +6 -0
  92. package/dist/core/types.d.ts.map +1 -1
  93. package/dist/core/types.js.map +1 -1
  94. package/dist/core/update-check.d.ts +16 -0
  95. package/dist/core/update-check.d.ts.map +1 -1
  96. package/dist/core/update-check.js +52 -0
  97. package/dist/core/update-check.js.map +1 -1
  98. package/dist/core/worker-pool.d.ts.map +1 -1
  99. package/dist/core/worker-pool.js +30 -23
  100. package/dist/core/worker-pool.js.map +1 -1
  101. package/dist/dashboard/schedule-card-model.d.ts +3 -0
  102. package/dist/dashboard/schedule-card-model.d.ts.map +1 -1
  103. package/dist/dashboard/schedule-card-model.js +5 -0
  104. package/dist/dashboard/schedule-card-model.js.map +1 -1
  105. package/dist/dashboard/web/app.d.ts.map +1 -1
  106. package/dist/dashboard/web/app.js +295 -6
  107. package/dist/dashboard/web/app.js.map +1 -1
  108. package/dist/dashboard/web/i18n.d.ts.map +1 -1
  109. package/dist/dashboard/web/i18n.js +92 -0
  110. package/dist/dashboard/web/i18n.js.map +1 -1
  111. package/dist/dashboard/web/plugin-page.d.ts.map +1 -1
  112. package/dist/dashboard/web/plugin-page.js +1 -3
  113. package/dist/dashboard/web/plugin-page.js.map +1 -1
  114. package/dist/dashboard/web/schedules-page.d.ts.map +1 -1
  115. package/dist/dashboard/web/schedules-page.js +111 -4
  116. package/dist/dashboard/web/schedules-page.js.map +1 -1
  117. package/dist/dashboard/web/settings-page.d.ts.map +1 -1
  118. package/dist/dashboard/web/settings-page.js +5 -1
  119. package/dist/dashboard/web/settings-page.js.map +1 -1
  120. package/dist/dashboard/web/update-action.d.ts +16 -0
  121. package/dist/dashboard/web/update-action.d.ts.map +1 -0
  122. package/dist/dashboard/web/update-action.js +64 -0
  123. package/dist/dashboard/web/update-action.js.map +1 -0
  124. package/dist/dashboard-web/app.js +1 -1
  125. package/dist/dashboard-web/chunks/{bot-defaults-page-KLDCZNLP.js → bot-defaults-page-4PM4T6QQ.js} +1 -1
  126. package/dist/dashboard-web/chunks/{chunk-FMJNOATE.js → chunk-3PMN55Z6.js} +1 -1
  127. package/dist/dashboard-web/chunks/{chunk-MLNI5BKE.js → chunk-AIDN2WZB.js} +1 -1
  128. package/dist/dashboard-web/chunks/{chunk-EZAW2EPM.js → chunk-IKCZ677K.js} +1 -1
  129. package/dist/dashboard-web/chunks/{chunk-KEAOTUL3.js → chunk-RXVA6XW3.js} +7 -7
  130. package/dist/dashboard-web/chunks/{connectors-page-O32LFAL5.js → connectors-page-2MKF4YTN.js} +1 -1
  131. package/dist/dashboard-web/chunks/{groups-page-PSAFIZ6M.js → groups-page-TAU2UIVS.js} +1 -1
  132. package/dist/dashboard-web/chunks/{insights-page-V23LLATX.js → insights-page-PJ2XVM6T.js} +1 -1
  133. package/dist/dashboard-web/chunks/{monitor-room-7X5AKYOF.js → monitor-room-D6445HJO.js} +1 -1
  134. package/dist/dashboard-web/chunks/{monitoring-page-CBA3CQYL.js → monitoring-page-GUHMCKSO.js} +1 -1
  135. package/dist/dashboard-web/chunks/{office-page-RL6QSEM7.js → office-page-6K6U2MS5.js} +1 -1
  136. package/dist/dashboard-web/chunks/{overview-page-XQIPO6KH.js → overview-page-USDYU7U5.js} +1 -1
  137. package/dist/dashboard-web/chunks/plugin-page-JXC5F5YV.js +1 -0
  138. package/dist/dashboard-web/chunks/{roles-page-ACIWV7QS.js → roles-page-O4OUA7ZH.js} +1 -1
  139. package/dist/dashboard-web/chunks/schedules-page-LNIBXJSZ.js +1 -0
  140. package/dist/dashboard-web/chunks/{sessions-page-2WDWHSP4.js → sessions-page-B6U6TCFF.js} +1 -1
  141. package/dist/dashboard-web/chunks/{settings-page-6VX4OUGG.js → settings-page-PXLRFF7I.js} +2 -2
  142. package/dist/dashboard-web/chunks/{skills-page-WXE3UYLT.js → skills-page-M5KYNXBA.js} +1 -1
  143. package/dist/dashboard-web/chunks/{team-federation-page-PYTF4FLM.js → team-federation-page-GT7UFQMN.js} +1 -1
  144. package/dist/dashboard-web/chunks/{v3-page-G6CD6O3I.js → v3-page-UYZIRJ2C.js} +1 -1
  145. package/dist/dashboard-web/chunks/{whiteboards-page-6SG33GP4.js → whiteboards-page-WAR77DQE.js} +1 -1
  146. package/dist/dashboard-web/style.css +697 -0
  147. package/dist/dashboard.js +404 -29
  148. package/dist/dashboard.js.map +1 -1
  149. package/dist/i18n/en.d.ts.map +1 -1
  150. package/dist/i18n/en.js +4 -0
  151. package/dist/i18n/en.js.map +1 -1
  152. package/dist/i18n/zh.d.ts.map +1 -1
  153. package/dist/i18n/zh.js +4 -0
  154. package/dist/i18n/zh.js.map +1 -1
  155. package/dist/im/lark/schedules-card.d.ts.map +1 -1
  156. package/dist/im/lark/schedules-card.js +2 -0
  157. package/dist/im/lark/schedules-card.js.map +1 -1
  158. package/dist/services/plugin-registry-store.d.ts.map +1 -1
  159. package/dist/services/plugin-registry-store.js +100 -18
  160. package/dist/services/plugin-registry-store.js.map +1 -1
  161. package/dist/services/restart-intent-store.d.ts +17 -2
  162. package/dist/services/restart-intent-store.d.ts.map +1 -1
  163. package/dist/services/restart-intent-store.js +110 -0
  164. package/dist/services/restart-intent-store.js.map +1 -1
  165. package/dist/services/schedule-store.d.ts +3 -1
  166. package/dist/services/schedule-store.d.ts.map +1 -1
  167. package/dist/services/schedule-store.js +5 -0
  168. package/dist/services/schedule-store.js.map +1 -1
  169. package/dist/skills/definitions.d.ts.map +1 -1
  170. package/dist/skills/definitions.js +3 -2
  171. package/dist/skills/definitions.js.map +1 -1
  172. package/dist/types.d.ts +16 -0
  173. package/dist/types.d.ts.map +1 -1
  174. package/dist/utils/child-env.d.ts +1 -1
  175. package/dist/utils/child-env.d.ts.map +1 -1
  176. package/dist/utils/child-env.js +5 -0
  177. package/dist/utils/child-env.js.map +1 -1
  178. package/dist/utils/global-install.d.ts +2 -0
  179. package/dist/utils/global-install.d.ts.map +1 -1
  180. package/dist/utils/global-install.js +14 -0
  181. package/dist/utils/global-install.js.map +1 -1
  182. package/dist/worker.js +125 -17
  183. package/dist/worker.js.map +1 -1
  184. package/dist/workflows/hostExecutors/botmux-schedule.d.ts +3 -0
  185. package/dist/workflows/hostExecutors/botmux-schedule.d.ts.map +1 -1
  186. package/dist/workflows/hostExecutors/botmux-schedule.js +9 -0
  187. package/dist/workflows/hostExecutors/botmux-schedule.js.map +1 -1
  188. package/package.json +1 -1
  189. package/dist/dashboard-web/chunks/plugin-page-7FW2LPJH.js +0 -1
  190. package/dist/dashboard-web/chunks/schedules-page-KJDUKOJV.js +0 -1
package/dist/dashboard.js CHANGED
@@ -38,12 +38,12 @@ import { dashboardSecretPath } from './core/dashboard-secret.js';
38
38
  import { deleteWhiteboard, listWhiteboards, readWhiteboard, whiteboardEnabled } from './services/whiteboard-store.js';
39
39
  import { isLocalDevInstall, botmuxVersionAt, botmuxCliEntry, botmuxInstallRoot } from './utils/install-info.js';
40
40
  import { checkNode, detectBotmuxInstalls, resolveCurrentVersion } from './utils/install-diagnostics.js';
41
- import { fetchLatestVersion, fetchReleasesSince, isNewerVersion } from './core/update-check.js';
41
+ import { fetchLatestVersion, fetchReleasesSince, fetchRollbackVersions, compareVersions, isCanonicalStableVersion, isNewerVersion, } from './core/update-check.js';
42
42
  import { GITHUB_REPO } from './core/restart-report.js';
43
43
  import { spawnDetachedRestart, globalInstallUpdateLockTarget, globalInstallUpdateCwd } from './core/maintenance.js';
44
- import { detectGlobalInstallManager, formatGlobalInstallCommand, resolveGlobalInstallPlan, tryResolveGlobalInstallPlan, UnsupportedGlobalInstallError, } from './utils/global-install.js';
44
+ import { detectGlobalInstallManager, formatGlobalInstallCommand, resolveGlobalInstallPlan, tryResolveGlobalInstallPlan, withGlobalInstallRegistry, UnsupportedGlobalInstallError, } from './utils/global-install.js';
45
45
  import { listCliRuntimeUpdateEntries } from './core/cli-runtime-update.js';
46
- import { writeRestartIntent } from './services/restart-intent-store.js';
46
+ import { claimRestartLease, clearRestartIntent, clearRestartLease, hasActiveRestartLease, writeManualIntentIfAbsent, writeRestartIntent, } from './services/restart-intent-store.js';
47
47
  import { withFileLock } from './utils/file-lock.js';
48
48
  import { spawn } from 'node:child_process';
49
49
  import { applySettingsWrite, defaultSettingsWriteApplierDeps, } from './dashboard/settings-write-applier.js';
@@ -187,6 +187,22 @@ function verifyDashboardBinding(port) {
187
187
  mkdirSync(REGISTRY_DIR, { recursive: true });
188
188
  const registry = new DaemonRegistry(REGISTRY_DIR);
189
189
  const aggregator = new Aggregator();
190
+ /**
191
+ * Resolve which daemon owns a schedule row. For rows with an explicit
192
+ * `larkAppId`, returns that. For legacy rows (no owner stamp), falls back to
193
+ * the primary daemon (botIndex === 0) — the only daemon that executes legacy
194
+ * tasks (see scheduler.belongsToOwner). Returns undefined when the row is
195
+ * genuinely unknown or no daemon is online.
196
+ */
197
+ function resolveScheduleOwner(id) {
198
+ const explicit = aggregator.scheduleOwnerOf(id);
199
+ if (explicit)
200
+ return explicit;
201
+ if (!aggregator.scheduleExists(id))
202
+ return undefined;
203
+ const primary = registry.list().find(d => d.botIndex === 0);
204
+ return primary?.larkAppId;
205
+ }
190
206
  /**
191
207
  * Bring a freshly-onboarded bot online without a fleet-wide restart by spawning
192
208
  * `botmux start-bot <appId> --json` (see cli.ts:ensureBotDaemonStarted). The new
@@ -755,14 +771,37 @@ const LATEST_TTL_MS = 30 * 60_000;
755
771
  const CHANGELOG_TTL_MS = 15 * 60_000;
756
772
  const FAILURE_TTL_MS = 60_000;
757
773
  let latestVersionCache = null;
774
+ let latestVersionLookupInFlight = null;
758
775
  let changelogCache = null;
759
- async function cachedLatestVersion(now = Date.now()) {
760
- const ttl = latestVersionCache?.value ? LATEST_TTL_MS : FAILURE_TTL_MS;
761
- if (latestVersionCache && now - latestVersionCache.at < ttl)
762
- return latestVersionCache.value;
763
- const value = await fetchLatestVersion();
764
- latestVersionCache = { value, at: now };
765
- return value;
776
+ let rollbackVersionCache = null;
777
+ let rollbackVersionLookupInFlight = null;
778
+ async function cachedLatestVersion(force = false) {
779
+ const now = Date.now();
780
+ const ttl = latestVersionCache?.lookupOk ? LATEST_TTL_MS : FAILURE_TTL_MS;
781
+ if (!force && latestVersionCache && now - latestVersionCache.at < ttl)
782
+ return latestVersionCache;
783
+ // When forcing a refresh, don't piggy-back on an in-flight lookup that may
784
+ // have started before the user asked for a refresh — start a fresh one so
785
+ // the result reflects the current upstream state, not a stale query.
786
+ if (!force && latestVersionLookupInFlight)
787
+ return latestVersionLookupInFlight;
788
+ const lookup = (async () => {
789
+ const value = await fetchLatestVersion();
790
+ latestVersionCache = {
791
+ value: value ?? latestVersionCache?.value ?? null,
792
+ at: Date.now(),
793
+ lookupOk: value !== null,
794
+ };
795
+ return latestVersionCache;
796
+ })();
797
+ latestVersionLookupInFlight = lookup;
798
+ try {
799
+ return await lookup;
800
+ }
801
+ finally {
802
+ if (latestVersionLookupInFlight === lookup)
803
+ latestVersionLookupInFlight = null;
804
+ }
766
805
  }
767
806
  async function cachedChangelog(current, now = Date.now()) {
768
807
  const ttl = changelogCache?.value.ok ? CHANGELOG_TTL_MS : FAILURE_TTL_MS;
@@ -772,6 +811,36 @@ async function cachedChangelog(current, now = Date.now()) {
772
811
  changelogCache = { key: current, value, at: now };
773
812
  return value;
774
813
  }
814
+ async function cachedRollbackVersions(current, force = false) {
815
+ const now = Date.now();
816
+ const ttl = rollbackVersionCache?.value.ok ? LATEST_TTL_MS : FAILURE_TTL_MS;
817
+ if (!force && rollbackVersionCache?.current === current && now - rollbackVersionCache.at < ttl) {
818
+ return rollbackVersionCache.value;
819
+ }
820
+ if (rollbackVersionLookupInFlight?.current === current)
821
+ return rollbackVersionLookupInFlight.value;
822
+ const lookup = (async () => {
823
+ const result = await fetchRollbackVersions(current);
824
+ const previous = rollbackVersionCache?.current === current ? rollbackVersionCache.value.versions : [];
825
+ const value = result.ok ? result : { ok: false, versions: previous };
826
+ rollbackVersionCache = { current, value, at: Date.now() };
827
+ return value;
828
+ })();
829
+ rollbackVersionLookupInFlight = { current, value: lookup };
830
+ try {
831
+ return await lookup;
832
+ }
833
+ finally {
834
+ if (rollbackVersionLookupInFlight?.value === lookup)
835
+ rollbackVersionLookupInFlight = null;
836
+ }
837
+ }
838
+ function currentInstalledVersion() {
839
+ if (!lastSuccessfulUpdatePlan)
840
+ return resolveCurrentVersion();
841
+ const version = botmuxVersionAt(lastSuccessfulUpdatePlan.activePackageRoot);
842
+ return version === '0.0.0' ? resolveCurrentVersion() : version;
843
+ }
775
844
  /**
776
845
  * Run the ownership-aware npm/pnpm/Bun update for the manual-update flow WITHOUT blocking
777
846
  * the event loop (async spawn, not execSync — the dashboard must keep serving
@@ -779,7 +848,7 @@ async function cachedChangelog(current, now = Date.now()) {
779
848
  * stdout/stderr on a non-zero exit, spawn error, or 3-minute timeout. Args are
780
849
  * a fixed literal — no shell interpolation of untrusted input.
781
850
  */
782
- function runGlobalInstallLatest(plan) {
851
+ function runGlobalInstall(plan) {
783
852
  return new Promise((resolve, reject) => {
784
853
  const child = spawn(plan.command, plan.args, {
785
854
  cwd: globalInstallUpdateCwd(),
@@ -2278,15 +2347,16 @@ const server = createServer(async (req, res) => {
2278
2347
  // unauthenticated caller (in both normal and public-read mode). The explicit
2279
2348
  // `authed` guards on the two mutations are defense-in-depth for host actions.
2280
2349
  if (req.method === 'GET' && url.pathname === '/api/update/status') {
2281
- const current = resolveCurrentVersion();
2282
- const packageRoot = botmuxInstallRoot();
2350
+ const current = currentInstalledVersion();
2351
+ const packageRoot = lastSuccessfulUpdatePlan?.activePackageRoot ?? botmuxInstallRoot();
2283
2352
  const installManager = detectGlobalInstallManager(packageRoot);
2284
- const installPlan = lastSuccessfulUpdatePlan ?? tryResolveGlobalInstallPlan(packageRoot);
2353
+ const installPlan = tryResolveGlobalInstallPlan(packageRoot);
2285
2354
  // Compare against the npm `latest` dist-tag (always stable; the update
2286
2355
  // button installs `@latest`). isNewerVersion uses semver precedence, so a
2287
2356
  // canary running AHEAD of the latest stable (e.g. 2.87.0-canary.0 vs
2288
2357
  // 2.86.0) is NOT flagged behind — exactly the canary case we want.
2289
- const latest = await cachedLatestVersion();
2358
+ const latestResult = await cachedLatestVersion(url.searchParams.get('refresh') === '1');
2359
+ const latest = latestResult.value;
2290
2360
  const cliUpdates = listCliRuntimeUpdateEntries(config.session.dataDir).map((entry) => ({
2291
2361
  cliId: entry.cliId,
2292
2362
  binPath: entry.binPath,
@@ -2300,6 +2370,7 @@ const server = createServer(async (req, res) => {
2300
2370
  return jsonRes(res, 200, {
2301
2371
  current,
2302
2372
  latest,
2373
+ versionLookupOk: latestResult.lookupOk,
2303
2374
  behind: !!latest && isNewerVersion(latest, current),
2304
2375
  cliBehind: cliUpdates.some((entry) => entry.updateAvailable),
2305
2376
  cliUpdates,
@@ -2311,8 +2382,13 @@ const server = createServer(async (req, res) => {
2311
2382
  installs: detectBotmuxInstalls(),
2312
2383
  });
2313
2384
  }
2385
+ if (req.method === 'GET' && url.pathname === '/api/update/versions') {
2386
+ const current = currentInstalledVersion();
2387
+ const result = await cachedRollbackVersions(current, url.searchParams.get('refresh') === '1');
2388
+ return jsonRes(res, 200, { current, ok: result.ok, versions: result.versions });
2389
+ }
2314
2390
  if (req.method === 'GET' && url.pathname === '/api/update/changelog') {
2315
- const current = resolveCurrentVersion();
2391
+ const current = currentInstalledVersion();
2316
2392
  const result = await cachedChangelog(current);
2317
2393
  return jsonRes(res, 200, {
2318
2394
  current,
@@ -2329,7 +2405,8 @@ const server = createServer(async (req, res) => {
2329
2405
  return jsonRes(res, 400, { ok: false, error: 'local_dev_no_update' });
2330
2406
  let installPlan;
2331
2407
  try {
2332
- installPlan = lastSuccessfulUpdatePlan ?? resolveGlobalInstallPlan();
2408
+ const packageRoot = lastSuccessfulUpdatePlan?.activePackageRoot ?? botmuxInstallRoot();
2409
+ installPlan = resolveGlobalInstallPlan(packageRoot);
2333
2410
  }
2334
2411
  catch (error) {
2335
2412
  if (error instanceof UnsupportedGlobalInstallError) {
@@ -2347,17 +2424,23 @@ const server = createServer(async (req, res) => {
2347
2424
  if (updateInFlight)
2348
2425
  return jsonRes(res, 409, { ok: false, error: 'update_in_flight' });
2349
2426
  updateInFlight = true;
2350
- const oldVersion = botmuxVersionAt(installPlan.activePackageRoot);
2427
+ let oldVersion = '';
2351
2428
  // Acquire the shared cross-process lock so a scheduled maintenance
2352
2429
  // auto-update (running in the bot-0 daemon) can't update the same global
2353
2430
  // install concurrently. `acquired` distinguishes "lock held by
2354
2431
  // maintenance" (409) from "the package manager failed" (500). Short wait:
2355
2432
  // don't block the request on a full in-progress install — report busy fast.
2356
2433
  let acquired = false;
2434
+ let blockedByRestart = false;
2357
2435
  try {
2358
2436
  await withFileLock(globalInstallUpdateLockTarget(), async () => {
2359
2437
  acquired = true;
2360
- await runGlobalInstallLatest(installPlan);
2438
+ if (hasActiveRestartLease()) {
2439
+ blockedByRestart = true;
2440
+ return;
2441
+ }
2442
+ oldVersion = botmuxVersionAt(installPlan.activePackageRoot);
2443
+ await runGlobalInstall(installPlan);
2361
2444
  }, { maxWaitMs: 2_000 });
2362
2445
  }
2363
2446
  catch (e) {
@@ -2368,6 +2451,8 @@ const server = createServer(async (req, res) => {
2368
2451
  finally {
2369
2452
  updateInFlight = false;
2370
2453
  }
2454
+ if (blockedByRestart)
2455
+ return jsonRes(res, 409, { ok: false, error: 'restart_in_flight' });
2371
2456
  const newVersion = botmuxVersionAt(installPlan.activePackageRoot);
2372
2457
  lastSuccessfulUpdatePlan = installPlan;
2373
2458
  return jsonRes(res, 200, {
@@ -2378,9 +2463,179 @@ const server = createServer(async (req, res) => {
2378
2463
  manager: installPlan.manager,
2379
2464
  });
2380
2465
  }
2466
+ if (req.method === 'POST' && url.pathname === '/api/update/rollback') {
2467
+ if (!authed)
2468
+ return jsonRes(res, 401, { ok: false, error: 'unauthorized' });
2469
+ if (isLocalDevInstall())
2470
+ return jsonRes(res, 400, { ok: false, error: 'local_dev_no_update' });
2471
+ let targetVersion = '';
2472
+ try {
2473
+ const parsed = await readJsonBody(req);
2474
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
2475
+ return jsonRes(res, 400, { ok: false, error: 'invalid_version' });
2476
+ }
2477
+ const body = parsed;
2478
+ if (Object.keys(body).length !== 1 || typeof body.version !== 'string' || !isCanonicalStableVersion(body.version)) {
2479
+ return jsonRes(res, 400, { ok: false, error: 'invalid_version' });
2480
+ }
2481
+ targetVersion = body.version;
2482
+ }
2483
+ catch {
2484
+ return jsonRes(res, 400, { ok: false, error: 'invalid_json' });
2485
+ }
2486
+ const rollback = await cachedRollbackVersions(currentInstalledVersion());
2487
+ if (!rollback.ok)
2488
+ return jsonRes(res, 503, { ok: false, error: 'versions_unavailable' });
2489
+ if (!rollback.versions.some(entry => entry.version === targetVersion)) {
2490
+ return jsonRes(res, 400, { ok: false, error: 'not_rollback_target' });
2491
+ }
2492
+ let installPlan;
2493
+ try {
2494
+ const packageRoot = lastSuccessfulUpdatePlan?.activePackageRoot ?? botmuxInstallRoot();
2495
+ installPlan = withGlobalInstallRegistry(resolveGlobalInstallPlan(packageRoot, process.platform, `botmux@${targetVersion}`));
2496
+ }
2497
+ catch (error) {
2498
+ if (error instanceof UnsupportedGlobalInstallError) {
2499
+ return jsonRes(res, 400, {
2500
+ ok: false,
2501
+ error: 'unsupported_install_method',
2502
+ manager: error.manager,
2503
+ });
2504
+ }
2505
+ throw error;
2506
+ }
2507
+ const node = checkNode();
2508
+ if (!node.ok)
2509
+ return jsonRes(res, 400, { ok: false, error: 'node_too_old', node });
2510
+ if (updateInFlight)
2511
+ return jsonRes(res, 409, { ok: false, error: 'update_in_flight' });
2512
+ updateInFlight = true;
2513
+ let acquired = false;
2514
+ let blockedByRestart = false;
2515
+ let invalidRollbackTarget = false;
2516
+ let installedVersionMismatch = '';
2517
+ let restartIntentError = '';
2518
+ let leaseId = null;
2519
+ let oldVersion = '';
2520
+ try {
2521
+ await withFileLock(globalInstallUpdateLockTarget(), async () => {
2522
+ acquired = true;
2523
+ if (hasActiveRestartLease()) {
2524
+ blockedByRestart = true;
2525
+ return;
2526
+ }
2527
+ oldVersion = botmuxVersionAt(installPlan.activePackageRoot);
2528
+ if (compareVersions(targetVersion, oldVersion) >= 0) {
2529
+ invalidRollbackTarget = true;
2530
+ return;
2531
+ }
2532
+ await runGlobalInstall(installPlan);
2533
+ const newVersion = botmuxVersionAt(installPlan.activePackageRoot);
2534
+ lastSuccessfulUpdatePlan = installPlan;
2535
+ if (newVersion !== targetVersion) {
2536
+ installedVersionMismatch = newVersion;
2537
+ return;
2538
+ }
2539
+ leaseId = claimRestartLease();
2540
+ if (!leaseId) {
2541
+ blockedByRestart = true;
2542
+ return;
2543
+ }
2544
+ try {
2545
+ writeRestartIntent({
2546
+ kind: 'rollback',
2547
+ oldVersion,
2548
+ newVersion,
2549
+ at: new Date().toISOString(),
2550
+ });
2551
+ }
2552
+ catch (error) {
2553
+ restartIntentError = error instanceof Error ? error.message : String(error);
2554
+ clearRestartLease(leaseId);
2555
+ leaseId = null;
2556
+ return;
2557
+ }
2558
+ // Keep the install lock through restart handoff: maintenance cannot
2559
+ // race in between the downgrade and the detached restart driver.
2560
+ await new Promise((resolveLaunch) => {
2561
+ let launched = false;
2562
+ const launch = () => {
2563
+ if (launched)
2564
+ return;
2565
+ launched = true;
2566
+ try {
2567
+ const child = spawnDetachedRestart('dashboard', installPlan.activePackageRoot, leaseId);
2568
+ if (!child.pid)
2569
+ throw new Error('restart driver did not start');
2570
+ }
2571
+ catch (error) {
2572
+ clearRestartLease(leaseId);
2573
+ clearRestartIntent();
2574
+ logger.error(`[dashboard] rollback restart launch failed: ${error instanceof Error ? error.message : error}`);
2575
+ }
2576
+ finally {
2577
+ resolveLaunch();
2578
+ }
2579
+ };
2580
+ res.once('finish', launch);
2581
+ res.once('close', launch);
2582
+ try {
2583
+ jsonRes(res, 202, {
2584
+ ok: true,
2585
+ oldVersion,
2586
+ newVersion,
2587
+ changed: true,
2588
+ manager: installPlan.manager,
2589
+ operation: 'rollback',
2590
+ });
2591
+ }
2592
+ finally {
2593
+ if (res.destroyed || res.writableFinished)
2594
+ launch();
2595
+ }
2596
+ });
2597
+ }, { maxWaitMs: 2_000 });
2598
+ if (blockedByRestart)
2599
+ return jsonRes(res, 409, { ok: false, error: 'restart_in_flight' });
2600
+ if (invalidRollbackTarget)
2601
+ return jsonRes(res, 409, { ok: false, error: 'not_rollback_target' });
2602
+ if (installedVersionMismatch) {
2603
+ return jsonRes(res, 500, {
2604
+ ok: false,
2605
+ error: 'install_version_mismatch',
2606
+ expectedVersion: targetVersion,
2607
+ actualVersion: installedVersionMismatch,
2608
+ });
2609
+ }
2610
+ if (restartIntentError) {
2611
+ return jsonRes(res, 500, { ok: false, error: 'restart_intent_failed', detail: restartIntentError });
2612
+ }
2613
+ return;
2614
+ }
2615
+ catch (error) {
2616
+ if (leaseId)
2617
+ clearRestartLease(leaseId);
2618
+ if (!acquired)
2619
+ return jsonRes(res, 409, { ok: false, error: 'update_in_flight' });
2620
+ if (!res.headersSent) {
2621
+ return jsonRes(res, 500, {
2622
+ ok: false,
2623
+ error: 'install_failed',
2624
+ detail: error instanceof Error ? error.message : String(error),
2625
+ });
2626
+ }
2627
+ logger.error(`[dashboard] rollback failed after response: ${error instanceof Error ? error.message : error}`);
2628
+ return;
2629
+ }
2630
+ finally {
2631
+ updateInFlight = false;
2632
+ }
2633
+ }
2381
2634
  if (req.method === 'POST' && url.pathname === '/api/update/restart') {
2382
2635
  if (!authed)
2383
2636
  return jsonRes(res, 401, { ok: false, error: 'unauthorized' });
2637
+ if (updateInFlight)
2638
+ return jsonRes(res, 409, { ok: false, error: 'update_in_flight' });
2384
2639
  let body = {};
2385
2640
  try {
2386
2641
  const parsed = await readJsonBody(req);
@@ -2389,17 +2644,74 @@ const server = createServer(async (req, res) => {
2389
2644
  }
2390
2645
  catch { /* empty / bad body → plain restart */ }
2391
2646
  const upd = body.update && typeof body.update === 'object' ? body.update : null;
2392
- // After a manual update, leave an `update` breadcrumb so the fresh daemon
2393
- // DMs the owner the changelog (reuses the restart-report pipeline). A plain
2394
- // restart leaves none here; cmdRestart writes a `manual` breadcrumb itself.
2395
- if (upd && typeof upd.oldVersion === 'string' && typeof upd.newVersion === 'string' && upd.oldVersion !== upd.newVersion) {
2396
- try {
2397
- writeRestartIntent({ kind: 'update', oldVersion: upd.oldVersion, newVersion: upd.newVersion, at: new Date().toISOString() });
2647
+ let acquired = false;
2648
+ let leaseId = null;
2649
+ let activePackageRoot;
2650
+ let shouldLaunch = false;
2651
+ try {
2652
+ await withFileLock(globalInstallUpdateLockTarget(), async () => {
2653
+ acquired = true;
2654
+ const claimed = claimRestartLease();
2655
+ if (!claimed) {
2656
+ jsonRes(res, 202, { ok: true, alreadyScheduled: true });
2657
+ return;
2658
+ }
2659
+ leaseId = claimed;
2660
+ try {
2661
+ if (upd && typeof upd.oldVersion === 'string' && typeof upd.newVersion === 'string' && upd.oldVersion !== upd.newVersion) {
2662
+ writeRestartIntent({ kind: 'update', oldVersion: upd.oldVersion, newVersion: upd.newVersion, at: new Date().toISOString() });
2663
+ }
2664
+ else {
2665
+ writeManualIntentIfAbsent();
2666
+ }
2667
+ }
2668
+ catch (error) {
2669
+ clearRestartLease(leaseId);
2670
+ leaseId = null;
2671
+ jsonRes(res, 500, {
2672
+ ok: false,
2673
+ error: 'restart_intent_failed',
2674
+ detail: error instanceof Error ? error.message : String(error),
2675
+ });
2676
+ return;
2677
+ }
2678
+ activePackageRoot = (lastSuccessfulUpdatePlan ?? tryResolveGlobalInstallPlan())?.activePackageRoot;
2679
+ // Send acknowledgement while holding the lock, then release immediately.
2680
+ // The lease itself prevents concurrent restarts — no need to hold the
2681
+ // lock across the network round-trip waiting for res.finish.
2682
+ jsonRes(res, 202, { ok: true });
2683
+ shouldLaunch = true;
2684
+ }, { maxWaitMs: 2_000 });
2685
+ }
2686
+ catch (error) {
2687
+ if (!acquired)
2688
+ return jsonRes(res, 409, { ok: false, error: 'update_in_flight' });
2689
+ throw error;
2690
+ }
2691
+ // Spawn the detached driver after the lock is released. The lease guards
2692
+ // against double-restart; if launch fails we clear the lease so a retry
2693
+ // can succeed.
2694
+ if (shouldLaunch && leaseId) {
2695
+ const launch = () => {
2696
+ try {
2697
+ const child = spawnDetachedRestart('dashboard', activePackageRoot, leaseId);
2698
+ if (!child.pid)
2699
+ throw new Error('restart driver did not start');
2700
+ }
2701
+ catch (error) {
2702
+ clearRestartLease(leaseId);
2703
+ logger.error(`[dashboard] restart launch failed: ${error instanceof Error ? error.message : error}`);
2704
+ }
2705
+ };
2706
+ if (res.destroyed || res.writableFinished) {
2707
+ launch();
2708
+ }
2709
+ else {
2710
+ res.once('finish', launch);
2711
+ res.once('close', launch);
2398
2712
  }
2399
- catch { /* breadcrumb is best-effort */ }
2400
2713
  }
2401
- spawnDetachedRestart('dashboard', lastSuccessfulUpdatePlan?.activePackageRoot);
2402
- return jsonRes(res, 200, { ok: true });
2714
+ return;
2403
2715
  }
2404
2716
  if (req.method === 'GET' && url.pathname === '/api/skills') {
2405
2717
  return jsonRes(res, 200, dashboardSkillsPayload());
@@ -2907,7 +3219,7 @@ const server = createServer(async (req, res) => {
2907
3219
  if (req.method === 'POST' && (m = url.pathname.match(/^\/api\/schedules\/([^/]+)\/(run|pause|resume|delivery)$/))) {
2908
3220
  const id = decodeURIComponent(m[1]);
2909
3221
  const op = m[2];
2910
- const owner = aggregator.scheduleOwnerOf(id);
3222
+ const owner = resolveScheduleOwner(id);
2911
3223
  if (!owner)
2912
3224
  return jsonRes(res, 404, { ok: false, error: 'unknown_schedule' });
2913
3225
  const upstream = await proxyToDaemon(owner, `/api/schedules/${id}/${op}`, { method: 'POST' });
@@ -2915,6 +3227,69 @@ const server = createServer(async (req, res) => {
2915
3227
  res.end(await upstream.text());
2916
3228
  return;
2917
3229
  }
3230
+ // Create a new scheduled task. Body must include `larkAppId` to select
3231
+ // which bot/daemon owns the task (multi-bot dashboards cannot guess).
3232
+ if (req.method === 'POST' && url.pathname === '/api/schedules') {
3233
+ let body;
3234
+ try {
3235
+ body = await readJsonBody(req);
3236
+ }
3237
+ catch {
3238
+ return jsonRes(res, 400, { ok: false, error: 'bad_json' });
3239
+ }
3240
+ if (body === null || typeof body !== 'object') {
3241
+ return jsonRes(res, 400, { ok: false, error: 'body_must_be_object' });
3242
+ }
3243
+ const b = body;
3244
+ const larkAppId = typeof b.larkAppId === 'string' ? b.larkAppId : '';
3245
+ if (!larkAppId)
3246
+ return jsonRes(res, 400, { ok: false, error: 'larkAppId_required' });
3247
+ const upstream = await proxyToDaemon(larkAppId, '/api/schedules', {
3248
+ method: 'POST',
3249
+ headers: { 'content-type': 'application/json' },
3250
+ body: JSON.stringify(body),
3251
+ });
3252
+ res.writeHead(upstream.status, { 'content-type': 'application/json' });
3253
+ res.end(await upstream.text());
3254
+ return;
3255
+ }
3256
+ // Update an existing task (PATCH) or delete it (DELETE). Both route to
3257
+ // the daemon that owns the task. Legacy rows (no larkAppId) fall back to
3258
+ // the primary daemon (botIndex === 0) so they remain editable.
3259
+ if (req.method === 'PATCH' && (m = url.pathname.match(/^\/api\/schedules\/([^/]+)$/))) {
3260
+ const id = decodeURIComponent(m[1]);
3261
+ const owner = resolveScheduleOwner(id);
3262
+ if (!owner)
3263
+ return jsonRes(res, 404, { ok: false, error: 'unknown_schedule' });
3264
+ let body;
3265
+ try {
3266
+ body = await readJsonBody(req);
3267
+ }
3268
+ catch {
3269
+ return jsonRes(res, 400, { ok: false, error: 'bad_json' });
3270
+ }
3271
+ if (body === null || typeof body !== 'object') {
3272
+ return jsonRes(res, 400, { ok: false, error: 'body_must_be_object' });
3273
+ }
3274
+ const upstream = await proxyToDaemon(owner, `/api/schedules/${id}`, {
3275
+ method: 'PATCH',
3276
+ headers: { 'content-type': 'application/json' },
3277
+ body: JSON.stringify(body),
3278
+ });
3279
+ res.writeHead(upstream.status, { 'content-type': 'application/json' });
3280
+ res.end(await upstream.text());
3281
+ return;
3282
+ }
3283
+ if (req.method === 'DELETE' && (m = url.pathname.match(/^\/api\/schedules\/([^/]+)$/))) {
3284
+ const id = decodeURIComponent(m[1]);
3285
+ const owner = resolveScheduleOwner(id);
3286
+ if (!owner)
3287
+ return jsonRes(res, 404, { ok: false, error: 'unknown_schedule' });
3288
+ const upstream = await proxyToDaemon(owner, `/api/schedules/${id}`, { method: 'DELETE' });
3289
+ res.writeHead(upstream.status, { 'content-type': 'application/json' });
3290
+ res.end(await upstream.text());
3291
+ return;
3292
+ }
2918
3293
  // v3 workflow runs. Reads project directly from disk; cancel resolves the
2919
3294
  // immutable run owner and proxies to that daemon (the dashboard never
2920
3295
  // writes the v3 journal itself).