openmausbot 0.1.97 → 0.1.98

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 (144) hide show
  1. package/dist/assets/ActivityPanel-H6D3SD39.js +1 -0
  2. package/dist/assets/BotSettingsDialog-Dqz_AiBZ.js +7 -0
  3. package/dist/assets/CloudBackendPicker-iezpJFpF.js +1 -0
  4. package/dist/assets/ComputerPanel-DZPj-IWu.js +1 -0
  5. package/dist/assets/DesktopViewer-utONms1p.js +5 -0
  6. package/dist/assets/InspectorPanel-OVaBVrzC.js +5 -0
  7. package/dist/assets/KeyboardShortcutsModal-gk9mvHj_.js +1 -0
  8. package/dist/assets/LocalVmWorkspace-DLIvd2kC.js +1 -0
  9. package/dist/assets/MacLocalControl-bJv8yiPB.js +1 -0
  10. package/dist/assets/NewBotDialog-1qGOAdUM.js +2 -0
  11. package/dist/assets/RemoteAgentSettingsPanel-DqKr0HkO.js +1 -0
  12. package/dist/assets/RoutinesPage-BM2D4qGi.js +1 -0
  13. package/dist/assets/RoutinesSection-Dny9ra-c.js +1 -0
  14. package/dist/assets/SettingsModal-gC6XJJmt.js +11 -0
  15. package/dist/assets/TeamMapPage-B6fuu4r_.js +8 -0
  16. package/dist/assets/TriggersPanel-j0qTP2CO.js +1 -0
  17. package/dist/assets/VisibilitySection-DT0FqPvu.js +12 -0
  18. package/dist/assets/WebhooksPanel--YQyCjKO.js +3 -0
  19. package/dist/assets/{abnfDiagram-O67JEVCF-k1UCNXb7.js → abnfDiagram-O67JEVCF-BsjcFX0-.js} +1 -1
  20. package/dist/assets/activity-BxOkayvD.js +1 -0
  21. package/dist/assets/{arc-BUWGa9UU.js → arc-CJNA94c6.js} +1 -1
  22. package/dist/assets/architectureDiagram-NJMV4G6O-dz2ud7lP.js +36 -0
  23. package/dist/assets/{blockDiagram-BEXU5L5S-Ag9nLW4H.js → blockDiagram-BEXU5L5S-ChBEALoR.js} +1 -1
  24. package/dist/assets/brain-CFZ7PEXc.js +1 -0
  25. package/dist/assets/{c4Diagram-YGBWAQC7-Bh0sjgUa.js → c4Diagram-YGBWAQC7-KZNRKDQe.js} +1 -1
  26. package/dist/assets/channel-WhFyujhZ.js +1 -0
  27. package/dist/assets/{chunk-2Q5K7J3B-BFBcIIqu.js → chunk-2Q5K7J3B-BLLwmCET.js} +1 -1
  28. package/dist/assets/{chunk-3FUC2YCW-Dvfpex-f.js → chunk-3FUC2YCW-CQxyreBZ.js} +2 -2
  29. package/dist/assets/{chunk-5VM5RSS4-CMjP2YZW.js → chunk-5VM5RSS4-D6sFMW0u.js} +1 -1
  30. package/dist/assets/{chunk-GTNCS2PH-BryoeTc7.js → chunk-GTNCS2PH-BaZ1YgLF.js} +1 -1
  31. package/dist/assets/{chunk-GWA4HPMP-BhfvcchO.js → chunk-GWA4HPMP-CcalphmC.js} +1 -1
  32. package/dist/assets/{chunk-JWPE2WC7-bcCVp8rp.js → chunk-JWPE2WC7-CSk8R3ve.js} +1 -1
  33. package/dist/assets/{chunk-NETBCI7D-yKIjIlkQ.js → chunk-NETBCI7D-CTmLjA57.js} +1 -1
  34. package/dist/assets/{chunk-WEXAMYUT-D3Zqbmup.js → chunk-WEXAMYUT-CM9Z77Ne.js} +1 -1
  35. package/dist/assets/{chunk-XXDRQBXY-CjVoIMG6.js → chunk-XXDRQBXY-B1fxpMS2.js} +1 -1
  36. package/dist/assets/{classDiagram-v2-NBCMYWYE-mUCWdVyH.js → classDiagram-v2-NBCMYWYE-DCCyoLpj.js} +1 -1
  37. package/dist/assets/computer-control-NLov352p.js +1 -0
  38. package/dist/assets/cose-bilkent-JH36ORCC-KyImBKvE.js +1 -0
  39. package/dist/assets/{cynefin-OW5HDTMX-BpRrxoO4.js → cynefin-OW5HDTMX-fsZc6MGa.js} +1 -1
  40. package/dist/assets/{cynefinDiagram-VND7K2PF-D3VBWCPK.js → cynefinDiagram-VND7K2PF-CGmHsShB.js} +1 -1
  41. package/dist/assets/{dagre-6A5THRUB-DxEIACOO.js → dagre-6A5THRUB-Dah1h-CH.js} +1 -1
  42. package/dist/assets/{diagram-22UHCM2B-BANgZEOS.js → diagram-22UHCM2B-BIu71OyV.js} +1 -1
  43. package/dist/assets/{diagram-3UASUU5V-Cnq7nJir.js → diagram-3UASUU5V-gxhUB_Mu.js} +1 -1
  44. package/dist/assets/{diagram-ATOU4E4O-4TBDIWnz.js → diagram-ATOU4E4O-CBhusUBn.js} +1 -1
  45. package/dist/assets/{diagram-CDSNMT55-CD_B39D4.js → diagram-CDSNMT55-BdPrc86x.js} +1 -1
  46. package/dist/assets/{diagram-MLGK6HIB-CzvnFhEC.js → diagram-MLGK6HIB-Clp110R_.js} +1 -1
  47. package/dist/assets/{diagram-MPIPVDR6-C4w2SNml.js → diagram-MPIPVDR6-DJKfbugw.js} +1 -1
  48. package/dist/assets/{ebnfDiagram-ZINNZB2B-gkUEBm2s.js → ebnfDiagram-ZINNZB2B-DnsltoBu.js} +1 -1
  49. package/dist/assets/{elk-276RUBZZ-B_56X6mw.js → elk-276RUBZZ-jWdq6gqH.js} +1 -1
  50. package/dist/assets/{erDiagram-OPXOYQCR-C_oByoDS.js → erDiagram-OPXOYQCR-DArPVf8u.js} +1 -1
  51. package/dist/assets/{flowDiagram-KWPJA3E3-CR9xXJkC.js → flowDiagram-KWPJA3E3-D2HZ-2f7.js} +1 -1
  52. package/dist/assets/ganttDiagram-FUAMR5RP--o0KIxEo.js +292 -0
  53. package/dist/assets/{gitGraphDiagram-X574FWY7-VJ6-Tp_p.js → gitGraphDiagram-X574FWY7-CsoXr2D7.js} +1 -1
  54. package/dist/assets/image-plus-BbVjGHin.js +1 -0
  55. package/dist/assets/index-BrPW5HDO.css +1 -0
  56. package/dist/assets/index-C3NqfdVU.js +585 -0
  57. package/dist/assets/{index-Cp4S4NOL.js → index-CJTp9JEp.js} +1 -1
  58. package/dist/assets/{infoDiagram-VRGFBTTK-2Lu9te4D.js → infoDiagram-VRGFBTTK-g-6lNp7r.js} +1 -1
  59. package/dist/assets/{ishikawaDiagram-OU5B5YK6-DR49XY-e.js → ishikawaDiagram-OU5B5YK6-B1d6qVTV.js} +1 -1
  60. package/dist/assets/{journeyDiagram-ZHPQQLJL-ZoR0M305.js → journeyDiagram-ZHPQQLJL-BTPuiU6T.js} +1 -1
  61. package/dist/assets/{kanban-definition-PNTS6WVX-DLKHxrO-.js → kanban-definition-PNTS6WVX-bDDuRJvP.js} +1 -1
  62. package/dist/assets/{linear-BASsfWly.js → linear-BVVKbEMy.js} +1 -1
  63. package/dist/assets/{mermaid.core-BVidiPkk.js → mermaid.core-JJdoKyKB.js} +5 -5
  64. package/dist/assets/{mindmap-definition-NLK3R4M7-g_Qx0U5C.js → mindmap-definition-NLK3R4M7-B4aE3T0q.js} +1 -1
  65. package/dist/assets/module-DCUsy2BQ.js +3 -0
  66. package/dist/assets/page-visible-BBo_Zm8g.js +1 -0
  67. package/dist/assets/{pegDiagram-GJSIUBJH-DI9Cjs8w.js → pegDiagram-GJSIUBJH-DFKQWpR7.js} +1 -1
  68. package/dist/assets/{pieDiagram-5QR66LMP-sRI0Uhr5.js → pieDiagram-5QR66LMP-Iv7OKVJi.js} +1 -1
  69. package/dist/assets/{quadrantDiagram-O4NWA36T-CamKTby3.js → quadrantDiagram-O4NWA36T-U2YnfwBk.js} +1 -1
  70. package/dist/assets/{railroadDiagram-XR7U4H2S-DOLlkbf3.js → railroadDiagram-XR7U4H2S-CuPy6RqM.js} +1 -1
  71. package/dist/assets/remote-desktop-panel-BNEj7tUx.js +1 -0
  72. package/dist/assets/{requirementDiagram-PLB6GJNP-DrRQ5r97.js → requirementDiagram-PLB6GJNP-bMI5AK7T.js} +1 -1
  73. package/dist/assets/{sankeyDiagram-IPEJSGJF-DGS_lXcX.js → sankeyDiagram-IPEJSGJF-BKcFE2WQ.js} +1 -1
  74. package/dist/assets/{sequenceDiagram-PO4LG4MO-CkjWqwtf.js → sequenceDiagram-PO4LG4MO-Bn9q88kl.js} +1 -1
  75. package/dist/assets/{sizeCapture-INFHLROL-CepRgful.js → sizeCapture-INFHLROL-Cvmdq9HD.js} +1 -1
  76. package/dist/assets/{stateDiagram-v2-GCMORJYK-hykr-9RS.js → stateDiagram-v2-GCMORJYK-JJQYw3eQ.js} +1 -1
  77. package/dist/assets/{swimlanes-2SLR337P-CKOHEZbI.js → swimlanes-2SLR337P-BkSCBEwp.js} +1 -1
  78. package/dist/assets/swimlanesDiagram-TC7HE7FX-CmTirbVT.js +8 -0
  79. package/dist/assets/table-file.worker-KkMx-SNx.js +10 -0
  80. package/dist/assets/{timeline-definition-EJHVYXUP-CB8pJ6AC.js → timeline-definition-EJHVYXUP-DsaM4GHr.js} +1 -1
  81. package/dist/assets/{usecaseDiagram-POWQR4AR-DrclJHdl.js → usecaseDiagram-POWQR4AR-3FOwLq7B.js} +1 -1
  82. package/dist/assets/user-DcZBnAuv.js +1 -0
  83. package/dist/assets/{vennDiagram-UO4OBE2U-zS23qjv8.js → vennDiagram-UO4OBE2U-DyNjJsKt.js} +1 -1
  84. package/dist/assets/{wardleyDiagram-VNRHLVJA-52jgeKxz.js → wardleyDiagram-VNRHLVJA-Y4ZMcnBC.js} +1 -1
  85. package/dist/assets/{xychartDiagram-PMCCYNJV-DSdl9rQ8.js → xychartDiagram-PMCCYNJV-BXZpg955.js} +1 -1
  86. package/dist/index.html +2 -2
  87. package/dist-server/cloud-home-start.js +19 -8
  88. package/dist-server/container-mcp.js +8 -0
  89. package/dist-server/desktop-entry.mjs +54 -0
  90. package/dist-server/drivers/agents-proxy.js +21 -4
  91. package/dist-server/index.js +1412 -821
  92. package/dist-server/openmausbot.js +837 -571
  93. package/dist-server/pair-cli.js +837 -571
  94. package/dist-server/permission-proxy.js +2 -0
  95. package/dist-server/server/atomic.js +70 -2
  96. package/dist-server/server/cloud-home-start.js +3 -3
  97. package/dist-server/server/config.js +22 -1
  98. package/dist-server/server/connector-card-text.js +15 -0
  99. package/dist-server/server/default-model-selection.js +7 -4
  100. package/dist-server/server/drivers/acp/core.js +10 -0
  101. package/dist-server/server/drivers/acp/grok.js +47 -5
  102. package/dist-server/server/drivers/agents-catalog.js +24 -4
  103. package/dist-server/server/drivers/agents-proxy.js +1 -0
  104. package/dist-server/server/drivers/chat-mcp-tools.js +5 -3
  105. package/dist-server/server/drivers/claude-login-auth.js +1 -1
  106. package/dist-server/server/drivers/claude.js +35 -11
  107. package/dist-server/server/drivers/codex-device-auth.js +27 -319
  108. package/dist-server/server/drivers/device-auth.js +342 -0
  109. package/dist-server/server/drivers/openai-chat.js +12 -4
  110. package/dist-server/server/fleet-agent.js +18 -5
  111. package/dist-server/server/harness/http.js +5 -2
  112. package/dist-server/server/harness/registry.js +19 -0
  113. package/dist-server/server/harness-capabilities.js +7 -4
  114. package/dist-server/server/hosted-models.js +3 -1
  115. package/dist-server/server/index.js +268 -54
  116. package/dist-server/server/key-rejections.js +57 -0
  117. package/dist-server/server/message-file.js +7 -4
  118. package/dist-server/server/permission-proxy.js +4 -0
  119. package/dist-server/server/provider-key-check.js +5 -1
  120. package/dist-server/server/request-auth.js +1 -1
  121. package/dist-server/server/room-turn-timeout.js +28 -0
  122. package/dist-server/server/routes/thread-models.js +36 -0
  123. package/dist-server/server/routines.js +2 -2
  124. package/dist-server/server/skills.js +3 -2
  125. package/dist-server/server/store.js +148 -60
  126. package/dist-server/server/team-backup.js +19 -4
  127. package/dist-server/server/thread-model.js +25 -0
  128. package/dist-server/server/turn-continuation.js +8 -2
  129. package/dist-server/server/webhook-ingress.js +8 -3
  130. package/dist-server/server/webhooks.js +40 -5
  131. package/dist-server/shared/device-sign-in.js +57 -0
  132. package/dist-server/shared/team-backup.js +2 -0
  133. package/dist-server/shared/thread-model.js +13 -0
  134. package/dist-server/vps-container-mcp.js +8 -0
  135. package/dist-server/workspace-backup.worker.js +41 -22
  136. package/package.json +1 -1
  137. package/dist/assets/DesktopViewer-CPidMCtt.js +0 -5
  138. package/dist/assets/architectureDiagram-NJMV4G6O-DiPor3UT.js +0 -36
  139. package/dist/assets/channel-DR8eD6QW.js +0 -1
  140. package/dist/assets/cose-bilkent-JH36ORCC-BBmR1bbX.js +0 -1
  141. package/dist/assets/ganttDiagram-FUAMR5RP-gtYBTu0B.js +0 -292
  142. package/dist/assets/index-CpaIXRLp.js +0 -614
  143. package/dist/assets/index-Z81SMsgR.css +0 -1
  144. package/dist/assets/swimlanesDiagram-TC7HE7FX-D-8vqZ4-.js +0 -8
@@ -5,7 +5,7 @@
5
5
  import { createHash } from "node:crypto";
6
6
  import { chmodSync, existsSync, readFileSync, mkdirSync, rmSync, statSync, unlinkSync } from "node:fs";
7
7
  import { join } from "node:path";
8
- import { writeFileAtomic } from "./atomic.js";
8
+ import { writeFileAtomic, writeFileAtomicIfChanged } from "./atomic.js";
9
9
  import { parseToolScope, toolScopeWidens } from "../shared/tool-scope.js";
10
10
  import { ensureSections, readSections, changeEmptySection } from "./section-context.js";
11
11
  import { removeBotFolder, soulFile, soulHash, writeSoulMirror } from "./bot-folder.js";
@@ -22,6 +22,7 @@ import { approvalModeFor, isApprovalMode, modelSwitchNeedsAsk } from "../shared/
22
22
  import { REMOVED_COMPUTER_DRIVER } from "./computer-engine-removal.js";
23
23
  import { isMentionBoundary, isMentionNameContinuation } from "../shared/mention-boundary.js";
24
24
  import { CONNECTOR_SLUG_PATTERN, CONNECTOR_TOOL_NAME_PATTERN } from "../shared/wire.js";
25
+ import { sameModelSelection } from "../shared/thread-model.js";
25
26
  export const groupWireProjectionIsExact = true;
26
27
  // Unicode's complete emoji sequences include flags, skin tones and ZWJ
27
28
  // combinations. Also allow unqualified single symbols (e.g. ♥), but not
@@ -32,13 +33,19 @@ export function isProjectEmoji(value) {
32
33
  }
33
34
  export const taskWireProjectionIsExact = true;
34
35
  /** The typed wire projection for one task. Pairs with the assertion above:
35
- * returning WireTask means an undeclared server field cannot ride silently. */
36
- export function toWireTask(task) {
37
- const { resumeCursors: _resumeCursors, lastInstanceId: _lastInstanceId, handedMessages: _handedMessages, appliedCompactionId: _appliedCompactionId, contextFloor: _contextFloor, lastContextModel: _lastContextModel, surfaceSource, ...wire } = task;
36
+ * returning WireTask means an undeclared server field cannot ride silently.
37
+ * A thread with no model of its own follows its bot: the wire carries the
38
+ * model it runs on either way, so every client reads one effective model. */
39
+ export function toWireTask(task, botModel) {
40
+ const { resumeCursors: _resumeCursors, lastInstanceId: _lastInstanceId, handedMessages: _handedMessages, appliedCompactionId: _appliedCompactionId, contextFloor: _contextFloor, lastContextModel: _lastContextModel, surfaceSource, ...stored } = task;
41
+ const wire = { ...stored, modelSelection: structuredClone(task.modelSelection ?? botModel), followsBotModel: task.modelSelection === undefined };
38
42
  // Who pinned stays private. A client learns only whether the pin is the
39
43
  // machine's own record, so it never presents one as the person's choice.
40
44
  return surfaceSource === "auto" && wire.surface !== undefined ? { ...wire, surfaceAuto: true } : wire;
41
45
  }
46
+ /** The level a thread drops to when its engine changes under a level the new
47
+ * engine would have to confirm (modelSwitchNeedsAsk), as on every switch. */
48
+ const ASK_NOW = { approvalMode: "ask", autoApprove: false, alwaysAllow: [] };
42
49
  const TASK_PATCH_FIELDS = [
43
50
  "title", "projectId", "modelSelection", "approvalMode", "autoApprove", "alwaysAllow",
44
51
  "unread", "rewound", "archivedAt", "pinned", "pinnedMessageId", "resumeCursors", "lastInstanceId", "cwd",
@@ -326,6 +333,9 @@ export function parseConnectorTools(value) {
326
333
  }
327
334
  const BOTS_FILE = join(DATA_DIR, "bots.json");
328
335
  const GROUPS_FILE = join(DATA_DIR, "groups.json");
336
+ /** Written once the copies older builds made of a bot's model on its threads
337
+ * are cleared (Store load), so that cleanup runs once per data directory. */
338
+ const THREAD_MODEL_COPIES_CLEARED = join(DATA_DIR, "thread-model-copies-cleared");
329
339
  /** The registries carry souls, project paths and per-bot settings, so they are
330
340
  * owner-only like the other data-dir stores. A file written by an older
331
341
  * release (or loosened by hand) is tightened on load. Best effort: failing to
@@ -456,6 +466,7 @@ export class Store {
456
466
  threads = new Map();
457
467
  defaultSelection;
458
468
  completeNewBotSelection;
469
+ driverKindOf;
459
470
  listeners = new Set();
460
471
  /** A broken team registry must not prevent loading independent chat data. */
461
472
  registeringInitialSections = true;
@@ -465,9 +476,14 @@ export class Store {
465
476
  constructor(defaultSelection,
466
477
  /** Workspace-wide new-bot defaults (config newBots), applied to every
467
478
  * new bot's selection whichever path created it. */
468
- completeNewBotSelection = (selection) => selection) {
479
+ completeNewBotSelection = (selection) => selection,
480
+ /** The driver an engine instance runs, for the level a thread keeps when
481
+ * its engine changes (modelSwitchNeedsAsk). Unknown counts as another
482
+ * driver, so Full and Custom fall back to Ask. */
483
+ driverKindOf = () => undefined) {
469
484
  this.defaultSelection = defaultSelection;
470
485
  this.completeNewBotSelection = completeNewBotSelection;
486
+ this.driverKindOf = driverKindOf;
471
487
  mkdirSync(DATA_DIR, { recursive: true });
472
488
  for (const file of [BOTS_FILE, GROUPS_FILE])
473
489
  tightenRegistryFile(file);
@@ -696,11 +712,17 @@ export class Store {
696
712
  }
697
713
  if (groupsMigrated)
698
714
  this.saveGroups();
715
+ // Older builds copied a bot's model onto every thread it made, so a
716
+ // thread on exactly its bot's model is a copy, not a person's pick: once
717
+ // per data directory it is cleared and the thread follows its bot. A
718
+ // different model can't be told apart from a pick and stays. Only over a
719
+ // bot list this load could read, so an unreadable file is not skipped.
720
+ const clearModelCopies = !existsSync(THREAD_MODEL_COPIES_CLEARED) && (botsLoaded || !existsSync(BOTS_FILE));
699
721
  // bots saved before tasks existed have one endless thread; adopt it as
700
722
  // their first task so nothing is lost and nothing special-cases it
701
723
  for (const b of this.bots) {
702
- // Folders are organizational only. Preserve existing thread model
703
- // snapshots while discarding the unshipped folder-default setting.
724
+ // Folders are organizational only. Preserve threads' own models while
725
+ // discarding the unshipped folder-default setting.
704
726
  if (b.projects?.some((project) => "modelSelection" in project)) {
705
727
  b.projects = b.projects.map(({ id, name, emoji }) => ({ id, name, ...(isProjectEmoji(emoji) ? { emoji } : {}) }));
706
728
  botsMigrated = true;
@@ -730,8 +752,8 @@ export class Store {
730
752
  botsMigrated = true;
731
753
  }
732
754
  for (const task of b.tasks) {
733
- if (task.modelSelection === undefined) {
734
- task.modelSelection = structuredClone(b.modelSelection);
755
+ if (clearModelCopies && task.modelSelection !== undefined && sameModelSelection(task.modelSelection, b.modelSelection)) {
756
+ delete task.modelSelection;
735
757
  botsMigrated = true;
736
758
  }
737
759
  if (!task.resumeCursors) {
@@ -774,6 +796,8 @@ export class Store {
774
796
  }
775
797
  if (botsMigrated)
776
798
  this.saveBots();
799
+ if (clearModelCopies)
800
+ writeFileAtomic(THREAD_MODEL_COPIES_CLEARED, "Threads follow their bot's model; the copies older builds made are cleared.\n", { mode: 0o600 });
777
801
  // Search reads SQLite directly, so migrate every known legacy transcript
778
802
  // at startup rather than waiting until the user happens to open it. Only
779
803
  // pending JSON files are touched; already-migrated threads stay lazy.
@@ -896,10 +920,13 @@ export class Store {
896
920
  if (botsDirty)
897
921
  this.saveBots();
898
922
  }
923
+ // Many saves repeat what is already on disk (the same resume cursor, an
924
+ // unchanged task field), so both registries skip a byte-identical write.
925
+ // Events are still emitted by the callers either way.
899
926
  saveBots(bots = this.bots, registerSections = true) {
900
927
  if (registerSections)
901
928
  this.rememberSections([...this.bots, ...bots].map((bot) => bot.section));
902
- writeFileAtomic(BOTS_FILE, JSON.stringify(bots.map(({ busy: _busy, activity: _activity, ...bot }) => ({
929
+ writeFileAtomicIfChanged(BOTS_FILE, JSON.stringify(bots.map(({ busy: _busy, activity: _activity, ...bot }) => ({
903
930
  ...bot,
904
931
  tasks: bot.tasks?.map(({ busy: _taskBusy, activity: _taskActivity, turnStartedAt: _taskTurnStarted, ...task }) => persistedPin(task)),
905
932
  })), null, 2), { mode: 0o600 });
@@ -907,7 +934,7 @@ export class Store {
907
934
  saveGroups(groups = this.groups, registerSections = true) {
908
935
  if (registerSections)
909
936
  this.rememberSections(groups.map((group) => group.section));
910
- writeFileAtomic(GROUPS_FILE, JSON.stringify(groups.map(({ busyBotId: _busyBotId, turnStartedAt: _turnStartedAt, ...g }) => ({
937
+ writeFileAtomicIfChanged(GROUPS_FILE, JSON.stringify(groups.map(({ busyBotId: _busyBotId, turnStartedAt: _turnStartedAt, ...g }) => ({
911
938
  ...g,
912
939
  ...(g.tasks ? { tasks: g.tasks.map((task) => persistedPin(task)) } : {}),
913
940
  })), null, 2), { mode: 0o600 });
@@ -1120,6 +1147,9 @@ export class Store {
1120
1147
  else if (!patch.busyBotId)
1121
1148
  delete group.turnStartedAt;
1122
1149
  }
1150
+ if (Object.prototype.hasOwnProperty.call(patch, "turnTimeoutMinutes") && patch.turnTimeoutMinutes == null) {
1151
+ delete group.turnTimeoutMinutes;
1152
+ }
1123
1153
  if (!group.dm && Object.prototype.hasOwnProperty.call(patch, "pinnedMessageId")) {
1124
1154
  const active = this.activeGroupTask(group.id);
1125
1155
  if (active)
@@ -1260,6 +1290,20 @@ export class Store {
1260
1290
  this.emit({ type: "group", groupId });
1261
1291
  return task;
1262
1292
  }
1293
+ /** Null removes the conversation's own ceiling so the next turn uses the
1294
+ * global group limit. A sibling conversation is left alone. */
1295
+ setGroupTaskTurnTimeout(groupId, threadId, minutes) {
1296
+ const task = this.groupTaskByThread(groupId, threadId);
1297
+ if (!task)
1298
+ return null;
1299
+ if (minutes == null)
1300
+ delete task.turnTimeoutMinutes;
1301
+ else
1302
+ task.turnTimeoutMinutes = minutes;
1303
+ this.saveGroups();
1304
+ this.emit({ type: "group", groupId });
1305
+ return task;
1306
+ }
1263
1307
  renameGroupTask(groupId, threadId, title) {
1264
1308
  const task = this.groupTaskByThread(groupId, threadId);
1265
1309
  if (!task)
@@ -1626,7 +1670,6 @@ export class Store {
1626
1670
  createdAt: bot.createdAt,
1627
1671
  updatedAt: bot.createdAt,
1628
1672
  resumeCursors: {},
1629
- modelSelection: structuredClone(bot.modelSelection),
1630
1673
  unread: false,
1631
1674
  activity: "idle",
1632
1675
  busy: false,
@@ -1658,7 +1701,8 @@ export class Store {
1658
1701
  return bot;
1659
1702
  }
1660
1703
  /** All setup fields and the Chief's receipt commit before publishing any
1661
- * mutation. Model defaults never rewrite saved thread selections. */
1704
+ * mutation. A new model moves the threads that follow the bot, never a
1705
+ * thread's own model. */
1662
1706
  applyTeamSetup(request) {
1663
1707
  const chief = this.bot(request.botId);
1664
1708
  if (!chief)
@@ -1687,7 +1731,7 @@ export class Store {
1687
1731
  // a restricted Chief never creates a bot everyone sees.
1688
1732
  ...(chief.visibility && chief.visibility !== "everyone" ? { visibility: structuredClone(chief.visibility) } : {}),
1689
1733
  tasks: [{ threadId: operation.threadId, title: UNTITLED_THREAD, createdAt, updatedAt: createdAt, resumeCursors: {},
1690
- modelSelection: structuredClone(modelSelection), approvalMode: "ask", autoApprove: false,
1734
+ approvalMode: "ask", autoApprove: false,
1691
1735
  unread: false, activity: "idle", busy: false }],
1692
1736
  };
1693
1737
  // "" is the private-workspace spelling on proposal; the record
@@ -1702,13 +1746,7 @@ export class Store {
1702
1746
  const previous = nextBots[at];
1703
1747
  next = { ...previous, ...operation.fields };
1704
1748
  if (operation.fields.modelSelection)
1705
- next.tasks = previous.tasks?.map((task) => ({
1706
- ...task,
1707
- modelSelection: structuredClone(task.modelSelection ?? previous.modelSelection),
1708
- approvalMode: approvalModeFor(this.projectBotForTask(previous.id, task.threadId)),
1709
- autoApprove: task.autoApprove ?? previous.autoApprove,
1710
- alwaysAllow: structuredClone(task.alwaysAllow ?? previous.alwaysAllow ?? []),
1711
- }));
1749
+ next.tasks = this.withDefaultModel(previous, operation.fields.modelSelection);
1712
1750
  nextBots[at] = next;
1713
1751
  }
1714
1752
  if (operation.fields.chiefOfStaff === false)
@@ -1746,23 +1784,38 @@ export class Store {
1746
1784
  this.emit({ type: "bot", botId: chief.id });
1747
1785
  return result;
1748
1786
  }
1749
- /** One reviewed default-model change (propose_model): task stamping is
1750
- * identical to applyTeamSetup's update branch — saved per-thread
1751
- * selections are never rewritten, and a thread with no selection of its
1752
- * own is pinned to the previous default so it does not silently follow
1753
- * the new one. */
1787
+ /** Moving this thread from one model to another takes its level back to
1788
+ * Ask where the new engine would have to confirm it (modelSwitchNeedsAsk),
1789
+ * as on every engine switch. Read before anything changes. */
1790
+ switchNeedsAsk(botId, threadId, from, to) {
1791
+ if (from.instanceId === to.instanceId)
1792
+ return false;
1793
+ const thread = this.projectBotForTask(botId, threadId);
1794
+ return Boolean(thread) && modelSwitchNeedsAsk(approvalModeFor(thread), this.driverKindOf(from.instanceId), this.driverKindOf(to.instanceId));
1795
+ }
1796
+ /** A bot's threads as a change of its model to `next` leaves them. Each
1797
+ * keeps the level it runs at now (older threads may still inherit the
1798
+ * bot's, which may change in the same write); one that follows the bot
1799
+ * moves with it, back to Ask where the new engine would have to confirm
1800
+ * its level; a thread's own model stays. */
1801
+ withDefaultModel(bot, next) {
1802
+ return bot.tasks?.map((task) => ({
1803
+ ...task,
1804
+ approvalMode: approvalModeFor(this.projectBotForTask(bot.id, task.threadId)),
1805
+ autoApprove: task.autoApprove ?? bot.autoApprove,
1806
+ alwaysAllow: structuredClone(task.alwaysAllow ?? bot.alwaysAllow ?? []),
1807
+ ...(task.modelSelection === undefined && this.switchNeedsAsk(bot.id, task.threadId, bot.modelSelection, next) ? structuredClone(ASK_NOW) : {}),
1808
+ }));
1809
+ }
1810
+ /** One reviewed default-model change (propose_model), with
1811
+ * applyTeamSetup's task stamping (withDefaultModel): every thread that
1812
+ * follows the bot moves to the new model, and a thread's own model, which a
1813
+ * person picked there, stays. */
1754
1814
  applyModelDefault(id, modelSelection) {
1755
1815
  const previous = this.bot(id);
1756
1816
  if (!previous)
1757
1817
  return null;
1758
- const next = { ...previous, modelSelection: structuredClone(modelSelection) };
1759
- next.tasks = previous.tasks?.map((task) => ({
1760
- ...task,
1761
- modelSelection: structuredClone(task.modelSelection ?? previous.modelSelection),
1762
- approvalMode: approvalModeFor(this.projectBotForTask(previous.id, task.threadId)),
1763
- autoApprove: task.autoApprove ?? previous.autoApprove,
1764
- alwaysAllow: structuredClone(task.alwaysAllow ?? previous.alwaysAllow ?? []),
1765
- }));
1818
+ const next = { ...previous, modelSelection: structuredClone(modelSelection), tasks: this.withDefaultModel(previous, modelSelection) };
1766
1819
  this.saveBots(this.bots.map((candidate) => candidate.id === id ? next : candidate));
1767
1820
  this.bots = this.bots.map((candidate) => candidate.id === id ? next : candidate);
1768
1821
  this.emit({ type: "bot", botId: id });
@@ -1860,9 +1913,16 @@ export class Store {
1860
1913
  patch = { ...patch };
1861
1914
  delete patch.toolScope;
1862
1915
  }
1916
+ // A new model moves the threads that follow the bot; one whose level the
1917
+ // new engine would have to confirm goes back to Ask. That is a
1918
+ // revocation, so it holds in memory like the others below.
1919
+ const toAsk = patch.modelSelection ? (bot.tasks ?? []).filter((task) => task.modelSelection === undefined &&
1920
+ this.switchNeedsAsk(id, task.threadId, bot.modelSelection, patch.modelSelection)) : [];
1863
1921
  // Runtime revocations must become effective in memory even when disk is
1864
1922
  // unavailable. Profile edits use the separate atomic path below.
1865
1923
  Object.assign(bot, patch);
1924
+ for (const task of toAsk)
1925
+ Object.assign(task, structuredClone(ASK_NOW));
1866
1926
  const task = this.activeTask(id);
1867
1927
  if (task) {
1868
1928
  for (const key of ["resumeCursors", "rewound", "pinnedMessageId", "unread"]) {
@@ -2586,37 +2646,61 @@ export class Store {
2586
2646
  this.emit({ type: "bot", botId });
2587
2647
  return cleared;
2588
2648
  }
2589
- /** Model/provider changes are one configuration transaction: never publish
2590
- * a new provider before its confirmed approval downgrade, or change the
2591
- * default while leaving the selected thread behind after a write failure. */
2649
+ /** A person picks a model in one thread, and with updateBotDefault makes it
2650
+ * the bot's model too. A thread on its bot's model follows the bot: it
2651
+ * keeps no model of its own. Model/provider changes are one configuration
2652
+ * transaction: never publish a new provider before its confirmed approval
2653
+ * downgrade, or change the default while leaving the selected thread
2654
+ * behind after a write failure. */
2592
2655
  switchTaskModel(botId, threadId, selection, updateBotDefault, resetApprovalToAsk, taskPatch = {}) {
2593
2656
  const bot = this.bot(botId);
2594
2657
  const task = this.taskByThread(botId, threadId);
2595
2658
  if (!bot || !task)
2596
2659
  return null;
2597
- const patch = { modelSelection: structuredClone(selection),
2598
- ...(resetApprovalToAsk ? { approvalMode: "ask", autoApprove: false, alwaysAllow: [] } : {}) };
2599
- const nextTask = persistedPin({ ...task, ...taskPatch, ...patch,
2660
+ const approval = resetApprovalToAsk ? structuredClone(ASK_NOW) : {};
2661
+ const own = sameModelSelection(selection, updateBotDefault ? selection : bot.modelSelection) ? undefined : structuredClone(selection);
2662
+ const nextTask = persistedPin({ ...task, ...taskPatch, modelSelection: own, ...approval,
2600
2663
  ...(typeof taskPatch.title === "string" ? { title: taskPatch.title.trim().slice(0, 80) || UNTITLED_THREAD } : {}) });
2601
- // Older threads may still inherit settings. Freeze their effective
2602
- // values before updating the default so "other threads unchanged" also
2603
- // holds for workspaces created before per-thread approval settings.
2604
- const nextTasks = bot.tasks.map((candidate) => candidate === task ? nextTask : !updateBotDefault ? candidate : {
2605
- ...candidate,
2606
- modelSelection: structuredClone(candidate.modelSelection ?? bot.modelSelection),
2607
- approvalMode: approvalModeFor(this.projectBotForTask(botId, candidate.threadId)),
2608
- autoApprove: candidate.autoApprove ?? bot.autoApprove,
2609
- alwaysAllow: structuredClone(candidate.alwaysAllow ?? bot.alwaysAllow ?? []),
2610
- });
2611
- const next = { ...bot, ...(updateBotDefault ? patch : {}),
2612
- tasks: nextTasks };
2613
- this.saveBots(this.bots.map((candidate) => candidate === bot ? next : candidate));
2664
+ // A new bot model moves the threads that follow it (withDefaultModel);
2665
+ // every other thread keeps its model and the level it runs at now.
2666
+ const others = updateBotDefault ? this.withDefaultModel(bot, selection) : bot.tasks;
2667
+ const nextTasks = bot.tasks.map((candidate, index) => candidate === task ? nextTask : others[index]);
2668
+ const botPatch = updateBotDefault ? { modelSelection: structuredClone(selection), ...approval } : {};
2669
+ this.saveBots(this.bots.map((candidate) => candidate === bot ? { ...bot, ...botPatch, tasks: nextTasks } : candidate));
2614
2670
  bot.tasks.forEach((candidate, index) => Object.assign(candidate, nextTasks[index]));
2615
- if (updateBotDefault)
2616
- Object.assign(bot, patch);
2671
+ Object.assign(bot, botPatch);
2617
2672
  this.emit({ type: "bot", botId });
2618
2673
  return task;
2619
2674
  }
2675
+ /** These threads follow their bot's model again: each one's own model is
2676
+ * cleared ("Switch them too", "Use the bot's model", or a model that can't
2677
+ * run here). A level the bot's engine would have to confirm goes back to
2678
+ * Ask, as on every engine switch, and `reset` adds what a changed model
2679
+ * needs (a hosted workspace's fresh session). One write. Returns the
2680
+ * threads that changed; a listed thread that already follows stays as it is. */
2681
+ followBotModel(botId, threadIds, reset = () => ({})) {
2682
+ const bot = this.bot(botId);
2683
+ if (!bot?.tasks)
2684
+ return [];
2685
+ const listed = new Set(threadIds);
2686
+ const patches = bot.tasks.map((task) => !listed.has(task.threadId) || task.modelSelection === undefined ? null : {
2687
+ ...reset(task.modelSelection, bot.modelSelection),
2688
+ modelSelection: undefined,
2689
+ ...(this.switchNeedsAsk(botId, task.threadId, task.modelSelection, bot.modelSelection) ? structuredClone(ASK_NOW) : {}),
2690
+ });
2691
+ const moved = bot.tasks.filter((_, index) => patches[index]).map((task) => task.threadId);
2692
+ if (!moved.length)
2693
+ return [];
2694
+ const nextTasks = bot.tasks.map((task, index) => patches[index] ? { ...task, ...patches[index] } : task);
2695
+ this.saveBots(this.bots.map((candidate) => candidate === bot ? { ...bot, tasks: nextTasks } : candidate));
2696
+ bot.tasks.forEach((task, index) => { if (patches[index])
2697
+ Object.assign(task, patches[index]); });
2698
+ const active = bot.tasks.find((task) => task.threadId === bot.threadId);
2699
+ if (active)
2700
+ this.mirrorActiveTask(bot, active);
2701
+ this.emit({ type: "bot", botId });
2702
+ return moved;
2703
+ }
2620
2704
  /** One durable write: never leave only part of a bot's threads updated. */
2621
2705
  setAllThreadApprovalMode(botId, mode) {
2622
2706
  const bot = this.bot(botId);
@@ -2656,9 +2740,9 @@ export class Store {
2656
2740
  bot.pinnedMessageId = task.pinnedMessageId;
2657
2741
  }
2658
2742
  /** A fresh context on the same bot: new thread, new session, same
2659
- * persona/tools/computer. Becomes the active task. It runs on the bot's
2660
- * default model unless the caller hands it one (a thread opened from
2661
- * another of this bot's threads keeps that thread's model). */
2743
+ * persona/tools/computer. Becomes the active task. It follows the bot's
2744
+ * model unless the caller hands it another one: a thread opened from
2745
+ * another of this bot's threads keeps the model a person picked there. */
2662
2746
  createTask(botId, title, activate = true, projectId, openedBy, approvalMode, modelSelection) {
2663
2747
  const bot = this.bot(botId);
2664
2748
  if (!bot)
@@ -2674,7 +2758,7 @@ export class Store {
2674
2758
  ...(projectId ? { projectId } : {}),
2675
2759
  ...(openedBy ? { openedBy: structuredClone(openedBy) } : {}),
2676
2760
  resumeCursors: {},
2677
- modelSelection: structuredClone(modelSelection ?? bot.modelSelection),
2761
+ ...(modelSelection && !sameModelSelection(modelSelection, bot.modelSelection) ? { modelSelection: structuredClone(modelSelection) } : {}),
2678
2762
  approvalMode: approvalMode ?? approvalModeFor(bot),
2679
2763
  autoApprove: approvalMode ? false : Boolean(bot.autoApprove),
2680
2764
  alwaysAllow: approvalMode ? [] : [...(bot.alwaysAllow ?? [])],
@@ -2780,10 +2864,14 @@ export class Store {
2780
2864
  this.emit({ type: "bot", botId });
2781
2865
  return bot;
2782
2866
  }
2867
+ /** A first run: no bot yet, so seedIfEmpty makes one. */
2868
+ needsSeed() {
2869
+ return this.bots.length === 0;
2870
+ }
2783
2871
  /** First-run seed: one bot so the app never opens empty — it gets a
2784
2872
  * random friendly name like every other bot. */
2785
2873
  seedIfEmpty() {
2786
- if (this.bots.length)
2874
+ if (!this.needsSeed())
2787
2875
  return;
2788
2876
  this.createBot();
2789
2877
  }
@@ -78,6 +78,9 @@ export function createTeamBackup(store, routines, name) {
78
78
  ? { botId: task.closedBy.botId, name: task.closedBy.name, at: task.closedBy.at }
79
79
  : undefined,
80
80
  pinned: task.pinned === true ? true : undefined,
81
+ turnTimeoutMinutes: "turnTimeoutMinutes" in task && typeof task.turnTimeoutMinutes === "number"
82
+ ? task.turnTimeoutMinutes
83
+ : undefined,
81
84
  activeLeafId: store.activeLeaf(task.threadId),
82
85
  messages: store.messagesFor(task.threadId).map((message) => ({
83
86
  id: message.id, role: message.role, text: messageText(message), at: message.at,
@@ -97,7 +100,10 @@ export function createTeamBackup(store, routines, name) {
97
100
  ? { kind: "mentions" }
98
101
  : group.defaultResponder.kind === "auto" && group.defaultResponder.fallbackBotId && !memberIds.includes(group.defaultResponder.fallbackBotId)
99
102
  ? { kind: "auto" } : group.defaultResponder,
100
- activeTask: group.threadId, tasks: history(group),
103
+ activeTask: group.threadId,
104
+ tasks: history(group).map((task, index) => group.dm && index === 0 && typeof group.turnTimeoutMinutes === "number"
105
+ ? { ...task, turnTimeoutMinutes: group.turnTimeoutMinutes }
106
+ : task),
101
107
  };
102
108
  });
103
109
  const validRoutines = routines.filter((routine) => {
@@ -197,8 +203,9 @@ export function importTeamBackup(store, routines, input, selection, options = {}
197
203
  const bot = store.bot(botIds.get(source.key));
198
204
  const tasks = source.tasks.map((task, i) => {
199
205
  const record = {
206
+ // Each imported thread follows the imported bot's model.
200
207
  threadId: i === 0 ? bot.threadId : newId(), title: task.title, createdAt: task.createdAt, resumeCursors: {},
201
- modelSelection: structuredClone(bot.modelSelection), activity: "idle", busy: false, unread: false,
208
+ activity: "idle", busy: false, unread: false,
202
209
  ...(task.titleFromFirstMessage ? { titleFromFirstMessage: true } : {}),
203
210
  };
204
211
  // Same rule as a message's `from`: the opener is remapped to its
@@ -229,9 +236,16 @@ export function importTeamBackup(store, routines, input, selection, options = {}
229
236
  groups.push(group);
230
237
  groupIds.set(source.key, group.id);
231
238
  const responder = source.defaultResponder;
232
- store.patchGroup(group.id, { bulletin: source.bulletin, setupCompletedAt: Date.now(), defaultResponder: responder.kind === "member" ? { kind: "member", botId: botIds.get(responder.botId) }
239
+ store.patchGroup(group.id, {
240
+ bulletin: source.bulletin,
241
+ setupCompletedAt: Date.now(),
242
+ ...(source.dm && source.tasks[0]?.turnTimeoutMinutes != null
243
+ ? { turnTimeoutMinutes: source.tasks[0].turnTimeoutMinutes }
244
+ : {}),
245
+ defaultResponder: responder.kind === "member" ? { kind: "member", botId: botIds.get(responder.botId) }
233
246
  : responder.kind === "auto" ? { kind: "auto", ...(responder.fallbackBotId ? { fallbackBotId: botIds.get(responder.fallbackBotId) } : {}) }
234
- : responder });
247
+ : responder
248
+ });
235
249
  // Use the existing task APIs for rooms; direct-message rooms have one.
236
250
  const threads = source.tasks.map((task, i) => {
237
251
  if (i === 0) {
@@ -248,6 +262,7 @@ export function importTeamBackup(store, routines, input, selection, options = {}
248
262
  updatedAt: task.messages.reduce((max, message) => Math.max(max, message.at), task.createdAt),
249
263
  ...(task.pinned === true ? { pinned: true } : {}),
250
264
  ...(task.titleFromFirstMessage ? { titleFromFirstMessage: true } : {}),
265
+ ...(task.turnTimeoutMinutes != null ? { turnTimeoutMinutes: task.turnTimeoutMinutes } : {}),
251
266
  }));
252
267
  store.switchGroupTask(group.id, threads[source.tasks.findIndex((task) => task.key === source.activeTask)]);
253
268
  }
@@ -0,0 +1,25 @@
1
+ import { readyToRun, signedOut } from "./default-model-selection.js";
2
+ import { sameModelSelection } from "../shared/thread-model.js";
3
+ function canRunOn(engine, selection, context) {
4
+ return Boolean(engine?.enabled) && engine.snapshot.state === "available" && context.refusal?.(engine) === undefined &&
5
+ context.allows?.(selection) !== false;
6
+ }
7
+ /** Why this thread's own model gives way to its bot's, or null when the turn
8
+ * runs on it as picked:
9
+ * - "unavailable": its engine is gone, disabled, unavailable or not allowed
10
+ * (by the organisation, or a hosted workspace's assigned models);
11
+ * - "signed-out": its engine is signed out and the bot's model is on another
12
+ * engine that can run. On the same engine the turn runs as picked, so the
13
+ * engine's own sign-in prompt shows; a fallback never hides it. */
14
+ export function threadModelFallback(own, botModel, engine, context = {}) {
15
+ if (sameModelSelection(own, botModel))
16
+ return null;
17
+ const mine = engine(own.instanceId);
18
+ if (!canRunOn(mine, own, context))
19
+ return "unavailable";
20
+ if (!signedOut(mine))
21
+ return null;
22
+ // On the same engine the bot's model is signed out too, so it never stands in.
23
+ const theirs = engine(botModel.instanceId);
24
+ return canRunOn(theirs, botModel, context) && readyToRun(theirs, context) ? "signed-out" : null;
25
+ }
@@ -96,7 +96,10 @@ export function classifyContinuable(_stopReason, failure) {
96
96
  return null;
97
97
  if (NOT_RESUMABLE.test(failure))
98
98
  return null;
99
- if (/limit reached|stopped after \d+ steps|without a final answer/i.test(failure))
99
+ // Only OpenMausBot's own step cap. A provider's "Rate limit reached" or
100
+ // "usage limit reached" is not a budget a new thread can continue past:
101
+ // continuing would go straight back to the provider that said slow down.
102
+ if (/stopped after \d+ steps without a final answer|model-call limit reached/i.test(failure))
100
103
  return "cap";
101
104
  return null;
102
105
  }
@@ -131,7 +134,10 @@ export function makeCapContinuationSubscriber(deps) {
131
134
  if (CONTINUE_TITLE.test(deadTask.title ?? ""))
132
135
  return;
133
136
  const approvalMode = deadTask.approvalMode === "ask" || deadTask.approvalMode === "full" ? deadTask.approvalMode : undefined;
134
- const contTask = deps.store.createTask(bot.id, `Continue: ${(deadTask.title ?? "task").slice(0, 90)}`, true, deadTask.projectId, undefined, approvalMode, deadTask.modelSelection);
137
+ const contTask = deps.store.createTask(
138
+ // Not activated: the person's open thread stays theirs, and their next
139
+ // message goes where they were, not into the continuation.
140
+ bot.id, `Continue: ${(deadTask.title ?? "task").slice(0, 90)}`, false, deadTask.projectId, undefined, approvalMode, deadTask.modelSelection);
135
141
  if (!contTask)
136
142
  return;
137
143
  rec.count += 1;
@@ -84,11 +84,16 @@ export function createWebhookIngressHandler(manager, claimRequest) {
84
84
  let release;
85
85
  try {
86
86
  release = claimRequest?.();
87
- const pathSecret = match[2] ? decodeURIComponent(match[2]) : "";
88
- const secret = pathSecret || bearerSecret(req);
87
+ let secret = "";
88
+ try {
89
+ secret = (match[2] ? decodeURIComponent(match[2]) : "") || bearerSecret(req);
90
+ }
91
+ catch {
92
+ // A malformed escape is not a secret this server issued.
93
+ }
89
94
  // Reject bad capability URLs before buffering or parsing attacker input.
90
95
  if (!manager.authorize(match[1], secret)) {
91
- manager.recordRejected(match[1], 401, "Invalid webhook URL or secret", {
96
+ manager.recordUnauthorized(match[1], {
92
97
  contentType: header(req, "content-type"),
93
98
  eventName: eventName(req),
94
99
  deliveryId: deliveryId(req),
@@ -10,6 +10,7 @@ const MAX_ATTEMPTS = 2_000;
10
10
  const MAX_EVENT_CHARS = 48_000;
11
11
  const RATE_WINDOW_MS = 60_000;
12
12
  const RATE_LIMIT = 10;
13
+ const UNAUTHORIZED_REASON = "Invalid webhook URL or secret";
13
14
  /** Unfinished (queued, running or waiting) runs one webhook may hold before
14
15
  * new deliveries are refused with 429. A webhook can set its own limit; one
15
16
  * fanning out a project manager's events needs more than a CI hook does. */
@@ -223,6 +224,8 @@ export class WebhookManager {
223
224
  deliveries = [];
224
225
  attempts = [];
225
226
  rate = new Map();
227
+ /** Bad-secret requests folded into each webhook's rolling record. */
228
+ unauthorized = new Map();
226
229
  constructor(options) {
227
230
  this.options = options;
228
231
  this.file = options.file ?? join(DATA_DIR, "webhooks.json");
@@ -305,6 +308,7 @@ export class WebhookManager {
305
308
  this.deliveries = this.deliveries.filter((delivery) => !delivery.key.startsWith(`${trigger.endpointId}:`));
306
309
  this.attempts = this.attempts.filter((attempt) => attempt.webhookId !== trigger.id);
307
310
  this.rate.delete(trigger.endpointId);
311
+ this.unauthorized.delete(trigger.id);
308
312
  this.options.cancelQueued?.(trigger.id, "The webhook was deleted before this delivery started");
309
313
  this.save();
310
314
  this.options.emit?.({ kind: "webhook.deleted", webhookId: id });
@@ -374,6 +378,33 @@ export class WebhookManager {
374
378
  return null;
375
379
  return this.recordRejectedForTrigger(trigger, statusCode, reason, event);
376
380
  }
381
+ /** A request with a wrong secret can come from anyone who reaches the
382
+ * receiver, before any rate limit applies. Each webhook keeps one rolling
383
+ * record of them, updated in place and saved with the next real change, so
384
+ * a flood neither pushes real deliveries out of the shared history nor
385
+ * rewrites the file on every request. */
386
+ recordUnauthorized(endpointId, event = {}) {
387
+ const trigger = this.webhooks.find((candidate) => candidate.endpointId === endpointId);
388
+ if (!trigger)
389
+ return null;
390
+ const at = this.attempts.findLastIndex((attempt) => attempt.webhookId === trigger.id && attempt.outcome === "rejected" && attempt.statusCode === 401);
391
+ if (at === -1) {
392
+ this.unauthorized.set(trigger.id, 1);
393
+ return this.appendAttempt(trigger, event, { outcome: "rejected", statusCode: 401, reason: UNAUTHORIZED_REASON, deliveryId: event.deliveryId });
394
+ }
395
+ const count = (this.unauthorized.get(trigger.id) ?? 1) + 1;
396
+ this.unauthorized.set(trigger.id, count);
397
+ // Same id: clients replace the record they hold instead of adding one.
398
+ const attempt = this.buildAttempt(trigger, event, {
399
+ outcome: "rejected",
400
+ statusCode: 401,
401
+ reason: `${UNAUTHORIZED_REASON} (${count} requests)`,
402
+ deliveryId: event.deliveryId,
403
+ }, this.attempts[at].id);
404
+ this.attempts[at] = attempt;
405
+ this.options.emit?.({ kind: "webhook.attempt", attempt: { ...attempt } });
406
+ return attempt;
407
+ }
377
408
  dispatch(trigger, event) {
378
409
  if (!trigger.enabled)
379
410
  fail(409, "This webhook is paused");
@@ -524,8 +555,16 @@ export class WebhookManager {
524
555
  return attempt;
525
556
  }
526
557
  appendAttempt(trigger, event, details) {
558
+ const attempt = this.buildAttempt(trigger, event, details);
559
+ this.attempts.push(attempt);
560
+ if (this.attempts.length > MAX_ATTEMPTS)
561
+ this.attempts.splice(0, this.attempts.length - MAX_ATTEMPTS);
562
+ this.options.emit?.({ kind: "webhook.attempt", attempt: { ...attempt } });
563
+ return attempt;
564
+ }
565
+ buildAttempt(trigger, event, details, id = randomUUID()) {
527
566
  const attempt = {
528
- id: randomUUID(),
567
+ id,
529
568
  webhookId: trigger.id,
530
569
  receivedAt: this.now(),
531
570
  outcome: details.outcome,
@@ -541,10 +580,6 @@ export class WebhookManager {
541
580
  attempt.runId = details.runId;
542
581
  if (details.reason)
543
582
  attempt.reason = details.reason;
544
- this.attempts.push(attempt);
545
- if (this.attempts.length > MAX_ATTEMPTS)
546
- this.attempts.splice(0, this.attempts.length - MAX_ATTEMPTS);
547
- this.options.emit?.({ kind: "webhook.attempt", attempt: { ...attempt } });
548
583
  return attempt;
549
584
  }
550
585
  emit(trigger) {