openmausbot 0.1.96 → 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 (185) 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-Dkbd2hcj.js → abnfDiagram-O67JEVCF-BsjcFX0-.js} +1 -1
  20. package/dist/assets/activity-BxOkayvD.js +1 -0
  21. package/dist/assets/{arc-Duj5Coy2.js → arc-CJNA94c6.js} +1 -1
  22. package/dist/assets/architectureDiagram-NJMV4G6O-dz2ud7lP.js +36 -0
  23. package/dist/assets/{blockDiagram-BEXU5L5S-CWDlTpXe.js → blockDiagram-BEXU5L5S-ChBEALoR.js} +1 -1
  24. package/dist/assets/brain-CFZ7PEXc.js +1 -0
  25. package/dist/assets/{c4Diagram-YGBWAQC7-B0t15PDc.js → c4Diagram-YGBWAQC7-KZNRKDQe.js} +1 -1
  26. package/dist/assets/channel-WhFyujhZ.js +1 -0
  27. package/dist/assets/{chunk-2Q5K7J3B-BJSCL-HV.js → chunk-2Q5K7J3B-BLLwmCET.js} +1 -1
  28. package/dist/assets/{chunk-3FUC2YCW-D3_ILccd.js → chunk-3FUC2YCW-CQxyreBZ.js} +2 -2
  29. package/dist/assets/{chunk-5VM5RSS4-D3dmwT_S.js → chunk-5VM5RSS4-D6sFMW0u.js} +1 -1
  30. package/dist/assets/{chunk-GTNCS2PH-Br73HFPD.js → chunk-GTNCS2PH-BaZ1YgLF.js} +1 -1
  31. package/dist/assets/{chunk-GWA4HPMP-uSsjxubC.js → chunk-GWA4HPMP-CcalphmC.js} +1 -1
  32. package/dist/assets/{chunk-JWPE2WC7-C40VWFyr.js → chunk-JWPE2WC7-CSk8R3ve.js} +1 -1
  33. package/dist/assets/{chunk-NETBCI7D-D6WNNYwU.js → chunk-NETBCI7D-CTmLjA57.js} +1 -1
  34. package/dist/assets/{chunk-WEXAMYUT-DOEhq64P.js → chunk-WEXAMYUT-CM9Z77Ne.js} +1 -1
  35. package/dist/assets/{chunk-XXDRQBXY-BqJEi6Pi.js → chunk-XXDRQBXY-B1fxpMS2.js} +1 -1
  36. package/dist/assets/{classDiagram-v2-NBCMYWYE-ByU_GnxT.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-CZWO7aYj.js → cynefin-OW5HDTMX-fsZc6MGa.js} +1 -1
  40. package/dist/assets/{cynefinDiagram-VND7K2PF-C70-w0la.js → cynefinDiagram-VND7K2PF-CGmHsShB.js} +1 -1
  41. package/dist/assets/{dagre-6A5THRUB-CnhjhExB.js → dagre-6A5THRUB-Dah1h-CH.js} +1 -1
  42. package/dist/assets/{diagram-22UHCM2B-bnjWCNb4.js → diagram-22UHCM2B-BIu71OyV.js} +1 -1
  43. package/dist/assets/{diagram-3UASUU5V-A4uTWoFV.js → diagram-3UASUU5V-gxhUB_Mu.js} +1 -1
  44. package/dist/assets/{diagram-ATOU4E4O-Zi6m3NUl.js → diagram-ATOU4E4O-CBhusUBn.js} +1 -1
  45. package/dist/assets/{diagram-CDSNMT55-DtCcmd5k.js → diagram-CDSNMT55-BdPrc86x.js} +1 -1
  46. package/dist/assets/{diagram-MLGK6HIB-DofuETS3.js → diagram-MLGK6HIB-Clp110R_.js} +1 -1
  47. package/dist/assets/{diagram-MPIPVDR6-Dw2T9PuU.js → diagram-MPIPVDR6-DJKfbugw.js} +1 -1
  48. package/dist/assets/{ebnfDiagram-ZINNZB2B-DbrwPiem.js → ebnfDiagram-ZINNZB2B-DnsltoBu.js} +1 -1
  49. package/dist/assets/{elk-276RUBZZ-BvCt1dCb.js → elk-276RUBZZ-jWdq6gqH.js} +1 -1
  50. package/dist/assets/{erDiagram-OPXOYQCR-Y-QKqPnU.js → erDiagram-OPXOYQCR-DArPVf8u.js} +1 -1
  51. package/dist/assets/{flowDiagram-KWPJA3E3-CDkL1B5w.js → flowDiagram-KWPJA3E3-D2HZ-2f7.js} +1 -1
  52. package/dist/assets/ganttDiagram-FUAMR5RP--o0KIxEo.js +292 -0
  53. package/dist/assets/{gitGraphDiagram-X574FWY7-nL9iFTCv.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-DreDnjOu.js → index-CJTp9JEp.js} +1 -1
  58. package/dist/assets/{infoDiagram-VRGFBTTK-C7aHjzsf.js → infoDiagram-VRGFBTTK-g-6lNp7r.js} +1 -1
  59. package/dist/assets/{ishikawaDiagram-OU5B5YK6-KUZsNBTh.js → ishikawaDiagram-OU5B5YK6-B1d6qVTV.js} +1 -1
  60. package/dist/assets/{journeyDiagram-ZHPQQLJL-CGUuodFl.js → journeyDiagram-ZHPQQLJL-BTPuiU6T.js} +1 -1
  61. package/dist/assets/{kanban-definition-PNTS6WVX-Cy6r868Z.js → kanban-definition-PNTS6WVX-bDDuRJvP.js} +1 -1
  62. package/dist/assets/{linear-Bl669148.js → linear-BVVKbEMy.js} +1 -1
  63. package/dist/assets/{mermaid.core-86Xqz5Mr.js → mermaid.core-JJdoKyKB.js} +5 -5
  64. package/dist/assets/{mindmap-definition-NLK3R4M7-G4DzHLrd.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-K4eruzY0.js → pegDiagram-GJSIUBJH-DFKQWpR7.js} +1 -1
  68. package/dist/assets/{pieDiagram-5QR66LMP-DYdR9PPq.js → pieDiagram-5QR66LMP-Iv7OKVJi.js} +1 -1
  69. package/dist/assets/{quadrantDiagram-O4NWA36T-BCAh0nEy.js → quadrantDiagram-O4NWA36T-U2YnfwBk.js} +1 -1
  70. package/dist/assets/{railroadDiagram-XR7U4H2S-p0dGRc4g.js → railroadDiagram-XR7U4H2S-CuPy6RqM.js} +1 -1
  71. package/dist/assets/remote-desktop-panel-BNEj7tUx.js +1 -0
  72. package/dist/assets/{requirementDiagram-PLB6GJNP-tLeBG0aL.js → requirementDiagram-PLB6GJNP-bMI5AK7T.js} +1 -1
  73. package/dist/assets/{sankeyDiagram-IPEJSGJF-QRuN16rV.js → sankeyDiagram-IPEJSGJF-BKcFE2WQ.js} +1 -1
  74. package/dist/assets/{sequenceDiagram-PO4LG4MO-ueT1nVoa.js → sequenceDiagram-PO4LG4MO-Bn9q88kl.js} +1 -1
  75. package/dist/assets/{sizeCapture-INFHLROL-wRS2zWg2.js → sizeCapture-INFHLROL-Cvmdq9HD.js} +1 -1
  76. package/dist/assets/{stateDiagram-v2-GCMORJYK-CYzA_hE1.js → stateDiagram-v2-GCMORJYK-JJQYw3eQ.js} +1 -1
  77. package/dist/assets/{swimlanes-2SLR337P-kDnv6LMc.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-D1y2UbbH.js → timeline-definition-EJHVYXUP-DsaM4GHr.js} +1 -1
  81. package/dist/assets/{usecaseDiagram-POWQR4AR-IqB4v9HX.js → usecaseDiagram-POWQR4AR-3FOwLq7B.js} +1 -1
  82. package/dist/assets/user-DcZBnAuv.js +1 -0
  83. package/dist/assets/{vennDiagram-UO4OBE2U-BEi7KYVF.js → vennDiagram-UO4OBE2U-DyNjJsKt.js} +1 -1
  84. package/dist/assets/{wardleyDiagram-VNRHLVJA-Dkybo-Yf.js → wardleyDiagram-VNRHLVJA-Y4ZMcnBC.js} +1 -1
  85. package/dist/assets/{xychartDiagram-PMCCYNJV-BAPFJLpg.js → xychartDiagram-PMCCYNJV-BXZpg955.js} +1 -1
  86. package/dist/index.html +2 -2
  87. package/dist-server/cloud-home-start.js +20 -9
  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 +26 -9
  91. package/dist-server/index.js +3734 -2360
  92. package/dist-server/openmausbot.js +1664 -1553
  93. package/dist-server/pair-cli.js +1664 -1553
  94. package/dist-server/permission-proxy.js +2 -4
  95. package/dist-server/server/atomic.js +70 -2
  96. package/dist-server/server/auto-approve.js +1 -1
  97. package/dist-server/server/auto-vm-claims.js +13 -1
  98. package/dist-server/server/automatic-recovery.js +1 -2
  99. package/dist-server/server/boat.js +68 -84
  100. package/dist-server/server/bot-overview.js +1 -1
  101. package/dist-server/server/browser-engine.js +38 -1
  102. package/dist-server/server/browser-live.js +112 -25
  103. package/dist-server/server/browser-runtime.js +31 -1
  104. package/dist-server/server/checkpoints.js +275 -44
  105. package/dist-server/server/cli-setup.js +3 -3
  106. package/dist-server/server/cloud-computer-tools.js +11 -4
  107. package/dist-server/server/cloud-home-start.js +4 -4
  108. package/dist-server/server/cloud-home.js +3 -9
  109. package/dist-server/server/cloud-owner.js +1 -1
  110. package/dist-server/server/computer-engine-removal.js +70 -0
  111. package/dist-server/server/computer-selection.js +18 -0
  112. package/dist-server/server/config.js +29 -12
  113. package/dist-server/server/connector-card-text.js +15 -0
  114. package/dist-server/server/connector-scope-hint.js +83 -0
  115. package/dist-server/server/default-model-selection.js +23 -14
  116. package/dist-server/server/delegations.js +11 -5
  117. package/dist-server/server/delta-context.js +6 -1
  118. package/dist-server/server/drivers/acp/core.js +11 -1
  119. package/dist-server/server/drivers/acp/grok.js +47 -5
  120. package/dist-server/server/drivers/acp/opencode-go.js +16 -21
  121. package/dist-server/server/drivers/agents-call.js +1 -1
  122. package/dist-server/server/drivers/agents-catalog.js +27 -7
  123. package/dist-server/server/drivers/agents-proxy.js +1 -0
  124. package/dist-server/server/drivers/builtIn.js +0 -2
  125. package/dist-server/server/drivers/chat-mcp-tools.js +5 -3
  126. package/dist-server/server/drivers/claude-login-auth.js +1 -1
  127. package/dist-server/server/drivers/claude.js +84 -41
  128. package/dist-server/server/drivers/codex-device-auth.js +27 -319
  129. package/dist-server/server/drivers/codex.js +2 -2
  130. package/dist-server/server/drivers/device-auth.js +342 -0
  131. package/dist-server/server/drivers/openai-chat.js +110 -10
  132. package/dist-server/server/drivers/pi.js +184 -49
  133. package/dist-server/server/fleet-agent.js +18 -5
  134. package/dist-server/server/harness/http.js +5 -2
  135. package/dist-server/server/harness/registry.js +19 -0
  136. package/dist-server/server/harness-capabilities.js +7 -4
  137. package/dist-server/server/hosted-models.js +3 -1
  138. package/dist-server/server/index.js +823 -333
  139. package/dist-server/server/key-rejections.js +57 -0
  140. package/dist-server/server/live-call.js +11 -6
  141. package/dist-server/server/managed-policy.js +1 -1
  142. package/dist-server/server/message-file.js +7 -4
  143. package/dist-server/server/model-catalog/presets.js +8 -0
  144. package/dist-server/server/peer-roster.js +10 -2
  145. package/dist-server/server/permission-proxy.js +4 -0
  146. package/dist-server/server/provider-key-check.js +5 -1
  147. package/dist-server/server/request-auth.js +1 -1
  148. package/dist-server/server/room-turn-end.js +15 -0
  149. package/dist-server/server/room-turn-timeout.js +28 -0
  150. package/dist-server/server/routes/live.js +3 -3
  151. package/dist-server/server/routes/thread-models.js +36 -0
  152. package/dist-server/server/routine-requests.js +1 -1
  153. package/dist-server/server/routines.js +3 -3
  154. package/dist-server/server/screen-frame-gate.js +1 -3
  155. package/dist-server/server/skills.js +3 -2
  156. package/dist-server/server/store.js +273 -61
  157. package/dist-server/server/surface.js +29 -49
  158. package/dist-server/server/system-prompt.js +10 -10
  159. package/dist-server/server/team-backup.js +19 -4
  160. package/dist-server/server/thread-model.js +25 -0
  161. package/dist-server/server/turn-continuation.js +168 -0
  162. package/dist-server/server/webhook-ingress.js +8 -3
  163. package/dist-server/server/webhooks.js +40 -5
  164. package/dist-server/server/workspace.js +1 -1
  165. package/dist-server/shared/ask-question.js +0 -70
  166. package/dist-server/shared/cloud-computer.js +4 -6
  167. package/dist-server/shared/credential-request.js +1 -1
  168. package/dist-server/shared/device-sign-in.js +57 -0
  169. package/dist-server/shared/failed-turn.js +1 -4
  170. package/dist-server/shared/place-view.js +409 -0
  171. package/dist-server/shared/team-backup.js +2 -0
  172. package/dist-server/shared/thread-model.js +13 -0
  173. package/dist-server/shared/tool-scope-support.js +1 -2
  174. package/dist-server/vps-container-mcp.js +8 -0
  175. package/dist-server/workspace-backup.worker.js +41 -22
  176. package/package.json +1 -1
  177. package/dist/assets/DesktopViewer-DsnA23KM.js +0 -5
  178. package/dist/assets/architectureDiagram-NJMV4G6O-uYUsDawP.js +0 -36
  179. package/dist/assets/channel-BZxwC-Gw.js +0 -1
  180. package/dist/assets/cose-bilkent-JH36ORCC-D-Uglxqq.js +0 -1
  181. package/dist/assets/ganttDiagram-FUAMR5RP-CKV5D2F6.js +0 -292
  182. package/dist/assets/index-Du7o6Dsf.js +0 -610
  183. package/dist/assets/index-KxG4bWSr.css +0 -1
  184. package/dist/assets/swimlanesDiagram-TC7HE7FX-C7voMDle.js +0 -8
  185. package/dist-server/server/drivers/boatagent.js +0 -443
@@ -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";
@@ -18,9 +18,11 @@ import { newId } from "./contracts.js";
18
18
  import { pickBotName } from "./names.js";
19
19
  import { redactSecretsInText } from "./redact.js";
20
20
  import { AVATAR_FOCUS_CENTER, AVATAR_ZOOM_MIN, botAvatarProfile, clampAvatarFocus, clampAvatarZoom } from "../shared/bot-avatar.js";
21
- import { approvalModeFor, isApprovalMode } from "../shared/approval-mode.js";
21
+ import { approvalModeFor, isApprovalMode, modelSwitchNeedsAsk } from "../shared/approval-mode.js";
22
+ import { REMOVED_COMPUTER_DRIVER } from "./computer-engine-removal.js";
22
23
  import { isMentionBoundary, isMentionNameContinuation } from "../shared/mention-boundary.js";
23
24
  import { CONNECTOR_SLUG_PATTERN, CONNECTOR_TOOL_NAME_PATTERN } from "../shared/wire.js";
25
+ import { sameModelSelection } from "../shared/thread-model.js";
24
26
  export const groupWireProjectionIsExact = true;
25
27
  // Unicode's complete emoji sequences include flags, skin tones and ZWJ
26
28
  // combinations. Also allow unqualified single symbols (e.g. ♥), but not
@@ -31,13 +33,19 @@ export function isProjectEmoji(value) {
31
33
  }
32
34
  export const taskWireProjectionIsExact = true;
33
35
  /** The typed wire projection for one task. Pairs with the assertion above:
34
- * returning WireTask means an undeclared server field cannot ride silently. */
35
- export function toWireTask(task) {
36
- 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 };
37
42
  // Who pinned stays private. A client learns only whether the pin is the
38
43
  // machine's own record, so it never presents one as the person's choice.
39
44
  return surfaceSource === "auto" && wire.surface !== undefined ? { ...wire, surfaceAuto: true } : wire;
40
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: [] };
41
49
  const TASK_PATCH_FIELDS = [
42
50
  "title", "projectId", "modelSelection", "approvalMode", "autoApprove", "alwaysAllow",
43
51
  "unread", "rewound", "archivedAt", "pinned", "pinnedMessageId", "resumeCursors", "lastInstanceId", "cwd",
@@ -325,6 +333,9 @@ export function parseConnectorTools(value) {
325
333
  }
326
334
  const BOTS_FILE = join(DATA_DIR, "bots.json");
327
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");
328
339
  /** The registries carry souls, project paths and per-bot settings, so they are
329
340
  * owner-only like the other data-dir stores. A file written by an older
330
341
  * release (or loosened by hand) is tightened on load. Best effort: failing to
@@ -455,6 +466,7 @@ export class Store {
455
466
  threads = new Map();
456
467
  defaultSelection;
457
468
  completeNewBotSelection;
469
+ driverKindOf;
458
470
  listeners = new Set();
459
471
  /** A broken team registry must not prevent loading independent chat data. */
460
472
  registeringInitialSections = true;
@@ -464,9 +476,14 @@ export class Store {
464
476
  constructor(defaultSelection,
465
477
  /** Workspace-wide new-bot defaults (config newBots), applied to every
466
478
  * new bot's selection whichever path created it. */
467
- 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) {
468
484
  this.defaultSelection = defaultSelection;
469
485
  this.completeNewBotSelection = completeNewBotSelection;
486
+ this.driverKindOf = driverKindOf;
470
487
  mkdirSync(DATA_DIR, { recursive: true });
471
488
  for (const file of [BOTS_FILE, GROUPS_FILE])
472
489
  tightenRegistryFile(file);
@@ -695,11 +712,17 @@ export class Store {
695
712
  }
696
713
  if (groupsMigrated)
697
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));
698
721
  // bots saved before tasks existed have one endless thread; adopt it as
699
722
  // their first task so nothing is lost and nothing special-cases it
700
723
  for (const b of this.bots) {
701
- // Folders are organizational only. Preserve existing thread model
702
- // 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.
703
726
  if (b.projects?.some((project) => "modelSelection" in project)) {
704
727
  b.projects = b.projects.map(({ id, name, emoji }) => ({ id, name, ...(isProjectEmoji(emoji) ? { emoji } : {}) }));
705
728
  botsMigrated = true;
@@ -729,8 +752,8 @@ export class Store {
729
752
  botsMigrated = true;
730
753
  }
731
754
  for (const task of b.tasks) {
732
- if (task.modelSelection === undefined) {
733
- task.modelSelection = structuredClone(b.modelSelection);
755
+ if (clearModelCopies && task.modelSelection !== undefined && sameModelSelection(task.modelSelection, b.modelSelection)) {
756
+ delete task.modelSelection;
734
757
  botsMigrated = true;
735
758
  }
736
759
  if (!task.resumeCursors) {
@@ -773,6 +796,8 @@ export class Store {
773
796
  }
774
797
  if (botsMigrated)
775
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 });
776
801
  // Search reads SQLite directly, so migrate every known legacy transcript
777
802
  // at startup rather than waiting until the user happens to open it. Only
778
803
  // pending JSON files are touched; already-migrated threads stay lazy.
@@ -895,10 +920,13 @@ export class Store {
895
920
  if (botsDirty)
896
921
  this.saveBots();
897
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.
898
926
  saveBots(bots = this.bots, registerSections = true) {
899
927
  if (registerSections)
900
928
  this.rememberSections([...this.bots, ...bots].map((bot) => bot.section));
901
- 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 }) => ({
902
930
  ...bot,
903
931
  tasks: bot.tasks?.map(({ busy: _taskBusy, activity: _taskActivity, turnStartedAt: _taskTurnStarted, ...task }) => persistedPin(task)),
904
932
  })), null, 2), { mode: 0o600 });
@@ -906,7 +934,7 @@ export class Store {
906
934
  saveGroups(groups = this.groups, registerSections = true) {
907
935
  if (registerSections)
908
936
  this.rememberSections(groups.map((group) => group.section));
909
- 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 }) => ({
910
938
  ...g,
911
939
  ...(g.tasks ? { tasks: g.tasks.map((task) => persistedPin(task)) } : {}),
912
940
  })), null, 2), { mode: 0o600 });
@@ -1119,6 +1147,9 @@ export class Store {
1119
1147
  else if (!patch.busyBotId)
1120
1148
  delete group.turnStartedAt;
1121
1149
  }
1150
+ if (Object.prototype.hasOwnProperty.call(patch, "turnTimeoutMinutes") && patch.turnTimeoutMinutes == null) {
1151
+ delete group.turnTimeoutMinutes;
1152
+ }
1122
1153
  if (!group.dm && Object.prototype.hasOwnProperty.call(patch, "pinnedMessageId")) {
1123
1154
  const active = this.activeGroupTask(group.id);
1124
1155
  if (active)
@@ -1259,6 +1290,20 @@ export class Store {
1259
1290
  this.emit({ type: "group", groupId });
1260
1291
  return task;
1261
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
+ }
1262
1307
  renameGroupTask(groupId, threadId, title) {
1263
1308
  const task = this.groupTaskByThread(groupId, threadId);
1264
1309
  if (!task)
@@ -1625,7 +1670,6 @@ export class Store {
1625
1670
  createdAt: bot.createdAt,
1626
1671
  updatedAt: bot.createdAt,
1627
1672
  resumeCursors: {},
1628
- modelSelection: structuredClone(bot.modelSelection),
1629
1673
  unread: false,
1630
1674
  activity: "idle",
1631
1675
  busy: false,
@@ -1657,7 +1701,8 @@ export class Store {
1657
1701
  return bot;
1658
1702
  }
1659
1703
  /** All setup fields and the Chief's receipt commit before publishing any
1660
- * 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. */
1661
1706
  applyTeamSetup(request) {
1662
1707
  const chief = this.bot(request.botId);
1663
1708
  if (!chief)
@@ -1686,7 +1731,7 @@ export class Store {
1686
1731
  // a restricted Chief never creates a bot everyone sees.
1687
1732
  ...(chief.visibility && chief.visibility !== "everyone" ? { visibility: structuredClone(chief.visibility) } : {}),
1688
1733
  tasks: [{ threadId: operation.threadId, title: UNTITLED_THREAD, createdAt, updatedAt: createdAt, resumeCursors: {},
1689
- modelSelection: structuredClone(modelSelection), approvalMode: "ask", autoApprove: false,
1734
+ approvalMode: "ask", autoApprove: false,
1690
1735
  unread: false, activity: "idle", busy: false }],
1691
1736
  };
1692
1737
  // "" is the private-workspace spelling on proposal; the record
@@ -1701,13 +1746,7 @@ export class Store {
1701
1746
  const previous = nextBots[at];
1702
1747
  next = { ...previous, ...operation.fields };
1703
1748
  if (operation.fields.modelSelection)
1704
- next.tasks = previous.tasks?.map((task) => ({
1705
- ...task,
1706
- modelSelection: structuredClone(task.modelSelection ?? previous.modelSelection),
1707
- approvalMode: approvalModeFor(this.projectBotForTask(previous.id, task.threadId)),
1708
- autoApprove: task.autoApprove ?? previous.autoApprove,
1709
- alwaysAllow: structuredClone(task.alwaysAllow ?? previous.alwaysAllow ?? []),
1710
- }));
1749
+ next.tasks = this.withDefaultModel(previous, operation.fields.modelSelection);
1711
1750
  nextBots[at] = next;
1712
1751
  }
1713
1752
  if (operation.fields.chiefOfStaff === false)
@@ -1745,23 +1784,38 @@ export class Store {
1745
1784
  this.emit({ type: "bot", botId: chief.id });
1746
1785
  return result;
1747
1786
  }
1748
- /** One reviewed default-model change (propose_model): task stamping is
1749
- * identical to applyTeamSetup's update branch — saved per-thread
1750
- * selections are never rewritten, and a thread with no selection of its
1751
- * own is pinned to the previous default so it does not silently follow
1752
- * 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. */
1753
1814
  applyModelDefault(id, modelSelection) {
1754
1815
  const previous = this.bot(id);
1755
1816
  if (!previous)
1756
1817
  return null;
1757
- const next = { ...previous, modelSelection: structuredClone(modelSelection) };
1758
- next.tasks = previous.tasks?.map((task) => ({
1759
- ...task,
1760
- modelSelection: structuredClone(task.modelSelection ?? previous.modelSelection),
1761
- approvalMode: approvalModeFor(this.projectBotForTask(previous.id, task.threadId)),
1762
- autoApprove: task.autoApprove ?? previous.autoApprove,
1763
- alwaysAllow: structuredClone(task.alwaysAllow ?? previous.alwaysAllow ?? []),
1764
- }));
1818
+ const next = { ...previous, modelSelection: structuredClone(modelSelection), tasks: this.withDefaultModel(previous, modelSelection) };
1765
1819
  this.saveBots(this.bots.map((candidate) => candidate.id === id ? next : candidate));
1766
1820
  this.bots = this.bots.map((candidate) => candidate.id === id ? next : candidate);
1767
1821
  this.emit({ type: "bot", botId: id });
@@ -1859,9 +1913,16 @@ export class Store {
1859
1913
  patch = { ...patch };
1860
1914
  delete patch.toolScope;
1861
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)) : [];
1862
1921
  // Runtime revocations must become effective in memory even when disk is
1863
1922
  // unavailable. Profile edits use the separate atomic path below.
1864
1923
  Object.assign(bot, patch);
1924
+ for (const task of toAsk)
1925
+ Object.assign(task, structuredClone(ASK_NOW));
1865
1926
  const task = this.activeTask(id);
1866
1927
  if (task) {
1867
1928
  for (const key of ["resumeCursors", "rewound", "pinnedMessageId", "unread"]) {
@@ -2144,6 +2205,129 @@ export class Store {
2144
2205
  this.emit({ type: "bot", botId: bot.id });
2145
2206
  return changed.length;
2146
2207
  }
2208
+ /** The removed Computer engine's saved choices move to `replacement` in one
2209
+ * save: each bot's model, each conversation's own model, and its backups.
2210
+ * Native resume cursors and handed-message records of the removed engine go
2211
+ * too; the conversations stay, and the new engine reads them from the
2212
+ * transcript. Each conversation keeps working where it did: on Auto the
2213
+ * removed engine always ran on the bot's own cloud computer, so where
2214
+ * `keepCloud` says that computer is still there and the new engine can use
2215
+ * it, a moved bot's Works on becomes Cloud, and a moved conversation of an
2216
+ * Auto bot is pinned there the way an Auto turn records where it landed.
2217
+ * Where the new engine can't use a computer, an Auto-recorded cloud pin on
2218
+ * a moved conversation gives way, as on a Works on change
2219
+ * (clearAutoSurfacePins); a place the person chose stays chosen.
2220
+ * A level the new engine would have to confirm goes back to Ask, as on
2221
+ * every engine switch (modelSwitchNeedsAsk); a conversation that followed
2222
+ * the bot's level keeps the level it had. Every other setting is kept.
2223
+ * Nothing changes in memory until the save succeeds, so a failed save can
2224
+ * be tried again. Returns one entry per moved conversation, plus one for
2225
+ * the bot's open conversation when only the bot's own engine moved.
2226
+ * Idempotent: nothing left names the engine. */
2227
+ retireInstances(ids, replacement, options) {
2228
+ const retired = (selection) => Boolean(selection && ids.has(selection.instanceId));
2229
+ const without = (record) => Object.fromEntries(Object.entries(record).filter(([key]) => !ids.has(key)));
2230
+ const touches = (record) => Boolean(record && Object.keys(record).some(key => ids.has(key)));
2231
+ const needsAsk = (target) => modelSwitchNeedsAsk(approvalModeFor(target), REMOVED_COMPUTER_DRIVER, options.driverKind);
2232
+ const askNow = { approvalMode: "ask", autoApprove: false, alwaysAllow: [] };
2233
+ const changes = [];
2234
+ const moves = [];
2235
+ for (const bot of this.bots) {
2236
+ const patch = {};
2237
+ const tasks = (bot.tasks ?? []).map(() => ({}));
2238
+ const botMoved = retired(bot.modelSelection);
2239
+ const keepsCloud = options.canWorkOnCloud && options.keepCloud(bot);
2240
+ let botAsk = false;
2241
+ if (botMoved) {
2242
+ if (bot.computer === undefined && keepsCloud)
2243
+ patch.computer = "cloud";
2244
+ if (needsAsk(bot)) {
2245
+ Object.assign(patch, structuredClone(askNow));
2246
+ botAsk = true;
2247
+ }
2248
+ patch.modelSelection = structuredClone(replacement);
2249
+ }
2250
+ if (bot.fallback?.some(retired)) {
2251
+ const kept = bot.fallback.filter(candidate => !retired(candidate));
2252
+ patch.fallback = kept.length ? kept : undefined;
2253
+ }
2254
+ if (touches(bot.resumeCursors))
2255
+ patch.resumeCursors = without(bot.resumeCursors);
2256
+ const computer = patch.computer ?? bot.computer;
2257
+ /** Where a conversation worked before the move (its own place, else
2258
+ * the bot's Works on, and on Auto the bot's own cloud computer where
2259
+ * keepCloud says so), and what the move did to that. */
2260
+ const cloudAfterMove = (pin, pinSource) => {
2261
+ const place = pin ?? bot.computer;
2262
+ const wasCloud = place === "cloud" || (place === undefined && options.keepCloud(bot));
2263
+ if (!wasCloud || options.canWorkOnCloud)
2264
+ return { cloud: wasCloud, noComputer: false };
2265
+ // An Auto-recorded pin gives way (below): only the person's place
2266
+ // or the bot's Works on can still send it to the cloud computer.
2267
+ const personPin = pin !== undefined && pinSource !== "auto";
2268
+ const still = personPin ? pin : bot.computer;
2269
+ return { cloud: false, noComputer: still !== "cloud" ? "auto" : personPin ? "pin" : "works-on" };
2270
+ };
2271
+ (bot.tasks ?? []).forEach((task, index) => {
2272
+ const taskPatch = tasks[index];
2273
+ // Read before anything changes: a conversation that follows the bot's
2274
+ // level has the bot's level here.
2275
+ const before = this.projectBotForTask(bot.id, task.threadId);
2276
+ // The bot's own level goes back to Ask, so a conversation that
2277
+ // followed it keeps the level it had (as switchTaskModel does).
2278
+ if (botAsk) {
2279
+ Object.assign(taskPatch, {
2280
+ approvalMode: before.approvalMode, autoApprove: before.autoApprove, alwaysAllow: structuredClone(before.alwaysAllow ?? []),
2281
+ });
2282
+ }
2283
+ // The engine this conversation ran on: its own, else the bot's.
2284
+ const moved = task.modelSelection ? retired(task.modelSelection) : botMoved;
2285
+ if (moved) {
2286
+ if (!botMoved && computer === undefined && task.surface === undefined && keepsCloud) {
2287
+ taskPatch.surface = "cloud";
2288
+ taskPatch.surfaceSource = "auto";
2289
+ }
2290
+ // The machine's memory of where Auto landed: with no computer on
2291
+ // the new engine, the next turn would be refused there once.
2292
+ if (!options.canWorkOnCloud && task.surface === "cloud" && task.surfaceSource === "auto") {
2293
+ taskPatch.surface = undefined;
2294
+ taskPatch.surfaceSource = undefined;
2295
+ }
2296
+ const ask = needsAsk(before);
2297
+ if (ask)
2298
+ Object.assign(taskPatch, structuredClone(askNow));
2299
+ if (task.modelSelection)
2300
+ taskPatch.modelSelection = structuredClone(replacement);
2301
+ const scope = botMoved && task.threadId === bot.threadId ? "bot" : "conversation";
2302
+ moves.push({ botId: bot.id, threadId: task.threadId, scope, ...cloudAfterMove(task.surface, task.surfaceSource),
2303
+ askNow: ask || (scope === "bot" && botAsk) });
2304
+ }
2305
+ else if (botMoved && task.threadId === bot.threadId) {
2306
+ // The bot's own engine and level moved, but its open conversation
2307
+ // has an engine of its own: it is still told, about the bot.
2308
+ moves.push({ botId: bot.id, threadId: task.threadId, scope: "bot-only", ...cloudAfterMove(undefined, undefined), askNow: botAsk });
2309
+ }
2310
+ if (touches(task.resumeCursors))
2311
+ taskPatch.resumeCursors = without(task.resumeCursors);
2312
+ if (task.handedMessages && touches(task.handedMessages))
2313
+ taskPatch.handedMessages = without(task.handedMessages);
2314
+ });
2315
+ if (Object.keys(patch).length || tasks.some(taskPatch => Object.keys(taskPatch).length))
2316
+ changes.push({ bot, patch, tasks });
2317
+ }
2318
+ if (!changes.length)
2319
+ return moves;
2320
+ const next = new Map(changes.map(({ bot, patch, tasks }) => [bot, {
2321
+ ...bot, ...patch, tasks: bot.tasks?.map((task, index) => ({ ...task, ...tasks[index] })),
2322
+ }]));
2323
+ this.saveBots(this.bots.map(bot => next.get(bot) ?? bot));
2324
+ for (const { bot, patch, tasks } of changes) {
2325
+ bot.tasks?.forEach((task, index) => Object.assign(task, tasks[index]));
2326
+ Object.assign(bot, patch);
2327
+ this.emit({ type: "bot", botId: bot.id });
2328
+ }
2329
+ return moves;
2330
+ }
2147
2331
  setResumeCursor(botId, instanceId, cursor, threadId) {
2148
2332
  const bot = this.bot(botId);
2149
2333
  if (!bot)
@@ -2462,37 +2646,61 @@ export class Store {
2462
2646
  this.emit({ type: "bot", botId });
2463
2647
  return cleared;
2464
2648
  }
2465
- /** Model/provider changes are one configuration transaction: never publish
2466
- * a new provider before its confirmed approval downgrade, or change the
2467
- * 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. */
2468
2655
  switchTaskModel(botId, threadId, selection, updateBotDefault, resetApprovalToAsk, taskPatch = {}) {
2469
2656
  const bot = this.bot(botId);
2470
2657
  const task = this.taskByThread(botId, threadId);
2471
2658
  if (!bot || !task)
2472
2659
  return null;
2473
- const patch = { modelSelection: structuredClone(selection),
2474
- ...(resetApprovalToAsk ? { approvalMode: "ask", autoApprove: false, alwaysAllow: [] } : {}) };
2475
- 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,
2476
2663
  ...(typeof taskPatch.title === "string" ? { title: taskPatch.title.trim().slice(0, 80) || UNTITLED_THREAD } : {}) });
2477
- // Older threads may still inherit settings. Freeze their effective
2478
- // values before updating the default so "other threads unchanged" also
2479
- // holds for workspaces created before per-thread approval settings.
2480
- const nextTasks = bot.tasks.map((candidate) => candidate === task ? nextTask : !updateBotDefault ? candidate : {
2481
- ...candidate,
2482
- modelSelection: structuredClone(candidate.modelSelection ?? bot.modelSelection),
2483
- approvalMode: approvalModeFor(this.projectBotForTask(botId, candidate.threadId)),
2484
- autoApprove: candidate.autoApprove ?? bot.autoApprove,
2485
- alwaysAllow: structuredClone(candidate.alwaysAllow ?? bot.alwaysAllow ?? []),
2486
- });
2487
- const next = { ...bot, ...(updateBotDefault ? patch : {}),
2488
- tasks: nextTasks };
2489
- 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));
2490
2670
  bot.tasks.forEach((candidate, index) => Object.assign(candidate, nextTasks[index]));
2491
- if (updateBotDefault)
2492
- Object.assign(bot, patch);
2671
+ Object.assign(bot, botPatch);
2493
2672
  this.emit({ type: "bot", botId });
2494
2673
  return task;
2495
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
+ }
2496
2704
  /** One durable write: never leave only part of a bot's threads updated. */
2497
2705
  setAllThreadApprovalMode(botId, mode) {
2498
2706
  const bot = this.bot(botId);
@@ -2532,9 +2740,9 @@ export class Store {
2532
2740
  bot.pinnedMessageId = task.pinnedMessageId;
2533
2741
  }
2534
2742
  /** A fresh context on the same bot: new thread, new session, same
2535
- * persona/tools/computer. Becomes the active task. It runs on the bot's
2536
- * default model unless the caller hands it one (a thread opened from
2537
- * 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. */
2538
2746
  createTask(botId, title, activate = true, projectId, openedBy, approvalMode, modelSelection) {
2539
2747
  const bot = this.bot(botId);
2540
2748
  if (!bot)
@@ -2550,7 +2758,7 @@ export class Store {
2550
2758
  ...(projectId ? { projectId } : {}),
2551
2759
  ...(openedBy ? { openedBy: structuredClone(openedBy) } : {}),
2552
2760
  resumeCursors: {},
2553
- modelSelection: structuredClone(modelSelection ?? bot.modelSelection),
2761
+ ...(modelSelection && !sameModelSelection(modelSelection, bot.modelSelection) ? { modelSelection: structuredClone(modelSelection) } : {}),
2554
2762
  approvalMode: approvalMode ?? approvalModeFor(bot),
2555
2763
  autoApprove: approvalMode ? false : Boolean(bot.autoApprove),
2556
2764
  alwaysAllow: approvalMode ? [] : [...(bot.alwaysAllow ?? [])],
@@ -2656,10 +2864,14 @@ export class Store {
2656
2864
  this.emit({ type: "bot", botId });
2657
2865
  return bot;
2658
2866
  }
2867
+ /** A first run: no bot yet, so seedIfEmpty makes one. */
2868
+ needsSeed() {
2869
+ return this.bots.length === 0;
2870
+ }
2659
2871
  /** First-run seed: one bot so the app never opens empty — it gets a
2660
2872
  * random friendly name like every other bot. */
2661
2873
  seedIfEmpty() {
2662
- if (this.bots.length)
2874
+ if (!this.needsSeed())
2663
2875
  return;
2664
2876
  this.createBot();
2665
2877
  }
@@ -6,6 +6,8 @@
6
6
  // decides or describes a surface lives here, so the picker, the dispatch,
7
7
  // the system prompt and the notification cannot drift apart.
8
8
  import { canWorkOnCloud } from "../shared/cloud-computer.js";
9
+ import { canUseMcpServer } from "../shared/tool-scope.js";
10
+ import { placeRowText } from "../shared/place-view.js";
9
11
  const SURFACES = new Set(["cloud", "vm", "local", "browser"]);
10
12
  /** Parse a surface arriving over the wire; anything else is "not said". */
11
13
  export function parseSurface(value) {
@@ -68,65 +70,43 @@ export function resolveSurface(input) {
68
70
  }
69
71
  return { computer: undefined, browser: browserOn, pinned: null, note: "" };
70
72
  }
71
- /** The next action per source: the full sentence after a failed attach, and
72
- * the clause after "Choose another model, or" when the engine is the cause. */
73
- const PLACE_ACTION = {
74
- pin: {
75
- sentence: "Clear this conversation's place in the composer to continue.",
76
- clause: "clear this conversation's place in the composer",
77
- },
78
- // The dispatch clears an Auto-recorded pin that failed, so the next
79
- // message runs on Auto: that pin was the machine's memory, not a choice.
80
- "auto-pin": {
81
- sentence: "This conversation is back on Auto; send your message again.",
82
- clause: "send your message again; this conversation is back on Auto",
83
- },
84
- "works-on": {
85
- sentence: "Set Works on to Auto in this bot's settings to continue.",
86
- clause: "set Works on to Auto",
87
- },
88
- routine: {
89
- sentence: "Change where this routine runs.",
90
- clause: "change where this routine runs",
91
- },
92
- };
93
- /** Why this engine can't work on the cloud computer, in the words of the
94
- * place's source, or null when it can (shared/cloud-computer.ts holds the
95
- * rule). Checked before anything is provisioned, so a turn that cannot run
96
- * never creates or wakes a machine. */
97
- export function cloudPlaceDriverError(engine, backend, source = "works-on") {
98
- if (canWorkOnCloud(engine, backend))
73
+ /** Why this engine can't work on the cloud computer, as a failed place, or
74
+ * null when it can (shared/cloud-computer.ts holds the rule). Checked before
75
+ * anything is provisioned, so a turn that cannot run never creates or wakes
76
+ * a machine. */
77
+ export function cloudPlaceRefusal(engine, source, bot) {
78
+ if (canWorkOnCloud(engine))
99
79
  return null;
100
- const next = `Choose another model, or ${PLACE_ACTION[source].clause}.`;
101
- return engine.driverKind === "boxAgent"
102
- ? `The Computer engine runs on Boat and can't use a self-hosted VPS. ${next}`
103
- : `This model can't use a computer. ${next}`;
80
+ return placeUnavailable("cloud", { state: "cc-cannot", params: { bot, model: engine.name }, source });
104
81
  }
105
- /** One line, cause then next action. A bot thread's transcript row keeps
106
- * 160 characters, so the cause is shortened there, never the action; the
107
- * cause keeps its own words otherwise. */
108
- export function placeFailureMessage(cause, source, limit = 160) {
109
- const action = PLACE_ACTION[source].sentence;
110
- const body = cause.trim().replace(/[\s.]+$/, "");
111
- const room = limit - action.length - 2;
112
- if (body.length <= room)
113
- return `${body}. ${action}`;
114
- return `${body.slice(0, Math.max(0, room - 1)).trimEnd()}… ${action}`;
82
+ /** Why this bot's Tool selection keeps it off a computer, as a failed place,
83
+ * or null: one line and the one setting that changes it, the same whatever
84
+ * chose the place (a turn's refusal and select_computer's reason alike).
85
+ * Engines reach every desktop through the "computer" MCP server, so a
86
+ * selection without it has nothing to work with there. Checked with the
87
+ * engine rule, before anything is created or woken. */
88
+ export function computerToolsRefusal(toolScope, source, bot) {
89
+ if (canUseMcpServer(toolScope, "computer"))
90
+ return null;
91
+ return placeUnavailable("cloud", { state: "cc-tools-off", params: { bot }, source });
115
92
  }
116
- /** A place the turn was told to use could not be used. The message already
117
- * ends with the one next action; the place lets the dispatch clear a failed
93
+ /** A place the turn was told to use could not be used. Its message is the
94
+ * row's English words: one line and the one next action as a sentence
95
+ * (shared/place-view.ts), uncut. `row` lets the app word it again in the
96
+ * reader's language, and `place` lets the dispatch clear a failed
118
97
  * Auto-recorded pin to it, so a failed place never sticks. */
119
98
  export class PlaceUnavailableError extends Error {
120
99
  place;
121
- constructor(place, message) {
122
- super(message);
100
+ row;
101
+ constructor(place, row, cloudHome = false) {
102
+ super(placeRowText(row, cloudHome ? "my-cloud" : undefined));
123
103
  this.name = "PlaceUnavailableError";
124
104
  this.place = place;
105
+ this.row = row;
125
106
  }
126
107
  }
127
- /** A failed attach: the attach's own words as the cause, plus the action. */
128
- export function placeUnavailable(place, source, cause, limit = 160) {
129
- return new PlaceUnavailableError(place, placeFailureMessage(cause, source, limit));
108
+ export function placeUnavailable(place, row, cloudHome = false) {
109
+ return new PlaceUnavailableError(place, row, cloudHome);
130
110
  }
131
111
  /** How the prompt and the app name a surface. Deliberately the same words
132
112
  * the panel uses, so "tell them it is on the cloud computer" points at a