@chibitek/chibi 0.1.169 → 0.1.171

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 (99) hide show
  1. package/dist/assets/{abnfDiagram-O67JEVCF-aQkRN0ee.js → abnfDiagram-O67JEVCF-DrPBKmVh.js} +1 -1
  2. package/dist/assets/{arc-BT7qPVAa.js → arc-BEIChVSB.js} +1 -1
  3. package/dist/assets/{architectureDiagram-NJMV4G6O-oMlARqKW.js → architectureDiagram-NJMV4G6O-Da60nTzs.js} +1 -1
  4. package/dist/assets/{blockDiagram-BEXU5L5S-CKUrb4js.js → blockDiagram-BEXU5L5S-_NelwIQj.js} +1 -1
  5. package/dist/assets/{c4Diagram-YGBWAQC7-BkL5cRIA.js → c4Diagram-YGBWAQC7-DUdkQ3dy.js} +1 -1
  6. package/dist/assets/channel-DEJSSlVQ.js +1 -0
  7. package/dist/assets/{chunk-2Q5K7J3B-BMitkm2s.js → chunk-2Q5K7J3B-BQgYAptM.js} +1 -1
  8. package/dist/assets/{chunk-3FUC2YCW-DOhVN2ed.js → chunk-3FUC2YCW-XXOXwcoW.js} +2 -2
  9. package/dist/assets/{chunk-5VM5RSS4-BFkFo_ng.js → chunk-5VM5RSS4-CUyMXCSt.js} +1 -1
  10. package/dist/assets/{chunk-GTNCS2PH-QbxYUf3Q.js → chunk-GTNCS2PH-BwctbINm.js} +1 -1
  11. package/dist/assets/{chunk-GWA4HPMP-BZChfTYw.js → chunk-GWA4HPMP-DQ0bZK59.js} +1 -1
  12. package/dist/assets/{chunk-JWPE2WC7-BXpmYglA.js → chunk-JWPE2WC7-BADCk9AK.js} +1 -1
  13. package/dist/assets/{chunk-NETBCI7D-CIZTrz1i.js → chunk-NETBCI7D-CoJxabSR.js} +1 -1
  14. package/dist/assets/{chunk-WEXAMYUT-FhqQ0sO0.js → chunk-WEXAMYUT-fupByVJn.js} +1 -1
  15. package/dist/assets/{chunk-XXDRQBXY-5ThtmFbP.js → chunk-XXDRQBXY-BavbGHGi.js} +1 -1
  16. package/dist/assets/{classDiagram-v2-NBCMYWYE-BzObC5Qw.js → classDiagram-v2-NBCMYWYE-B5wfUNKJ.js} +1 -1
  17. package/dist/assets/{cose-bilkent-JH36ORCC-BASV9Okv.js → cose-bilkent-JH36ORCC-DlYFmKDe.js} +1 -1
  18. package/dist/assets/{cynefin-OW5HDTMX-B0zQYMFh.js → cynefin-OW5HDTMX-Y0U-21MH.js} +1 -1
  19. package/dist/assets/{cynefinDiagram-VND7K2PF-DMEO1cHA.js → cynefinDiagram-VND7K2PF-D1Fh1sU0.js} +1 -1
  20. package/dist/assets/{dagre-6A5THRUB-BG04TSi_.js → dagre-6A5THRUB-BNU-Trog.js} +1 -1
  21. package/dist/assets/{diagram-22UHCM2B-CfpVNMhg.js → diagram-22UHCM2B-Bjie4nDQ.js} +1 -1
  22. package/dist/assets/{diagram-3UASUU5V-DG9L4I1L.js → diagram-3UASUU5V-Qnbrq1T5.js} +1 -1
  23. package/dist/assets/{diagram-ATOU4E4O-Db7Bf63S.js → diagram-ATOU4E4O-CNJasPCN.js} +1 -1
  24. package/dist/assets/{diagram-CDSNMT55-Cdzv1tJn.js → diagram-CDSNMT55-BAzGZBKX.js} +1 -1
  25. package/dist/assets/{diagram-MLGK6HIB-BBMeXGKE.js → diagram-MLGK6HIB-I8ACRaw-.js} +1 -1
  26. package/dist/assets/{diagram-MPIPVDR6-BAQ22zk3.js → diagram-MPIPVDR6-nLlrAEPz.js} +1 -1
  27. package/dist/assets/{ebnfDiagram-ZINNZB2B-BDZgSFSF.js → ebnfDiagram-ZINNZB2B-DF9BG3pH.js} +1 -1
  28. package/dist/assets/{elk-276RUBZZ-Bx4-nN8N.js → elk-276RUBZZ-C1AJYKMV.js} +1 -1
  29. package/dist/assets/{erDiagram-OPXOYQCR-D2Pn-cqm.js → erDiagram-OPXOYQCR-MNNcRrPy.js} +1 -1
  30. package/dist/assets/{flowDiagram-KWPJA3E3-hsbvL3Ow.js → flowDiagram-KWPJA3E3-C7mfBfgt.js} +1 -1
  31. package/dist/assets/{ganttDiagram-FUAMR5RP-DBUgYe4p.js → ganttDiagram-FUAMR5RP-a9zNyDbb.js} +1 -1
  32. package/dist/assets/{gitGraphDiagram-X574FWY7-ChGeSINg.js → gitGraphDiagram-X574FWY7-OL8NhUsO.js} +1 -1
  33. package/dist/assets/{index-ByXr4FRl.js → index-DojGBgbN.js} +167 -164
  34. package/dist/assets/{index-Ch93Ygtq.js → index-xypOVpVT.js} +1 -1
  35. package/dist/assets/{infoDiagram-VRGFBTTK-DY2EeC-A.js → infoDiagram-VRGFBTTK-CF-3pzUZ.js} +1 -1
  36. package/dist/assets/{ishikawaDiagram-OU5B5YK6-BznYuY04.js → ishikawaDiagram-OU5B5YK6-B2HqD-sv.js} +1 -1
  37. package/dist/assets/{journeyDiagram-ZHPQQLJL-CX74ZLbW.js → journeyDiagram-ZHPQQLJL-84gzOTb9.js} +1 -1
  38. package/dist/assets/{kanban-definition-PNTS6WVX-0DPavB-t.js → kanban-definition-PNTS6WVX-D7Ee23QC.js} +1 -1
  39. package/dist/assets/{linear-BHeqbvM8.js → linear-CdAXsmfc.js} +1 -1
  40. package/dist/assets/{mermaid.core-Dm-Ny6a0.js → mermaid.core-CE_M1v1e.js} +5 -5
  41. package/dist/assets/{mindmap-definition-NLK3R4M7-BPrMElav.js → mindmap-definition-NLK3R4M7-KSTIET9y.js} +1 -1
  42. package/dist/assets/{pegDiagram-GJSIUBJH-CaD7wyyX.js → pegDiagram-GJSIUBJH-C7ogvddb.js} +1 -1
  43. package/dist/assets/{pieDiagram-5QR66LMP-lbSp8R4K.js → pieDiagram-5QR66LMP-Bp1CgCrM.js} +1 -1
  44. package/dist/assets/{quadrantDiagram-O4NWA36T-Bs3Sn2of.js → quadrantDiagram-O4NWA36T-Bw7aN9Lh.js} +1 -1
  45. package/dist/assets/{railroadDiagram-XR7U4H2S-C88ykXCV.js → railroadDiagram-XR7U4H2S-Dij4U4NY.js} +1 -1
  46. package/dist/assets/{requirementDiagram-PLB6GJNP-mgIQnORW.js → requirementDiagram-PLB6GJNP-DOxTQvCl.js} +1 -1
  47. package/dist/assets/{sankeyDiagram-IPEJSGJF-K5esEw-m.js → sankeyDiagram-IPEJSGJF-DPCUmNbV.js} +1 -1
  48. package/dist/assets/{sequenceDiagram-PO4LG4MO-D5v8PJqQ.js → sequenceDiagram-PO4LG4MO-DPjC8zGe.js} +1 -1
  49. package/dist/assets/{sizeCapture-INFHLROL-BHBBDTEI.js → sizeCapture-INFHLROL-WlmsNlh-.js} +1 -1
  50. package/dist/assets/{stateDiagram-v2-GCMORJYK-CeRdNmKQ.js → stateDiagram-v2-GCMORJYK-BwMPiapI.js} +1 -1
  51. package/dist/assets/{swimlanes-2SLR337P-D8CbMCI8.js → swimlanes-2SLR337P-Cyja7ZPb.js} +1 -1
  52. package/dist/assets/swimlanesDiagram-TC7HE7FX-Dh25zxzq.js +8 -0
  53. package/dist/assets/{timeline-definition-EJHVYXUP-CMG02UV1.js → timeline-definition-EJHVYXUP-BehkSauz.js} +1 -1
  54. package/dist/assets/{usecaseDiagram-POWQR4AR-Bm8h6ijK.js → usecaseDiagram-POWQR4AR-koUjcmpW.js} +1 -1
  55. package/dist/assets/{vennDiagram-UO4OBE2U-Dk9tFjlM.js → vennDiagram-UO4OBE2U-Ch8TXIpy.js} +1 -1
  56. package/dist/assets/{wardleyDiagram-VNRHLVJA-61vrb2Ft.js → wardleyDiagram-VNRHLVJA-Dm5kC4c2.js} +1 -1
  57. package/dist/assets/{xychartDiagram-PMCCYNJV-C7uO44ng.js → xychartDiagram-PMCCYNJV-YA-1zyQz.js} +1 -1
  58. package/dist/handbook/atomic-handbook.html +1 -1
  59. package/dist/index.html +1 -1
  60. package/dist-server/companion/src/routes.js +28 -1
  61. package/dist-server/container-mcp.js +1 -0
  62. package/dist-server/hooks/plan-hook.js +22 -0
  63. package/dist-server/index.js +38538 -37905
  64. package/dist-server/local-computer.js +1 -0
  65. package/dist-server/openmausbot.js +389 -303
  66. package/dist-server/pair-cli.js +389 -303
  67. package/dist-server/proxy-paths.js +1 -0
  68. package/dist-server/server/atomic-engine-release.js +13 -13
  69. package/dist-server/server/bot-work-modes.js +212 -0
  70. package/dist-server/server/config.js +6 -5
  71. package/dist-server/server/drivers/acp/core.js +6 -4
  72. package/dist-server/server/drivers/acp/custom.js +51 -37
  73. package/dist-server/server/drivers/builtIn.js +2 -1
  74. package/dist-server/server/drivers/chat-mcp-tools.js +1 -0
  75. package/dist-server/server/drivers/claude.js +28 -25
  76. package/dist-server/server/drivers/codex-approval-presets.js +34 -0
  77. package/dist-server/server/drivers/codex.js +11 -35
  78. package/dist-server/server/drivers/openai-chat.js +4 -3
  79. package/dist-server/server/hooks/plan-hook.js +17 -0
  80. package/dist-server/server/index.js +92 -175
  81. package/dist-server/server/mcp-interactive-probe.js +60 -0
  82. package/dist-server/server/mcp-remote-bridge.js +13 -6
  83. package/dist-server/server/message-file.js +28 -11
  84. package/dist-server/server/plan-policy.js +14 -0
  85. package/dist-server/server/proxy-paths.js +1 -0
  86. package/dist-server/server/remote-desktop.js +142 -0
  87. package/dist-server/server/routes/message-file.js +115 -0
  88. package/dist-server/server/shared-deliverables.js +109 -0
  89. package/dist-server/server/skill-learn.js +4 -0
  90. package/dist-server/server/store.js +4 -4
  91. package/dist-server/server/subscription-bots.js +3 -2
  92. package/dist-server/shared/approval-mode.js +3 -1
  93. package/dist-server/shared/engine-choices.js +1 -1
  94. package/dist-server/shared/provider-accounts.js +5 -5
  95. package/dist-server/shared/skill-command.js +16 -0
  96. package/dist-server/vps-container-mcp.js +1 -0
  97. package/package.json +1 -1
  98. package/dist/assets/channel-BZDQCBTd.js +0 -1
  99. package/dist/assets/swimlanesDiagram-TC7HE7FX-CQHcTGnI.js +0 -8
@@ -1,3 +1,5 @@
1
+ import { createMessageFileRoutes } from "./routes/message-file.js";
2
+ import { SharedDeliverables, createSharedDeliverablesRoutes, sharedDeliverableAccess } from "./shared-deliverables.js";
1
3
  import { connectionCredentialServer } from "./connection-credential.js";
2
4
  import { requestCredentialCard } from "./credential-card-request.js";
3
5
  import { invalidateBrokenCheckpoint } from "./checkpoint-recovery.js";
@@ -34,7 +36,7 @@ import { CLOUD_COMPUTER_BUSY_ERROR } from "../shared/computer-contention.js";
34
36
  import { approvalModeFor, supportsApprovalMode, modelSwitchNeedsAsk, isEmergencyApprovalDowngrade, isApprovalMode, } from "../shared/approval-mode.js";
35
37
  import { escapeAttribute } from "../src/lib/composer-attachments.js";
36
38
  import { credentialResumeOutcome, credentialIsConfigured, isCredentialTargetId, } from "../shared/credential-request.js";
37
- import { approvalHeldNote, approvalHeldReason, approvalModeForOrigin, autoVerdict, deliverFullAccessApproval, delegationInheritsFullAccess } from "./auto-approve.js";
39
+ import { approvalHeldNote, approvalHeldReason, autoVerdict, deliverFullAccessApproval, delegationInheritsFullAccess } from "./auto-approve.js";
38
40
  import { updateClaudeCli } from "./claude-update.js";
39
41
  import { failedRunDetail } from "./turn-failure.js";
40
42
  import { configuredAccountDirectory, assertSeparateClaudeAccount, claudeAccountInfo, createClaudeAccountSchema, instanceSettingsSchema, newClaudeAccount } from "./claude-accounts.js";
@@ -50,7 +52,6 @@ import { ApprovalGrantStore, describeScope, matchGrant } from "./approval-grants
50
52
  import { validateBotCwd } from "./bot-cwd.js";
51
53
  import { ATTACHMENTS_DIR, attachmentExists, cleanupStaleAttachmentPartials, deleteAttachment, extensionForMime, FILE_MAX_BYTES, IMAGE_MAX_BYTES, readAttachment, saveFile, saveImage, saveImageUpload, validateAttachmentUploadId, } from "./attachments.js";
52
54
  import { companyCheck, companyContext } from "./company-document-check.js";
53
- import { messageFileDisposition, messageFileDownloadName, messageAttachmentName, messageFileRoots, messageImageTargetAt, messageReferencesFile, openMessageFile, } from "./message-file.js";
54
55
  import { avatarGenerationRequestSchema, avatarGenerationStateMatches, avatarImageStatus, snapshotAvatarGenerationState } from "./avatar-image.js";
55
56
  import { generateRoutedAvatar, installAvatarSubscriptionRoute } from "./avatar-subscription.js";
56
57
  import { fitsOnOneLine, parseBotProfilePatch } from "./bot-profile.js";
@@ -99,6 +100,7 @@ import { decodeGeneratedImage } from "./generated-image.js";
99
100
  import { MAX_MCP_SERVERS, listMcpServers, mcpServerNameError, parseMcpServerMutation, parseMcpServersImport, parseStoredMcpServer, isRemoteMcpServer, mcpReadOnlyAutoAllow, } from "./mcp-registry.js";
100
101
  import { mcpLaunchBlocker, mcpLaunchBlockerReason, mcpSecretStorage, mcpServerSecretView, moveParkedArgToEnv, receiveMcpSecretsSaveResult, setMcpSecretSender, storeMcpServerSecrets, } from "./mcp-secrets.js";
101
102
  import { probeMcpServer } from "./mcp-probe.js";
103
+ import { createInteractiveMcpProbe } from "./mcp-interactive-probe.js";
102
104
  import { McpToolHintStore, readOnlyToolAllowed } from "./mcp-tool-hints.js";
103
105
  import { McpAuthStateStore } from "./mcp-auth-state.js";
104
106
  import { McpHealthStore } from "./mcp-health.js";
@@ -156,11 +158,12 @@ import { applyStagedSkillWrite, applySkillWriteWithReceipt, getStagedSkillWrite,
156
158
  import { resolveSkillImport, skillImportSchema } from "./skill-fetch.js";
157
159
  import { pickBotName } from "./names.js";
158
160
  import { botSkillTemplateSchema, newBotDefaultsSchema, resolveBotCreationDefaults } from "./new-bot-defaults.js";
159
- import { expandLearnTurnText, learnSource } from "./skill-learn.js";
161
+ import { expandSkillTurnText, learnSource } from "./skill-learn.js";
160
162
  import { expandSetupTurnText, setupModeActive, setupSystemPrompt } from "./setup-mode.js";
161
163
  import { checkSoulDrift, readSoulDrift, soulFile, writeSoulMirror } from "./bot-folder.js";
162
164
  import { buildSystemPrompt, userProfileSystemPrompt, computerPrompt, accountsPrompt, customMcpPrompt, CREDENTIAL_PROMPT, mentionPrompt, THREADS_PROMPT, LEARN_PROMPT, PROFILE_PROMPT, ROUTINE_PROMPT, ROUTINE_EXECUTION_PROMPT, WEBHOOK_PROMPT, } from "./system-prompt.js";
163
165
  import { readCuaConnection, readCuaUnavailableReason, gatedLocalComputer } from "./local-computer.js";
166
+ import { remoteDesktopRoute } from "./remote-desktop.js";
164
167
  import { discoverExistingPerBotLocalVms, localVmInventoryEntry, shouldArmLocalVmIdle, } from "./local-vm-inventory.js";
165
168
  import { ATOMIC_BACKUP_DIR, BROWSER_BACKUP_INTERVAL_MS, backupBrowserProfiles, localVmWorkspaces } from "./atomic-backup.js";
166
169
  import { LocalVmIdleTimer } from "./local-vm-idle.js";
@@ -251,6 +254,7 @@ import { PHONE_SECRET_PROTOCOL_VERSION, PhoneSecretBridge, PhoneSecretError, Pho
251
254
  // Keep these two last: a route module may import any server module, and
252
255
  // loading the table after everything above leaves module start-up order as is.
253
256
  import { json, onJsonBody, parsedBodyOf, readBody } from "./harness/http.js";
257
+ import { BotWorkModes, createBotWorkModeRoutes, ownBotApprovalMode, legacyGodModeCovers, dispatchWorkMode, trustedPlanChangeForStore, invalidAllThreadScope } from "./bot-work-modes.js";
254
258
  import { ROUTES, dispatchRoutes } from "./routes/table.js";
255
259
  import { createThreadStopRoutes } from "./routes/thread-stop.js";
256
260
  import { createThreadImageRoutes } from "./routes/thread-images.js";
@@ -2662,12 +2666,13 @@ function subscriptionModel(bot, candidate, wanted) {
2662
2666
  const provider = subscriptionProvider(bot.subscription?.provider);
2663
2667
  if (!provider)
2664
2668
  return undefined;
2669
+ const usable = (model) => !engineResting(candidate, model);
2665
2670
  const flagship = wanted !== undefined ? undefined : provider.host ? hostDefaultModel(provider, candidate) : frontierChoice(bot, candidate)?.model;
2666
- if (flagship)
2671
+ if (flagship && usable(flagship))
2667
2672
  return flagship;
2668
2673
  return bestProviderModel({
2669
2674
  provider, candidate, wanted: wanted ?? engineTierClasses(cfg.engineTiers)[botTier(bot)],
2670
- classOf: (model) => modelClassOf(candidate.driverKind, model), ranked: rankedModelIds(candidate.driverKind),
2675
+ classOf: (model) => modelClassOf(candidate.driverKind, model), ranked: rankedModelIds(candidate.driverKind), usable,
2671
2676
  });
2672
2677
  }
2673
2678
  /** Thrown when a subscription bot's turn has no account of its provider
@@ -4171,8 +4176,8 @@ function checkedTaskModelSwitch(current, raw, updateBotDefault, resetApprovalToA
4171
4176
  }
4172
4177
  for (const target of updateBotDefault ? [current, profile] : [current]) {
4173
4178
  const mode = approvalModeFor(target);
4174
- if (resetApprovalToAsk && mode === "custom" && !trusted) {
4175
- return { ok: false, status: 403, error: "Leaving Custom approval requires confirmation in the packaged desktop app" };
4179
+ if (resetApprovalToAsk && (mode === "plan" || (mode === "custom" && !trusted))) {
4180
+ return { ok: false, status: 403, error: "Approve the plan or change Custom permissions in the app before switching models" };
4176
4181
  }
4177
4182
  if (!resetApprovalToAsk && modelSwitchNeedsAsk(mode, registry.cliTarget(target.modelSelection.instanceId)?.driverKind, registry.cliTarget(checked.selection.instanceId)?.driverKind)) {
4178
4183
  return { ok: false, status: 400, error: "Confirm switching this model with Ask permissions first (resetApprovalToAsk)" };
@@ -4324,7 +4329,7 @@ const wireBot = (bot) => {
4324
4329
  const visible = approvalGrant && !approvalGrant.threadOnly
4325
4330
  ? { ...rest, approvalMode: "ask", autoApprove: false }
4326
4331
  : rest;
4327
- return { ...visible, waitingForTeammates: activeCoordinationForThread(bot.threadId) && !threadBusy(bot.id, bot.threadId),
4332
+ return { ...visible, workMode: godModeCovers(bot) ? "full" : botWorkModes.permits(approvalModeFor(visible)) ? approvalModeFor(visible) : "ask", workModeWorkspaceOverride: godModeCovers(bot), waitingForTeammates: activeCoordinationForThread(bot.threadId) && !threadBusy(bot.id, bot.threadId),
4328
4333
  screenLive: screenLiveForBot(bot.id), avatarUrl: visible.avatarUrl ?? null, ...(tasks ? { tasks: tasks.map(wireTask) } : {}) };
4329
4334
  };
4330
4335
  /** The correlated private response carries the requested value so Electron
@@ -4450,7 +4455,7 @@ async function botOverview(bot) {
4450
4455
  autoApprove: bot.autoApprove,
4451
4456
  approvalMode: approvalModeForTurn(bot),
4452
4457
  // GOD MODE skips the peer check for every bot, whatever its engine
4453
- approvePeerComms: bot.approvePeerComms && !godModeActive(),
4458
+ approvePeerComms: bot.approvePeerComms && !godModeCovers(bot),
4454
4459
  peers: bot.peers,
4455
4460
  browser: bot.browser,
4456
4461
  chiefOfStaff: bot.chiefOfStaff,
@@ -4489,36 +4494,15 @@ async function botOverview(bot) {
4489
4494
  recent,
4490
4495
  });
4491
4496
  }
4492
- /** Defense in depth for hand-edited/corrupt durable records: elevated
4493
- * approval semantics require an implemented provider mapping. The trusted transition enforces
4494
- * this too, but no provider dispatch or later permission callback relies on
4495
- * persistence having been produced exclusively by that route. Delegation
4496
- * uses the receiving bot's grant, never the sender's (approvalModeForOrigin) —
4497
- * with one deliberate exception: a Chief of Staff with Full access makes the
4498
- * threads it delegates Full too (delegatedFullAccess), so the grant the
4499
- * person gave the Chief covers the work the Chief hands out. */
4497
+ // Enforce engine permissions on every turn, including delegated work.
4498
+ const botWorkModes = new BotWorkModes(join(DATA_DIR, "bot-work-modes.json"));
4499
+ for (const id of botWorkModes.expired(id => sessions.isLive(id)))
4500
+ store.setAllThreadApprovalMode(id, "ask", true);
4500
4501
  const approvalModeForTurn = (bot, peerInitiated = false) => godModeElevates(bot) ? "full" : ownApprovalModeForTurn(bot, peerInitiated);
4501
4502
  /** The bot's (or thread's) own level for a turn, GOD MODE aside. */
4502
- const ownApprovalModeForTurn = (bot, peerInitiated = false) => {
4503
- const mode = approvalModeForOrigin(approvalModeFor(bot), { peerInitiated });
4504
- if (!supportsApprovalMode(registry.cliTarget(bot.modelSelection.instanceId)?.driverKind, mode)) {
4505
- return "ask";
4506
- }
4507
- return mode;
4508
- };
4509
- /** GOD MODE covers this bot or thread: the switch is on, this server allows
4510
- * it, and no approval change of this bot's own is still being confirmed
4511
- * (approvalModeFor keeps that inert as Ask, and so does GOD MODE). It is
4512
- * decided per request and never written to a bot record, so turning it off
4513
- * returns every bot to its own level. Every engine is covered: one that
4514
- * implements Full runs its turns at Full (godModeElevates); on any other the
4515
- * harness answers each tool permission request the engine sends
4516
- * (request.opened), so no engine keeps asking under GOD MODE. */
4503
+ const ownApprovalModeForTurn = (bot, peerInitiated = false) => ownBotApprovalMode(bot, registry.cliTarget(bot.modelSelection.instanceId)?.driverKind, peerInitiated, botWorkModes.permits(approvalModeFor(bot)));
4517
4504
  function godModeCovers(bot) {
4518
- if (!godModeActive())
4519
- return false;
4520
- const grant = bot.approvalGrant;
4521
- return !(grant && !(grant.threadOnly && grant.threadId !== bot.threadId));
4505
+ return legacyGodModeCovers(bot, godModeActive() && botWorkModes.permits("full"), botWorkModes.selected(bot.id) !== undefined);
4522
4506
  }
4523
4507
  /** GOD MODE runs this bot or thread at Full, whatever its own level says:
4524
4508
  * it is covered (godModeCovers) and its engine implements Full. */
@@ -4563,7 +4547,7 @@ function peerReviewRequired(bot, threadId) {
4563
4547
  /** "Ask me before contacting other bots" is skipped with Full access on the
4564
4548
  * source conversation, and for every bot while GOD MODE is on. */
4565
4549
  function peerApprovalWaived(botId, threadId) {
4566
- return godModeActive() || fullAccessForSource(botId, threadId);
4550
+ return fullAccessForSource(botId, threadId) || Boolean(store.projectBotForTask(botId, threadId) && godModeCovers(store.projectBotForTask(botId, threadId)));
4567
4551
  }
4568
4552
  /** Full access flows down a Chief of Staff's delegation. The person gave the
4569
4553
  * Chief Full access so its work runs without prompts; a teammate stopping
@@ -4577,7 +4561,7 @@ function peerApprovalWaived(botId, threadId) {
4577
4561
  function delegatedFullAccess(from, fromThreadId, target) {
4578
4562
  // Chibi's GOD MODE never reaches the Private Bot, and the Private Bot's
4579
4563
  // never reaches Chibi: each runs a linked handoff at its own level.
4580
- if (isPersonalBot(from) !== isPersonalBot(target))
4564
+ if (isPersonalBot(from) !== isPersonalBot(target) || approvalModeFor(target) === "plan")
4581
4565
  return false;
4582
4566
  return delegationInheritsFullAccess({
4583
4567
  senderIsChief: Boolean(from.chiefOfStaff),
@@ -4681,7 +4665,7 @@ function handleDesktopTrustedApprovalMessage(raw) {
4681
4665
  const grantBusy = (bot) => bot.approvalGrant?.threadOnly
4682
4666
  ? threadBusy(bot.id, bot.approvalGrant.threadId) : bot.busy ||
4683
4667
  Boolean(bot.approvalGrant?.allThreads && store.tasks(bot.id).some(task => threadBusy(bot.id, task.threadId)));
4684
- const grantSupported = (bot, mode) => supportsApprovalMode(registry.cliTarget(grantTarget(bot)?.modelSelection.instanceId ?? "")?.driverKind, mode) &&
4668
+ const grantSupported = (bot, mode) => botWorkModes.permits(mode) && supportsApprovalMode(registry.cliTarget(grantTarget(bot)?.modelSelection.instanceId ?? "")?.driverKind, mode) &&
4685
4669
  (!bot.approvalGrant?.allThreads || store.tasks(bot.id).every(task => supportsApprovalMode(registry.cliTarget((task.modelSelection ?? bot.modelSelection).instanceId)?.driverKind, mode)));
4686
4670
  const clearGrant = (bot) => store.patchBot(bot.id, {
4687
4671
  ...(!bot.approvalGrant?.threadOnly ? { approvalMode: "ask", autoApprove: false } : {}),
@@ -4896,6 +4880,10 @@ function handleDesktopTrustedApprovalMessage(raw) {
4896
4880
  respond({ ok: false, error: "The approval mode is invalid" });
4897
4881
  return true;
4898
4882
  }
4883
+ if (!botWorkModes.permits(mode)) {
4884
+ respond({ ok: false, error: "This mode is disabled in Settings" });
4885
+ return true;
4886
+ }
4899
4887
  const existing = store.bot(botId);
4900
4888
  if (!existing) {
4901
4889
  respond({ ok: false, error: "No such bot" });
@@ -4903,15 +4891,16 @@ function handleDesktopTrustedApprovalMessage(raw) {
4903
4891
  }
4904
4892
  const currentMode = approvalModeFor(existing);
4905
4893
  const threadId = message.threadId;
4906
- if (message.allThreads !== undefined && (typeof message.allThreads !== "boolean" ||
4907
- (message.allThreads && (threadId !== undefined || message.threadOnly === true ||
4908
- message.modelSelection !== undefined || message.updateBotDefault !== undefined ||
4909
- (mode !== "full" && mode !== "ask"))))) {
4894
+ if (!trustedPlanChangeForStore(store, botId, mode, message)) {
4895
+ respond({ ok: false, error: "Approve the plan in the app before leaving Plan" });
4896
+ return true;
4897
+ }
4898
+ if (invalidAllThreadScope(mode, message)) {
4910
4899
  respond({ ok: false, error: "Invalid all-threads approval scope" });
4911
4900
  return true;
4912
4901
  }
4913
4902
  if (message.allThreads === true && mode === "ask") {
4914
- const updated = store.setAllThreadApprovalMode(botId, "ask");
4903
+ const updated = store.setAllThreadApprovalMode(botId, "ask", message.approvePlan !== true);
4915
4904
  void stopBotForEmergencyApprovalDowngrade(botId).then(() => respond({ ok: true, bot: wireBot(store.bot(botId) ?? updated) }), () => respond({ ok: false, error: "Approval was reset to Ask, but an active turn could not be stopped" }));
4916
4905
  return true;
4917
4906
  }
@@ -6048,6 +6037,10 @@ function closeSessionStreams(sessionId) {
6048
6037
  }
6049
6038
  sessions.onSessionRevoked((sessionId) => {
6050
6039
  providerAuthSessions.revokeOwner(sessionId);
6040
+ for (const id of botWorkModes.revoke(sessionId)) {
6041
+ store.setAllThreadApprovalMode(id, "ask", true);
6042
+ void interruptBotWork(id);
6043
+ }
6051
6044
  closeSessionStreams(sessionId);
6052
6045
  });
6053
6046
  /** Every frame is numbered, and the last few hundred are kept, so a client
@@ -10051,7 +10044,7 @@ async function startTurn(botId, text, opts) {
10051
10044
  // the bot's first tool call (desktop attachments are host paths).
10052
10045
  const attachedText = commsDepth === 0 ? await withZipListings(providerText) : providerText;
10053
10046
  const setupText = agentsMounted ? expandSetupTurnText(attachedText) : attachedText;
10054
- const userTurnText = withAssistantProvenance(promptWithReply(skillAuthoring ? expandLearnTurnText(setupText) : setupText, opts?.replyTo, cfg.profile?.name?.trim() || "User"), userMessage.sender, cfg.profile?.name?.trim() || "the person");
10047
+ const userTurnText = withAssistantProvenance(promptWithReply(expandSkillTurnText(setupText, skillAuthoring), opts?.replyTo, cfg.profile?.name?.trim() || "User"), userMessage.sender, cfg.profile?.name?.trim() || "the person");
10055
10048
  // Decided again at dispatch when setup outlasted a soul edit (config).
10056
10049
  const decideContext = (config) => {
10057
10050
  const handedStale = Boolean(handed && (!unseen || (externalUpdate && handed.config !== config)));
@@ -10817,7 +10810,7 @@ async function startTurn(botId, text, opts) {
10817
10810
  text: dispatchContext.turnText,
10818
10811
  refreshSystemPrompt: true,
10819
10812
  images: turnImages,
10820
- approvalMode: approvalModeForTurn(bot, commsDepth > 0),
10813
+ approvalMode: dispatchWorkMode(approvalModeForTurn(bot, commsDepth > 0), registry.cliTarget(instance.instanceId)?.driverKind),
10821
10814
  model,
10822
10815
  effort,
10823
10816
  variant,
@@ -12546,7 +12539,7 @@ failover) {
12546
12539
  .filter(Boolean)
12547
12540
  .join("\n");
12548
12541
  const latestUserText = usesNativeImageInput ? resolvedLatestImages.text : latestUser?.text;
12549
- const learnTurn = skillAuthoring && latestUserText ? expandLearnTurnText(latestUserText) : "";
12542
+ const learnTurn = latestUserText ? expandSkillTurnText(latestUserText, skillAuthoring) : "";
12550
12543
  const learnBlock = learnTurn && learnTurn !== latestUserText ? `\n\n${learnTurn}` : "";
12551
12544
  const addressedRequest = orchestration?.roomHandoffId ? roomHandoffs.nodes.get(orchestration.roomHandoffId) : undefined;
12552
12545
  // The room transcript already carries recent requests and reports. Repeat
@@ -12769,7 +12762,7 @@ failover) {
12769
12762
  text,
12770
12763
  refreshSystemPrompt: true,
12771
12764
  images: turnImages,
12772
- approvalMode: roomTurnApprovalMode(readyBot, orchestration),
12765
+ approvalMode: dispatchWorkMode(roomTurnApprovalMode(readyBot, orchestration), registry.cliTarget(instance.instanceId)?.driverKind),
12773
12766
  system: roomSystem.text,
12774
12767
  systemStable: roomSystem.stable,
12775
12768
  systemVolatile: roomSystem.volatile,
@@ -13610,8 +13603,9 @@ function handoffAuthorizations(from, sourceThreadId, raw) {
13610
13603
  conversationBotName: from.name,
13611
13604
  });
13612
13605
  }
13613
- /** How long "Sign in" on a bridge server waits for the person's browser sign-in. */
13614
- const MCP_SIGN_IN_TIMEOUT_MS = 180_000;
13606
+ /** Test and Reconnect share a single browser-allowed probe per connection.
13607
+ * Bot MCP sessions remain independent to preserve request routing. */
13608
+ const interactiveMcpProbe = createInteractiveMcpProbe();
13615
13609
  /** The interview's view of this computer (GET /api/bot-interview/context):
13616
13610
  * only tools that exist here, each with a plain status. The Personal Bot
13617
13611
  * sees only connections marked Personal; a company bot never sees those. */
@@ -16043,6 +16037,24 @@ ROUTES.push(createMcpMarketplaceRoutes({
16043
16037
  fromBot: (req) => authorizedInternalCapability(req.headers.authorization) !== null,
16044
16038
  sessionExpired: (auth) => auth.kind === "session" && !sessions.isLive(auth.session.id),
16045
16039
  }));
16040
+ ROUTES.push(createBotWorkModeRoutes({
16041
+ modes: botWorkModes, desktopManaged: DESKTOP_MANAGED, live: id => sessions.isLive(id),
16042
+ bot: id => {
16043
+ const bot = store.bot(id);
16044
+ if (!bot)
16045
+ return undefined;
16046
+ const targets = [bot, ...store.tasks(id).map(task => store.projectBotForTask(id, task.threadId)).filter(Boolean)];
16047
+ return { busy: targets.some(target => threadBusy(id, target.threadId)),
16048
+ revision: JSON.stringify(targets.map(target => [target.threadId, target.modelSelection, target.approvalMode, target.approvalGrant, store.messagesFor(target.threadId).at(-1)?.id])),
16049
+ drivers: targets.map(target => registry.cliTarget(target.modelSelection.instanceId)?.driverKind),
16050
+ current: targets.some(target => approvalModeFor(target) === "plan") ? "plan" : wireBot(bot).workMode ?? "ask" };
16051
+ },
16052
+ stop: interruptBotWork,
16053
+ apply: (id, mode, preservePlan) => { store.setAllThreadApprovalMode(id, mode, preservePlan); },
16054
+ affected: allowed => store.bots.filter(bot => [bot, ...store.tasks(bot.id).map(task => store.projectBotForTask(bot.id, task.threadId)).filter(Boolean)].some(target => ![...allowed, "auto", "custom", "plan"].includes(godModeCovers(target) ? "full" : approvalModeFor(target)))).map(bot => bot.id),
16055
+ changed: id => { const bot = store.bot(id); if (bot)
16056
+ broadcast({ kind: "bot", bot: wireBot(bot) }); },
16057
+ }));
16046
16058
  // Settings → AI & accounts → Local models: Ollama's models, pulls and removals.
16047
16059
  ROUTES.push(createLocalModelRoutes({
16048
16060
  ollamaUrl: process.env.OMB_OLLAMA_URL || undefined,
@@ -16158,6 +16170,14 @@ ROUTES.push(createThreadStopRoutes({
16158
16170
  routines?.activeBotRunForBot(botId)?.threadId === threadId,
16159
16171
  stop: stopBotThread,
16160
16172
  }));
16173
+ const sharedDeliverables = new SharedDeliverables(DATA_DIR);
16174
+ const deliverableAccess = (senderId, auth) => sharedDeliverableAccess(sharedDeliverables, {
16175
+ dataDir: DATA_DIR, pawRoot: paw.root, store, senderId,
16176
+ canSeeBot: auth ? (id) => visibleTo(viewerFor(auth)).bot(id) : () => true,
16177
+ canSeeGroup: auth ? (id) => visibleTo(viewerFor(auth)).group(id) : () => true,
16178
+ });
16179
+ ROUTES.push(createSharedDeliverablesRoutes(sharedDeliverables, () => deliverableAccess(undefined).sharedBlockedRoots ?? []));
16180
+ ROUTES.push(createMessageFileRoutes({ store, attachments: ATTACHMENTS_DIR, workspaceDir, access: deliverableAccess }));
16161
16181
  ROUTES.push(createPinOrderRoutes({ store, visible: (auth) => visibleTo(viewerFor(auth)), personalHidden: personalBotHidden, fromBot: (req) => authorizedInternalCapability(req.headers.authorization) !== null }));
16162
16182
  // Linked repos: Settings > Repos, per-thread links and worktrees. See
16163
16183
  // server/routes/repos.ts for the routes and docs/chibitek/README.md.
@@ -19204,118 +19224,6 @@ const handleRequest = async (req, res) => {
19204
19224
  });
19205
19225
  return res.end(bytes);
19206
19226
  }
19207
- // Download one local file only when this exact stored message grants it:
19208
- // a bot must render a Markdown link or carry a generated-image attachment,
19209
- // while a user message must carry the standalone composer tag. The bot
19210
- // branch derives conversation/workspace roots; the user branch is limited
19211
- // to OpenMausBot's private attachment directory. This is deliberately not
19212
- // a general path reader.
19213
- m = path.match(/^\/api\/threads\/([\w-]+)\/messages\/([\w-]+)\/file$/);
19214
- const streamsMessageImage = Boolean(m && method === "GET" && url.searchParams.get("preview") === "1");
19215
- if (m && (method === "POST" || streamsMessageImage)) {
19216
- if (method === "POST" && !String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
19217
- return json(res, 415, { error: "content-type must be application/json" });
19218
- }
19219
- const threadId = m[1];
19220
- const directBot = store.botByThread(threadId);
19221
- const group = directBot ? undefined : store.groupByThread(threadId);
19222
- if (!directBot && !group)
19223
- return json(res, 404, { error: "no such conversation" });
19224
- const message = store.messagesFor(threadId).find((candidate) => candidate.id === m[2]);
19225
- if (!message)
19226
- return json(res, 404, { error: "no such message" });
19227
- if (message.kind !== "text") {
19228
- return json(res, 403, { error: "that message does not share this file" });
19229
- }
19230
- const body = method === "POST" ? await readBody(req) : null;
19231
- const rawReference = streamsMessageImage ? url.searchParams.get("ref") : null;
19232
- if (streamsMessageImage && (!rawReference || !/^\d+$/.test(rawReference))) {
19233
- return json(res, 400, { error: "ref must identify a rendered image" });
19234
- }
19235
- const href = method === "POST"
19236
- ? (typeof body.path === "string" ? body.path : "")
19237
- : messageImageTargetAt(message.text ?? "", Number(rawReference));
19238
- if (!href)
19239
- return json(res, 400, { error: "path is required" });
19240
- // Generated image paths are durable capabilities on this exact message.
19241
- // They do not grant access to arbitrary workspace files or Markdown refs.
19242
- const generatedImage = method === "POST" && message.role === "bot" &&
19243
- message.attachments?.some((attachment) => attachment.kind === "image" && attachment.path === href) === true;
19244
- let roots;
19245
- let downloadName;
19246
- if (generatedImage) {
19247
- roots = [ATTACHMENTS_DIR];
19248
- }
19249
- else if (message.role === "user") {
19250
- downloadName = messageAttachmentName(message.text ?? "", href) ?? undefined;
19251
- if (!downloadName) {
19252
- return json(res, 403, { error: "that message does not share this file" });
19253
- }
19254
- roots = [ATTACHMENTS_DIR];
19255
- }
19256
- else {
19257
- if (!messageReferencesFile(message.text ?? "", href)) {
19258
- return json(res, 403, { error: "that bot message does not link to this file" });
19259
- }
19260
- const senderId = directBot?.id ?? message.from?.botId;
19261
- // The persisted bot-role message is the author record. Membership is
19262
- // intentionally not consulted: removing a bot must not break files it
19263
- // already shared in channel history.
19264
- if (!senderId) {
19265
- return json(res, 403, { error: "the file's bot author could not be verified" });
19266
- }
19267
- let pinnedCwd;
19268
- let configuredCwd;
19269
- if (directBot) {
19270
- pinnedCwd = store.taskByThread(directBot.id, threadId)?.cwd;
19271
- configuredCwd = directBot.cwd;
19272
- }
19273
- else if (group) {
19274
- const task = store.groupTaskByThread(group.id, threadId);
19275
- pinnedCwd = task ? task.pinnedCwd : group.threadId === threadId ? group.pinnedCwd : undefined;
19276
- configuredCwd = group.cwd;
19277
- }
19278
- roots = messageFileRoots({
19279
- senderWorkspace: workspaceDir(senderId),
19280
- attachments: ATTACHMENTS_DIR,
19281
- pinnedCwd,
19282
- configuredCwd,
19283
- });
19284
- }
19285
- const file = await openMessageFile(href, roots);
19286
- if ((streamsMessageImage || generatedImage) && !file.mime.startsWith("image/")) {
19287
- await file.handle.close();
19288
- return json(res, 415, { error: "only images can be previewed here" });
19289
- }
19290
- res.writeHead(200, {
19291
- "content-type": file.mime,
19292
- "content-length": String(file.bytes),
19293
- ...(streamsMessageImage
19294
- ? { "content-disposition": "inline" }
19295
- : { "content-disposition": messageFileDisposition(messageFileDownloadName(downloadName, file.name)) }),
19296
- "cache-control": streamsMessageImage ? "private, max-age=3600" : "private, no-store",
19297
- "cdn-cache-control": "no-store",
19298
- "cloudflare-cdn-cache-control": "no-store",
19299
- pragma: "no-cache",
19300
- vary: "Authorization",
19301
- "x-content-type-options": "nosniff",
19302
- ...(streamsMessageImage
19303
- ? {
19304
- "cross-origin-resource-policy": "same-origin",
19305
- "referrer-policy": "no-referrer",
19306
- }
19307
- : {}),
19308
- });
19309
- if (file.bytes === 0) {
19310
- await file.handle.close();
19311
- return res.end();
19312
- }
19313
- const stream = file.handle.createReadStream({ start: 0, end: file.bytes - 1, autoClose: true });
19314
- stream.on("error", () => res.destroy());
19315
- res.on("close", () => stream.destroy());
19316
- stream.pipe(res);
19317
- return;
19318
- }
19319
19227
  // ── image attachments ────────────────────────────────────────────────
19320
19228
  // Pasted/dropped images are stored as files and referenced by path in
19321
19229
  // the prompt (<attached-image path="…"/>); this pair of routes is the
@@ -21283,8 +21191,12 @@ const handleRequest = async (req, res) => {
21283
21191
  else {
21284
21192
  requestedApprovalMode = approvalModeFor(existingBot ?? {});
21285
21193
  }
21194
+ if (!botWorkModes.permits(requestedApprovalMode))
21195
+ return json(res, 403, { error: "This mode is disabled in Settings" });
21286
21196
  const currentApprovalMode = approvalModeFor(existingBot ?? {});
21287
21197
  const approvalChangeRequested = body.approvalMode !== undefined || body.autoApprove !== undefined;
21198
+ if (currentApprovalMode === "plan" && requestedApprovalMode !== "plan")
21199
+ return json(res, 403, { error: "Approve the plan in the app before leaving Plan" });
21288
21200
  if (existingBot?.busy && approvalChangeRequested && requestedApprovalMode !== currentApprovalMode) {
21289
21201
  return json(res, 409, {
21290
21202
  error: "stop this bot's turn before changing its approval level",
@@ -21299,13 +21211,13 @@ const handleRequest = async (req, res) => {
21299
21211
  const targetSelection = normalizedSelection ?? existingBot?.modelSelection;
21300
21212
  if (normalizedSelection && selectedTask) {
21301
21213
  const mode = approvalModeFor(selectedTask);
21302
- if ((mode === "full" || mode === "custom") &&
21214
+ if ((mode === "full" || mode === "custom" || mode === "plan") &&
21303
21215
  (!supportsApprovalMode(registry.cliTarget(normalizedSelection.instanceId)?.driverKind, mode) ||
21304
21216
  registry.cliTarget(normalizedSelection.instanceId)?.driverKind !== registry.cliTarget(selectedTask.modelSelection.instanceId)?.driverKind)) {
21305
21217
  return json(res, 400, { error: "Choose Ask for the selected thread before changing providers with elevated permissions" });
21306
21218
  }
21307
21219
  }
21308
- if ((requestedApprovalMode === "full" || requestedApprovalMode === "custom") &&
21220
+ if ((requestedApprovalMode === "full" || requestedApprovalMode === "custom" || requestedApprovalMode === "plan") &&
21309
21221
  (body.approvalMode !== undefined || normalizedSelection !== undefined) &&
21310
21222
  (!targetSelection || !supportsApprovalMode(registry.cliTarget(targetSelection.instanceId)?.driverKind, requestedApprovalMode) ||
21311
21223
  (existingBot && normalizedSelection && registry.cliTarget(normalizedSelection.instanceId)?.driverKind !== registry.cliTarget(existingBot.modelSelection.instanceId)?.driverKind))) {
@@ -23009,14 +22921,19 @@ const handleRequest = async (req, res) => {
23009
22921
  if (!checked.ok)
23010
22922
  return json(res, checked.status, { error: checked.error });
23011
22923
  patch.modelSelection = checked.selection;
22924
+ if (approvalModeFor(current) === "plan" && !supportsApprovalMode(registry.cliTarget(checked.selection.instanceId)?.driverKind, "plan"))
22925
+ return json(res, 400, { error: "Choose Ask before switching from Plan to this engine" });
23012
22926
  }
23013
22927
  if (body.approvalMode !== undefined || body.autoApprove !== undefined) {
23014
22928
  if (body.autoApprove !== undefined && typeof body.autoApprove !== "boolean")
23015
22929
  return json(res, 400, { error: "autoApprove must be a boolean" });
23016
22930
  const mode = body.approvalMode ?? (body.autoApprove ? "auto" : "ask");
23017
- // Elevated modes still require the trusted desktop transition. A
23018
- // thread settings PATCH cannot manufacture that grant.
23019
- if (mode !== "ask" && mode !== "auto" && mode !== "edits")
22931
+ if (!botWorkModes.permits(mode))
22932
+ return json(res, 403, { error: "This mode is disabled in Settings" });
22933
+ if (approvalModeFor(current) === "plan" && mode !== "plan")
22934
+ return json(res, 403, { error: "Approve the plan in the app before leaving Plan" });
22935
+ // A settings PATCH cannot manufacture an elevated grant.
22936
+ if (mode !== "ask" && mode !== "auto" && mode !== "edits" && mode !== "plan")
23020
22937
  return json(res, 403, { error: "Full and Custom access require trusted desktop confirmation" });
23021
22938
  if (approvalModeFor(current) === "custom")
23022
22939
  return json(res, 403, { error: "Leaving Custom approval requires confirmation in the packaged desktop app" });
@@ -23995,7 +23912,7 @@ const handleRequest = async (req, res) => {
23995
23912
  const testBlocked = mcpLaunchBlocker(parsed.server);
23996
23913
  if (testBlocked)
23997
23914
  return json(res, 409, { error: `${mcpTest[1]} cannot start yet: ${mcpLaunchBlockerReason(testBlocked)}`, code: testBlocked });
23998
- if (mcpProbesInFlight >= MAX_CONCURRENT_MCP_PROBES) {
23915
+ if (mcpProbesInFlight >= MAX_CONCURRENT_MCP_PROBES && !interactiveMcpProbe.has(bridgedMcpServer(parsed.server, true))) {
23999
23916
  return json(res, 429, { error: "Two MCP connection tests are already running." });
24000
23917
  }
24001
23918
  const controller = new AbortController();
@@ -24006,11 +23923,9 @@ const handleRequest = async (req, res) => {
24006
23923
  res.once("close", disconnect);
24007
23924
  mcpProbesInFlight += 1;
24008
23925
  try {
24009
- // `?connect=1`: the person's own sign-in through a bridge's browser
24010
- // page (Mochii, Nexus). It waits for them, not for a machine.
24011
- const connecting = url.searchParams.get("connect") === "1";
24012
- // A person's click: the one start besides Reconnect that may open a browser.
24013
- const probed = await probeMcpServer(bridgedMcpServer(parsed.server, true), connecting ? MCP_SIGN_IN_TIMEOUT_MS : undefined, controller.signal);
23926
+ // Test may open a browser even without ?connect=1. Keep its callback
23927
+ // listener alive for the same MFA window as Reconnect.
23928
+ const probed = await interactiveMcpProbe(bridgedMcpServer(parsed.server, true), controller.signal);
24014
23929
  if (probed.ok) {
24015
23930
  mcpAuthState.noteOk(mcpTest[1]);
24016
23931
  mcpHealth.noteOk(mcpTest[1]);
@@ -24049,7 +23964,7 @@ const handleRequest = async (req, res) => {
24049
23964
  const reconnectBlocked = mcpLaunchBlocker(parsed.server);
24050
23965
  if (reconnectBlocked)
24051
23966
  return json(res, 409, { error: `${name} cannot start yet: ${mcpLaunchBlockerReason(reconnectBlocked)}`, code: reconnectBlocked });
24052
- if (mcpProbesInFlight >= MAX_CONCURRENT_MCP_PROBES) {
23967
+ if (mcpProbesInFlight >= MAX_CONCURRENT_MCP_PROBES && !interactiveMcpProbe.has(bridgedMcpServer(parsed.server, true))) {
24053
23968
  return json(res, 429, { error: "Two MCP connection tests are already running." });
24054
23969
  }
24055
23970
  const controller = new AbortController();
@@ -24060,7 +23975,7 @@ const handleRequest = async (req, res) => {
24060
23975
  res.once("close", disconnect);
24061
23976
  mcpProbesInFlight += 1;
24062
23977
  try {
24063
- const probed = await probeMcpServer(bridgedMcpServer(parsed.server, true), MCP_SIGN_IN_TIMEOUT_MS, controller.signal);
23978
+ const probed = await interactiveMcpProbe(bridgedMcpServer(parsed.server, true), controller.signal);
24064
23979
  if (probed.ok) {
24065
23980
  mcpAuthState.noteOk(name);
24066
23981
  mcpHealth.noteOk(name);
@@ -25120,6 +25035,8 @@ const handleRequest = async (req, res) => {
25120
25035
  }
25121
25036
  return json(res, 405, { error: "method not allowed" });
25122
25037
  }
25038
+ if (await remoteDesktopRoute(path, method, req, res, { store, surface: computerPreviewSurface, controlHeld: (id) => botComputerControlSnapshot(id).held }))
25039
+ return;
25123
25040
  m = path.match(/^\/api\/bots\/([\w-]+)\/computer\/viewer-close$/);
25124
25041
  if (m && method === "POST") {
25125
25042
  const bot = computerPreviewBot(m[1], url);
@@ -0,0 +1,60 @@
1
+ // One browser-allowed probe per resolved connection. Bot MCP sessions remain
2
+ // independent: sharing those would misroute server-initiated requests.
3
+ import { createHash } from "node:crypto";
4
+ import { probeMcpServer } from "./mcp-probe.js";
5
+ import { MCP_SIGN_IN_TIMEOUT_MS } from "./mcp-remote-bridge.js";
6
+ const cancelled = () => ({ ok: false, error: "Connection test was cancelled." });
7
+ function connectionKey(spec) {
8
+ // Include every resolved argument and credential value, never just the
9
+ // display name/URL. Only a digest is retained as the map key, never logged.
10
+ return createHash("sha256").update(JSON.stringify(spec, (_key, value) => {
11
+ if (value && typeof value === "object" && !Array.isArray(value)) {
12
+ return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)));
13
+ }
14
+ return value;
15
+ })).digest("hex");
16
+ }
17
+ export function createInteractiveMcpProbe(probe = probeMcpServer) {
18
+ const pending = new Map();
19
+ const run = (spec, signal) => {
20
+ if (signal?.aborted)
21
+ return Promise.resolve(cancelled());
22
+ const key = connectionKey(spec);
23
+ let entry = pending.get(key);
24
+ if (!entry) {
25
+ const controller = new AbortController();
26
+ entry = { controller, result: Promise.resolve().then(() => probe(spec, MCP_SIGN_IN_TIMEOUT_MS, controller.signal)), callers: 0 };
27
+ pending.set(key, entry);
28
+ const current = entry;
29
+ const clear = () => { if (pending.get(key) === current)
30
+ pending.delete(key); };
31
+ void current.result.then(clear, clear);
32
+ }
33
+ const current = entry;
34
+ current.callers += 1;
35
+ return new Promise((resolve, reject) => {
36
+ let settled = false;
37
+ const release = () => {
38
+ if (settled)
39
+ return false;
40
+ settled = true;
41
+ signal?.removeEventListener("abort", abort);
42
+ current.callers -= 1;
43
+ if (!current.callers && pending.get(key) === current) {
44
+ pending.delete(key);
45
+ current.controller.abort();
46
+ }
47
+ return true;
48
+ };
49
+ const abort = () => { if (release())
50
+ resolve(cancelled()); };
51
+ signal?.addEventListener("abort", abort, { once: true });
52
+ void current.result.then((result) => { if (release())
53
+ resolve(result); }, (error) => { if (release())
54
+ reject(error); });
55
+ if (signal?.aborted)
56
+ abort();
57
+ });
58
+ };
59
+ return Object.assign(run, { has: (spec) => pending.has(connectionKey(spec)) });
60
+ }
@@ -19,6 +19,7 @@ import { basename, dirname } from "node:path";
19
19
  /** The bundled bridge's exit code when it needed a browser sign-in and was
20
20
  * told not to open one (EX_NOPERM). */
21
21
  export const MCP_SIGN_IN_REQUIRED_EXIT = 77;
22
+ export const MCP_SIGN_IN_TIMEOUT_MS = 300_000;
22
23
  /** Set on a bridge's environment: never open a browser, exit instead. */
23
24
  export const MCP_NO_BROWSER_ENV = "ATOMIC_MCP_NO_BROWSER";
24
25
  /** The line the bundled bridge writes to stderr before that exit. */
@@ -63,6 +64,13 @@ export function bridgeMcpRemote(spec, options) {
63
64
  const target = mcpRemoteTarget(spec);
64
65
  if (!target)
65
66
  return spec;
67
+ // A stable redirect host prevents saved OAuth clients becoming invalid when
68
+ // an upstream/platform default changes. Preserve an explicitly configured host.
69
+ const extraArgs = target.extraArgs.includes("--host")
70
+ ? [...target.extraArgs]
71
+ : [...target.extraArgs, "--host", "localhost"];
72
+ if (!extraArgs.includes("--auth-timeout"))
73
+ extraArgs.push("--auth-timeout", String(MCP_SIGN_IN_TIMEOUT_MS / 1_000));
66
74
  const noBrowser = options.allowBrowser ? {} : { [MCP_NO_BROWSER_ENV]: "1" };
67
75
  const env = { ...spec.env };
68
76
  // A stale flag from an older start must not outlive a person's click.
@@ -71,16 +79,15 @@ export function bridgeMcpRemote(spec, options) {
71
79
  // A bundled entry keeps its own script: the desktop app already repoints
72
80
  // it at the current Resources copy on launch (electron/chibitek-defaults.mjs).
73
81
  if (target.bundled)
74
- return { ...spec, env: { ...env, ...noBrowser } };
75
- // Without a built bridge the npx form stays as configured: it may still
76
- // open a browser, which is what this release did before. Never silently
77
- // drop a server the person added.
82
+ return { ...spec, args: [spec.args[0], target.url, ...extraArgs], env: { ...env, ...noBrowser } };
83
+ // Keep the redirect stable even in a dev checkout without a built bridge.
84
+ // The unbundled command retains its existing browser behavior.
78
85
  if (!options.bridge)
79
- return spec;
86
+ return { ...spec, args: [...spec.args.slice(0, spec.args.length - target.extraArgs.length), ...extraArgs] };
80
87
  return {
81
88
  ...spec,
82
89
  command: options.execPath,
83
- args: [options.bridge, target.url, ...target.extraArgs],
90
+ args: [options.bridge, target.url, ...extraArgs],
84
91
  env: { ...env, ELECTRON_RUN_AS_NODE: "1", ...noBrowser },
85
92
  };
86
93
  }