@chibitek/chibi 0.1.168 → 0.1.170

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 (90) hide show
  1. package/dist/assets/{abnfDiagram-O67JEVCF-DUyzT6Xq.js → abnfDiagram-O67JEVCF-Dwjw0xC0.js} +1 -1
  2. package/dist/assets/{arc-B4-kO642.js → arc-B__BIHzb.js} +1 -1
  3. package/dist/assets/{architectureDiagram-NJMV4G6O-DgKdnNL2.js → architectureDiagram-NJMV4G6O-DQy7n3Ga.js} +1 -1
  4. package/dist/assets/{blockDiagram-BEXU5L5S-B6oV3kDr.js → blockDiagram-BEXU5L5S-moFo2nSA.js} +1 -1
  5. package/dist/assets/{c4Diagram-YGBWAQC7-Dp0E43jx.js → c4Diagram-YGBWAQC7-C-dF8UVl.js} +1 -1
  6. package/dist/assets/channel-BQM13l0i.js +1 -0
  7. package/dist/assets/{chunk-2Q5K7J3B-Cfv1WVUo.js → chunk-2Q5K7J3B-DnF0mxAV.js} +1 -1
  8. package/dist/assets/{chunk-3FUC2YCW-_-STY2yO.js → chunk-3FUC2YCW-Dp8Dsifv.js} +2 -2
  9. package/dist/assets/{chunk-5VM5RSS4-C6MZm1cm.js → chunk-5VM5RSS4-D8fmZ4d8.js} +1 -1
  10. package/dist/assets/{chunk-GTNCS2PH-C__aVIJy.js → chunk-GTNCS2PH-DNH2HZNQ.js} +1 -1
  11. package/dist/assets/{chunk-GWA4HPMP-fSrARJuB.js → chunk-GWA4HPMP-BvL6j7pQ.js} +1 -1
  12. package/dist/assets/{chunk-JWPE2WC7-CJioJB6h.js → chunk-JWPE2WC7-Co0gUA5M.js} +1 -1
  13. package/dist/assets/{chunk-NETBCI7D-o6MBGCq_.js → chunk-NETBCI7D-Deu9tpCF.js} +1 -1
  14. package/dist/assets/{chunk-WEXAMYUT-O-TFLbdc.js → chunk-WEXAMYUT-DKy8jAU5.js} +1 -1
  15. package/dist/assets/{chunk-XXDRQBXY-DYS9CQbl.js → chunk-XXDRQBXY-DQhvP8zg.js} +1 -1
  16. package/dist/assets/{classDiagram-v2-NBCMYWYE-BC4bSH09.js → classDiagram-v2-NBCMYWYE-mhv3hMMV.js} +1 -1
  17. package/dist/assets/{cose-bilkent-JH36ORCC-7NKuC3-C.js → cose-bilkent-JH36ORCC-CR1nOQOe.js} +1 -1
  18. package/dist/assets/{cynefin-OW5HDTMX-CuWv5cOw.js → cynefin-OW5HDTMX-Ddgkhxgt.js} +1 -1
  19. package/dist/assets/{cynefinDiagram-VND7K2PF-D_l_JrG5.js → cynefinDiagram-VND7K2PF-4KbkrNhB.js} +1 -1
  20. package/dist/assets/{dagre-6A5THRUB-D7eucSmr.js → dagre-6A5THRUB-DNxtjbvT.js} +1 -1
  21. package/dist/assets/{diagram-22UHCM2B-CADR5v1e.js → diagram-22UHCM2B-BRpvnOWX.js} +1 -1
  22. package/dist/assets/{diagram-3UASUU5V-DF_fBOZn.js → diagram-3UASUU5V-DsQVnKUb.js} +1 -1
  23. package/dist/assets/{diagram-ATOU4E4O-DE5p59WH.js → diagram-ATOU4E4O-BRZ5455_.js} +1 -1
  24. package/dist/assets/{diagram-CDSNMT55-vthvYooA.js → diagram-CDSNMT55-CidrDKp0.js} +1 -1
  25. package/dist/assets/{diagram-MLGK6HIB-ByMHy4S-.js → diagram-MLGK6HIB-1hkZ8SsK.js} +1 -1
  26. package/dist/assets/{diagram-MPIPVDR6-C9hKBUnO.js → diagram-MPIPVDR6-D1dWGFqn.js} +1 -1
  27. package/dist/assets/{ebnfDiagram-ZINNZB2B-Djv6hWEl.js → ebnfDiagram-ZINNZB2B-DGjfEPY5.js} +1 -1
  28. package/dist/assets/{elk-276RUBZZ-CmUjf18z.js → elk-276RUBZZ-C5nvtTy7.js} +1 -1
  29. package/dist/assets/{erDiagram-OPXOYQCR-DkmXQUok.js → erDiagram-OPXOYQCR-CC0QBE1A.js} +1 -1
  30. package/dist/assets/{flowDiagram-KWPJA3E3-6pz2poYn.js → flowDiagram-KWPJA3E3-BthbP_HO.js} +1 -1
  31. package/dist/assets/{ganttDiagram-FUAMR5RP-Cx9Fbw75.js → ganttDiagram-FUAMR5RP-BQ2wLSEI.js} +1 -1
  32. package/dist/assets/{gitGraphDiagram-X574FWY7-BR5MsYkP.js → gitGraphDiagram-X574FWY7-lTR8tNlq.js} +1 -1
  33. package/dist/assets/{index-BoF5cGZq.js → index-AWsWxgoq.js} +1 -1
  34. package/dist/assets/{index-Ckv52eaP.js → index-VagspC1z.js} +186 -186
  35. package/dist/assets/{infoDiagram-VRGFBTTK-DoF183Am.js → infoDiagram-VRGFBTTK-BgI9ZuT_.js} +1 -1
  36. package/dist/assets/{ishikawaDiagram-OU5B5YK6-4T_-hM1-.js → ishikawaDiagram-OU5B5YK6-qPe6Rhk2.js} +1 -1
  37. package/dist/assets/{journeyDiagram-ZHPQQLJL-Ca0T3Ngd.js → journeyDiagram-ZHPQQLJL-CpbadZuP.js} +1 -1
  38. package/dist/assets/{kanban-definition-PNTS6WVX-DNp_DAr9.js → kanban-definition-PNTS6WVX-D9kCb3-t.js} +1 -1
  39. package/dist/assets/{linear-COUoS2bF.js → linear-CdW4LT3g.js} +1 -1
  40. package/dist/assets/{mermaid.core-DQHowMIV.js → mermaid.core-BbPWTT56.js} +5 -5
  41. package/dist/assets/{mindmap-definition-NLK3R4M7-CMgO1Mkh.js → mindmap-definition-NLK3R4M7-BtOaXxvb.js} +1 -1
  42. package/dist/assets/{pegDiagram-GJSIUBJH-BCVCZbsq.js → pegDiagram-GJSIUBJH-BtcTITAK.js} +1 -1
  43. package/dist/assets/{pieDiagram-5QR66LMP-DxaMlX62.js → pieDiagram-5QR66LMP-Ch_--zjt.js} +1 -1
  44. package/dist/assets/{quadrantDiagram-O4NWA36T-qUD0pkur.js → quadrantDiagram-O4NWA36T-Di8ygRal.js} +1 -1
  45. package/dist/assets/{railroadDiagram-XR7U4H2S-Dc6rcAJB.js → railroadDiagram-XR7U4H2S-DxgesAmb.js} +1 -1
  46. package/dist/assets/{requirementDiagram-PLB6GJNP-DMTfI_lR.js → requirementDiagram-PLB6GJNP-DjH-jdCr.js} +1 -1
  47. package/dist/assets/{sankeyDiagram-IPEJSGJF-B0BWZys8.js → sankeyDiagram-IPEJSGJF-ClNLceSc.js} +1 -1
  48. package/dist/assets/{sequenceDiagram-PO4LG4MO-DZVt17mL.js → sequenceDiagram-PO4LG4MO-Cip_EmtV.js} +1 -1
  49. package/dist/assets/{sizeCapture-INFHLROL-BFQztpNW.js → sizeCapture-INFHLROL-BzW2hkq8.js} +1 -1
  50. package/dist/assets/{stateDiagram-v2-GCMORJYK-GeFCVCDr.js → stateDiagram-v2-GCMORJYK-CwHu7P19.js} +1 -1
  51. package/dist/assets/{swimlanes-2SLR337P-DTqQXTvP.js → swimlanes-2SLR337P-DO9E9HUK.js} +1 -1
  52. package/dist/assets/swimlanesDiagram-TC7HE7FX-UC0GNJzN.js +8 -0
  53. package/dist/assets/{timeline-definition-EJHVYXUP-BoiNrkP7.js → timeline-definition-EJHVYXUP-D2_E-i9h.js} +1 -1
  54. package/dist/assets/{usecaseDiagram-POWQR4AR-CdfsSO2g.js → usecaseDiagram-POWQR4AR-oKBQ1FAG.js} +1 -1
  55. package/dist/assets/{vennDiagram-UO4OBE2U-CHc_Fn1O.js → vennDiagram-UO4OBE2U-HEdQsC7L.js} +1 -1
  56. package/dist/assets/{wardleyDiagram-VNRHLVJA-Bv3KWVEF.js → wardleyDiagram-VNRHLVJA-mNpRO8rd.js} +1 -1
  57. package/dist/assets/{xychartDiagram-PMCCYNJV-CQFknLPS.js → xychartDiagram-PMCCYNJV-uURjTK1X.js} +1 -1
  58. package/dist/handbook/atomic-handbook.html +1 -1
  59. package/dist/index.html +1 -1
  60. package/dist-server/container-mcp.js +1 -0
  61. package/dist-server/hooks/plan-hook.js +22 -0
  62. package/dist-server/index.js +1277 -931
  63. package/dist-server/local-computer.js +1 -0
  64. package/dist-server/openmausbot.js +384 -298
  65. package/dist-server/pair-cli.js +384 -298
  66. package/dist-server/proxy-paths.js +1 -0
  67. package/dist-server/server/atomic-engine-release.js +13 -13
  68. package/dist-server/server/bot-work-modes.js +212 -0
  69. package/dist-server/server/config.js +6 -5
  70. package/dist-server/server/drivers/acp/core.js +6 -4
  71. package/dist-server/server/drivers/acp/custom.js +51 -37
  72. package/dist-server/server/drivers/builtIn.js +2 -1
  73. package/dist-server/server/drivers/chat-mcp-tools.js +1 -0
  74. package/dist-server/server/drivers/claude.js +28 -25
  75. package/dist-server/server/drivers/codex-approval-presets.js +34 -0
  76. package/dist-server/server/drivers/codex.js +11 -35
  77. package/dist-server/server/drivers/openai-chat.js +4 -3
  78. package/dist-server/server/hooks/plan-hook.js +17 -0
  79. package/dist-server/server/index.js +73 -57
  80. package/dist-server/server/mcp-interactive-probe.js +60 -0
  81. package/dist-server/server/mcp-remote-bridge.js +13 -6
  82. package/dist-server/server/plan-policy.js +14 -0
  83. package/dist-server/server/proxy-paths.js +1 -0
  84. package/dist-server/server/store.js +4 -4
  85. package/dist-server/shared/approval-mode.js +3 -1
  86. package/dist-server/shared/engine-choices.js +1 -1
  87. package/dist-server/vps-container-mcp.js +1 -0
  88. package/package.json +1 -1
  89. package/dist/assets/channel-BidDehbw.js +0 -1
  90. package/dist/assets/swimlanesDiagram-TC7HE7FX-CRQqFEdH.js +0 -8
@@ -1,3 +1,4 @@
1
+ import { namedApprovalParams } from "./codex-approval-presets.js";
1
2
  import { codexToolSurfaceArgs } from "./codex-tool-surface.js";
2
3
  import { codexImage } from "./codex-image.js";
3
4
  // Official Codex app-server over newline-delimited JSON-RPC on stdio.
@@ -183,40 +184,6 @@ function additionalPermissionSummary(permissions, reason) {
183
184
  const plainRecord = (value) => value && typeof value === "object" && !Array.isArray(value)
184
185
  ? value
185
186
  : null;
186
- /** Codex persists these values on its native thread. Keep them explicit on
187
- * start, resume, and every turn so switching modes cannot leave a more
188
- * permissive sandbox/reviewer stuck to the next request. */
189
- /** Ask and Edits both run Codex's workspace-write sandbox with the person as
190
- * reviewer: Codex has no narrower "edits only" mode, so the selector never
191
- * offers Edits for it (supportsApprovalMode) and a stray value asks. */
192
- function namedApprovalParams(mode) {
193
- if (mode === "full") {
194
- return {
195
- thread: {
196
- approvalPolicy: "never",
197
- approvalsReviewer: "user",
198
- sandbox: "danger-full-access",
199
- },
200
- turn: {
201
- approvalPolicy: "never",
202
- approvalsReviewer: "user",
203
- sandboxPolicy: { type: "dangerFullAccess" },
204
- },
205
- };
206
- }
207
- return {
208
- thread: {
209
- approvalPolicy: "on-request",
210
- approvalsReviewer: mode === "auto" ? "auto_review" : "user",
211
- sandbox: "workspace-write",
212
- },
213
- turn: {
214
- approvalPolicy: "on-request",
215
- approvalsReviewer: mode === "auto" ? "auto_review" : "user",
216
- sandboxPolicy: { type: "workspaceWrite" },
217
- },
218
- };
219
- }
220
187
  /** Linked repos besides the working folder become extra writable roots of
221
188
  * Codex's workspace-write sandbox (the app-server's equivalent of the CLI's
222
189
  * --add-dir). Full access already reaches them; read-only and a named
@@ -559,6 +526,9 @@ export const CodexDriver = {
559
526
  // instance's legacy fullAuto setting. Harness turns always send an
560
527
  // explicit mode, which takes precedence.
561
528
  const approvalMode = turn.approvalMode ?? (config.fullAuto ? "full" : "ask");
529
+ // Plan has no MCP/harness tools, even when a user opted into native MCP.
530
+ if (approvalMode === "plan")
531
+ turn = { ...turn, integrations: undefined, mcpFromUserConfig: false };
562
532
  // The person's servers this turn mounts. One that would clobber a
563
533
  // harness-owned variable is left out, not fatal: the rest still mount.
564
534
  const customServers = {};
@@ -627,7 +597,7 @@ export const CodexDriver = {
627
597
  // The harness's own mounts above are never switched off by this.
628
598
  if (!turn.mcpFromUserConfig) {
629
599
  const harnessMounts = new Set(["openmausbot_connectors", "atomic_accounts", "agents", "computer", "browser", "openmausbot_phone"]);
630
- appServerArgs.push(...codexUserMcpOffArgs(new Map([...declaredInCodexConfig].filter(([name]) => !harnessMounts.has(name)))));
600
+ appServerArgs.push(...codexUserMcpOffArgs(new Map([...declaredInCodexConfig].filter(([name]) => approvalMode === "plan" || !harnessMounts.has(name)))));
631
601
  // Same rule for the person's plugins (a `git ls-remote` per Git
632
602
  // marketplace at every start) and personal skills (listed in the
633
603
  // model's context on every call). Sign-in is untouched.
@@ -656,6 +626,8 @@ export const CodexDriver = {
656
626
  const prefix = "mcp_servers.openmausbot_phone";
657
627
  appServerArgs.push("-c", `${prefix}.command=${JSON.stringify(bridge.command)}`, "-c", `${prefix}.args=${JSON.stringify(bridge.args)}`, "-c", `${prefix}.env_vars=${JSON.stringify(Object.keys(bridge.env))}`, "-c", `${prefix}.default_tools_approval_mode="auto"`);
658
628
  }
629
+ if (approvalMode === "plan")
630
+ appServerArgs.push("-c", 'web_search="disabled"', "-c", "features.apps=false", "-c", "features.multi_agent=false");
659
631
  const child = spawnCli(config.cli, appServerArgs, {
660
632
  cwd: childCwd,
661
633
  env,
@@ -925,6 +897,10 @@ export const CodexDriver = {
925
897
  : isAdditionalPermission
926
898
  ? { permissions: allow ? grantedPermissions(params.permissions) : {}, scope: "turn" }
927
899
  : { decision: allow ? (legacy ? "approved" : "accept") : legacy ? "denied" : "decline" };
900
+ if (approvalMode === "plan" && isPermission) {
901
+ send({ jsonrpc: "2.0", id: msg.id, result: permissionResult(false) });
902
+ return;
903
+ }
928
904
  if (autoAcceptPermissions && isPermission && !serverConfirm) {
929
905
  return send({
930
906
  jsonrpc: "2.0",
@@ -1,3 +1,4 @@
1
+ import { PLAN_REFUSAL } from "../plan-policy.js";
1
2
  import { newEventId, newId } from "../contracts.js";
2
3
  import { redactSecretsInText } from "../redact.js";
3
4
  import { toolDetailPreview } from "../tool-summary.js";
@@ -423,8 +424,8 @@ export function createOpenAIChatRuntime(options) {
423
424
  // so it is honoured here: without it every single tool call on
424
425
  // an OpenAI-compatible engine stops for a card, and a Chief's
425
426
  // delegated Full access cannot help either.
426
- const allowed = turn.approvalMode === "full"
427
- || await approval.ask(call.function.name, inputPreview ?? "This tool has no arguments.", tools.customTool?.(call.function.name));
427
+ const allowed = (turn.approvalMode !== "plan" || tools.planReadOnly?.(call.function.name) === true) && (turn.approvalMode === "full"
428
+ || await approval.ask(call.function.name, inputPreview ?? "This tool has no arguments.", tools.customTool?.(call.function.name)));
428
429
  abort.signal.throwIfAborted();
429
430
  emit({ ...base(turn.threadId, turnId), type: "item.started", itemType: "tool", itemId: call.id,
430
431
  title: call.function.name, ...(inputPreview ? { input: inputPreview } : {}),
@@ -444,7 +445,7 @@ export function createOpenAIChatRuntime(options) {
444
445
  }
445
446
  else {
446
447
  denials.push(call.function.name);
447
- result = { ok: false, text: "Permission denied or expired; the tool was not executed." };
448
+ result = { ok: false, text: turn.approvalMode === "plan" && tools.planReadOnly?.(call.function.name) !== true ? PLAN_REFUSAL : "Permission denied or expired; the tool was not executed." };
448
449
  }
449
450
  }
450
451
  catch (error) {
@@ -0,0 +1,17 @@
1
+ // Atomic Engineering, 2026-10-31. Claude PreToolUse Plan guard.
2
+ import { readFileSync } from "node:fs";
3
+ import { planToolAllowed, PLAN_REFUSAL } from "../plan-policy.js";
4
+ try {
5
+ const event = JSON.parse(readFileSync(0, "utf8"));
6
+ if (event.hook_event_name === "PreToolUse" && planToolAllowed(event.tool_name)) {
7
+ // No permission override: reads still obey Claude's normal access checks.
8
+ }
9
+ else {
10
+ process.stderr.write(PLAN_REFUSAL);
11
+ process.exitCode = 2;
12
+ }
13
+ }
14
+ catch {
15
+ process.stderr.write(PLAN_REFUSAL);
16
+ process.exitCode = 2;
17
+ }
@@ -34,7 +34,7 @@ import { CLOUD_COMPUTER_BUSY_ERROR } from "../shared/computer-contention.js";
34
34
  import { approvalModeFor, supportsApprovalMode, modelSwitchNeedsAsk, isEmergencyApprovalDowngrade, isApprovalMode, } from "../shared/approval-mode.js";
35
35
  import { escapeAttribute } from "../src/lib/composer-attachments.js";
36
36
  import { credentialResumeOutcome, credentialIsConfigured, isCredentialTargetId, } from "../shared/credential-request.js";
37
- import { approvalHeldNote, approvalHeldReason, approvalModeForOrigin, autoVerdict, deliverFullAccessApproval, delegationInheritsFullAccess } from "./auto-approve.js";
37
+ import { approvalHeldNote, approvalHeldReason, autoVerdict, deliverFullAccessApproval, delegationInheritsFullAccess } from "./auto-approve.js";
38
38
  import { updateClaudeCli } from "./claude-update.js";
39
39
  import { failedRunDetail } from "./turn-failure.js";
40
40
  import { configuredAccountDirectory, assertSeparateClaudeAccount, claudeAccountInfo, createClaudeAccountSchema, instanceSettingsSchema, newClaudeAccount } from "./claude-accounts.js";
@@ -99,6 +99,7 @@ import { decodeGeneratedImage } from "./generated-image.js";
99
99
  import { MAX_MCP_SERVERS, listMcpServers, mcpServerNameError, parseMcpServerMutation, parseMcpServersImport, parseStoredMcpServer, isRemoteMcpServer, mcpReadOnlyAutoAllow, } from "./mcp-registry.js";
100
100
  import { mcpLaunchBlocker, mcpLaunchBlockerReason, mcpSecretStorage, mcpServerSecretView, moveParkedArgToEnv, receiveMcpSecretsSaveResult, setMcpSecretSender, storeMcpServerSecrets, } from "./mcp-secrets.js";
101
101
  import { probeMcpServer } from "./mcp-probe.js";
102
+ import { createInteractiveMcpProbe } from "./mcp-interactive-probe.js";
102
103
  import { McpToolHintStore, readOnlyToolAllowed } from "./mcp-tool-hints.js";
103
104
  import { McpAuthStateStore } from "./mcp-auth-state.js";
104
105
  import { McpHealthStore } from "./mcp-health.js";
@@ -251,6 +252,7 @@ import { PHONE_SECRET_PROTOCOL_VERSION, PhoneSecretBridge, PhoneSecretError, Pho
251
252
  // Keep these two last: a route module may import any server module, and
252
253
  // loading the table after everything above leaves module start-up order as is.
253
254
  import { json, onJsonBody, parsedBodyOf, readBody } from "./harness/http.js";
255
+ import { BotWorkModes, createBotWorkModeRoutes, ownBotApprovalMode, legacyGodModeCovers, dispatchWorkMode, trustedPlanChangeForStore, invalidAllThreadScope } from "./bot-work-modes.js";
254
256
  import { ROUTES, dispatchRoutes } from "./routes/table.js";
255
257
  import { createThreadStopRoutes } from "./routes/thread-stop.js";
256
258
  import { createThreadImageRoutes } from "./routes/thread-images.js";
@@ -4171,8 +4173,8 @@ function checkedTaskModelSwitch(current, raw, updateBotDefault, resetApprovalToA
4171
4173
  }
4172
4174
  for (const target of updateBotDefault ? [current, profile] : [current]) {
4173
4175
  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" };
4176
+ if (resetApprovalToAsk && (mode === "plan" || (mode === "custom" && !trusted))) {
4177
+ return { ok: false, status: 403, error: "Approve the plan or change Custom permissions in the app before switching models" };
4176
4178
  }
4177
4179
  if (!resetApprovalToAsk && modelSwitchNeedsAsk(mode, registry.cliTarget(target.modelSelection.instanceId)?.driverKind, registry.cliTarget(checked.selection.instanceId)?.driverKind)) {
4178
4180
  return { ok: false, status: 400, error: "Confirm switching this model with Ask permissions first (resetApprovalToAsk)" };
@@ -4324,7 +4326,7 @@ const wireBot = (bot) => {
4324
4326
  const visible = approvalGrant && !approvalGrant.threadOnly
4325
4327
  ? { ...rest, approvalMode: "ask", autoApprove: false }
4326
4328
  : rest;
4327
- return { ...visible, waitingForTeammates: activeCoordinationForThread(bot.threadId) && !threadBusy(bot.id, bot.threadId),
4329
+ 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
4330
  screenLive: screenLiveForBot(bot.id), avatarUrl: visible.avatarUrl ?? null, ...(tasks ? { tasks: tasks.map(wireTask) } : {}) };
4329
4331
  };
4330
4332
  /** The correlated private response carries the requested value so Electron
@@ -4450,7 +4452,7 @@ async function botOverview(bot) {
4450
4452
  autoApprove: bot.autoApprove,
4451
4453
  approvalMode: approvalModeForTurn(bot),
4452
4454
  // GOD MODE skips the peer check for every bot, whatever its engine
4453
- approvePeerComms: bot.approvePeerComms && !godModeActive(),
4455
+ approvePeerComms: bot.approvePeerComms && !godModeCovers(bot),
4454
4456
  peers: bot.peers,
4455
4457
  browser: bot.browser,
4456
4458
  chiefOfStaff: bot.chiefOfStaff,
@@ -4489,36 +4491,15 @@ async function botOverview(bot) {
4489
4491
  recent,
4490
4492
  });
4491
4493
  }
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. */
4494
+ // Enforce engine permissions on every turn, including delegated work.
4495
+ const botWorkModes = new BotWorkModes(join(DATA_DIR, "bot-work-modes.json"));
4496
+ for (const id of botWorkModes.expired(id => sessions.isLive(id)))
4497
+ store.setAllThreadApprovalMode(id, "ask", true);
4500
4498
  const approvalModeForTurn = (bot, peerInitiated = false) => godModeElevates(bot) ? "full" : ownApprovalModeForTurn(bot, peerInitiated);
4501
4499
  /** 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. */
4500
+ const ownApprovalModeForTurn = (bot, peerInitiated = false) => ownBotApprovalMode(bot, registry.cliTarget(bot.modelSelection.instanceId)?.driverKind, peerInitiated, botWorkModes.permits(approvalModeFor(bot)));
4517
4501
  function godModeCovers(bot) {
4518
- if (!godModeActive())
4519
- return false;
4520
- const grant = bot.approvalGrant;
4521
- return !(grant && !(grant.threadOnly && grant.threadId !== bot.threadId));
4502
+ return legacyGodModeCovers(bot, godModeActive() && botWorkModes.permits("full"), botWorkModes.selected(bot.id) !== undefined);
4522
4503
  }
4523
4504
  /** GOD MODE runs this bot or thread at Full, whatever its own level says:
4524
4505
  * it is covered (godModeCovers) and its engine implements Full. */
@@ -4563,7 +4544,7 @@ function peerReviewRequired(bot, threadId) {
4563
4544
  /** "Ask me before contacting other bots" is skipped with Full access on the
4564
4545
  * source conversation, and for every bot while GOD MODE is on. */
4565
4546
  function peerApprovalWaived(botId, threadId) {
4566
- return godModeActive() || fullAccessForSource(botId, threadId);
4547
+ return fullAccessForSource(botId, threadId) || Boolean(store.projectBotForTask(botId, threadId) && godModeCovers(store.projectBotForTask(botId, threadId)));
4567
4548
  }
4568
4549
  /** Full access flows down a Chief of Staff's delegation. The person gave the
4569
4550
  * Chief Full access so its work runs without prompts; a teammate stopping
@@ -4577,7 +4558,7 @@ function peerApprovalWaived(botId, threadId) {
4577
4558
  function delegatedFullAccess(from, fromThreadId, target) {
4578
4559
  // Chibi's GOD MODE never reaches the Private Bot, and the Private Bot's
4579
4560
  // never reaches Chibi: each runs a linked handoff at its own level.
4580
- if (isPersonalBot(from) !== isPersonalBot(target))
4561
+ if (isPersonalBot(from) !== isPersonalBot(target) || approvalModeFor(target) === "plan")
4581
4562
  return false;
4582
4563
  return delegationInheritsFullAccess({
4583
4564
  senderIsChief: Boolean(from.chiefOfStaff),
@@ -4681,7 +4662,7 @@ function handleDesktopTrustedApprovalMessage(raw) {
4681
4662
  const grantBusy = (bot) => bot.approvalGrant?.threadOnly
4682
4663
  ? threadBusy(bot.id, bot.approvalGrant.threadId) : bot.busy ||
4683
4664
  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) &&
4665
+ const grantSupported = (bot, mode) => botWorkModes.permits(mode) && supportsApprovalMode(registry.cliTarget(grantTarget(bot)?.modelSelection.instanceId ?? "")?.driverKind, mode) &&
4685
4666
  (!bot.approvalGrant?.allThreads || store.tasks(bot.id).every(task => supportsApprovalMode(registry.cliTarget((task.modelSelection ?? bot.modelSelection).instanceId)?.driverKind, mode)));
4686
4667
  const clearGrant = (bot) => store.patchBot(bot.id, {
4687
4668
  ...(!bot.approvalGrant?.threadOnly ? { approvalMode: "ask", autoApprove: false } : {}),
@@ -4896,6 +4877,10 @@ function handleDesktopTrustedApprovalMessage(raw) {
4896
4877
  respond({ ok: false, error: "The approval mode is invalid" });
4897
4878
  return true;
4898
4879
  }
4880
+ if (!botWorkModes.permits(mode)) {
4881
+ respond({ ok: false, error: "This mode is disabled in Settings" });
4882
+ return true;
4883
+ }
4899
4884
  const existing = store.bot(botId);
4900
4885
  if (!existing) {
4901
4886
  respond({ ok: false, error: "No such bot" });
@@ -4903,15 +4888,16 @@ function handleDesktopTrustedApprovalMessage(raw) {
4903
4888
  }
4904
4889
  const currentMode = approvalModeFor(existing);
4905
4890
  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"))))) {
4891
+ if (!trustedPlanChangeForStore(store, botId, mode, message)) {
4892
+ respond({ ok: false, error: "Approve the plan in the app before leaving Plan" });
4893
+ return true;
4894
+ }
4895
+ if (invalidAllThreadScope(mode, message)) {
4910
4896
  respond({ ok: false, error: "Invalid all-threads approval scope" });
4911
4897
  return true;
4912
4898
  }
4913
4899
  if (message.allThreads === true && mode === "ask") {
4914
- const updated = store.setAllThreadApprovalMode(botId, "ask");
4900
+ const updated = store.setAllThreadApprovalMode(botId, "ask", message.approvePlan !== true);
4915
4901
  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
4902
  return true;
4917
4903
  }
@@ -6048,6 +6034,10 @@ function closeSessionStreams(sessionId) {
6048
6034
  }
6049
6035
  sessions.onSessionRevoked((sessionId) => {
6050
6036
  providerAuthSessions.revokeOwner(sessionId);
6037
+ for (const id of botWorkModes.revoke(sessionId)) {
6038
+ store.setAllThreadApprovalMode(id, "ask", true);
6039
+ void interruptBotWork(id);
6040
+ }
6051
6041
  closeSessionStreams(sessionId);
6052
6042
  });
6053
6043
  /** Every frame is numbered, and the last few hundred are kept, so a client
@@ -10817,7 +10807,7 @@ async function startTurn(botId, text, opts) {
10817
10807
  text: dispatchContext.turnText,
10818
10808
  refreshSystemPrompt: true,
10819
10809
  images: turnImages,
10820
- approvalMode: approvalModeForTurn(bot, commsDepth > 0),
10810
+ approvalMode: dispatchWorkMode(approvalModeForTurn(bot, commsDepth > 0), registry.cliTarget(instance.instanceId)?.driverKind),
10821
10811
  model,
10822
10812
  effort,
10823
10813
  variant,
@@ -12769,7 +12759,7 @@ failover) {
12769
12759
  text,
12770
12760
  refreshSystemPrompt: true,
12771
12761
  images: turnImages,
12772
- approvalMode: roomTurnApprovalMode(readyBot, orchestration),
12762
+ approvalMode: dispatchWorkMode(roomTurnApprovalMode(readyBot, orchestration), registry.cliTarget(instance.instanceId)?.driverKind),
12773
12763
  system: roomSystem.text,
12774
12764
  systemStable: roomSystem.stable,
12775
12765
  systemVolatile: roomSystem.volatile,
@@ -13610,8 +13600,9 @@ function handoffAuthorizations(from, sourceThreadId, raw) {
13610
13600
  conversationBotName: from.name,
13611
13601
  });
13612
13602
  }
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;
13603
+ /** Test and Reconnect share a single browser-allowed probe per connection.
13604
+ * Bot MCP sessions remain independent to preserve request routing. */
13605
+ const interactiveMcpProbe = createInteractiveMcpProbe();
13615
13606
  /** The interview's view of this computer (GET /api/bot-interview/context):
13616
13607
  * only tools that exist here, each with a plain status. The Personal Bot
13617
13608
  * sees only connections marked Personal; a company bot never sees those. */
@@ -16043,6 +16034,24 @@ ROUTES.push(createMcpMarketplaceRoutes({
16043
16034
  fromBot: (req) => authorizedInternalCapability(req.headers.authorization) !== null,
16044
16035
  sessionExpired: (auth) => auth.kind === "session" && !sessions.isLive(auth.session.id),
16045
16036
  }));
16037
+ ROUTES.push(createBotWorkModeRoutes({
16038
+ modes: botWorkModes, desktopManaged: DESKTOP_MANAGED, live: id => sessions.isLive(id),
16039
+ bot: id => {
16040
+ const bot = store.bot(id);
16041
+ if (!bot)
16042
+ return undefined;
16043
+ const targets = [bot, ...store.tasks(id).map(task => store.projectBotForTask(id, task.threadId)).filter(Boolean)];
16044
+ return { busy: targets.some(target => threadBusy(id, target.threadId)),
16045
+ revision: JSON.stringify(targets.map(target => [target.threadId, target.modelSelection, target.approvalMode, target.approvalGrant, store.messagesFor(target.threadId).at(-1)?.id])),
16046
+ drivers: targets.map(target => registry.cliTarget(target.modelSelection.instanceId)?.driverKind),
16047
+ current: targets.some(target => approvalModeFor(target) === "plan") ? "plan" : wireBot(bot).workMode ?? "ask" };
16048
+ },
16049
+ stop: interruptBotWork,
16050
+ apply: (id, mode, preservePlan) => { store.setAllThreadApprovalMode(id, mode, preservePlan); },
16051
+ 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),
16052
+ changed: id => { const bot = store.bot(id); if (bot)
16053
+ broadcast({ kind: "bot", bot: wireBot(bot) }); },
16054
+ }));
16046
16055
  // Settings → AI & accounts → Local models: Ollama's models, pulls and removals.
16047
16056
  ROUTES.push(createLocalModelRoutes({
16048
16057
  ollamaUrl: process.env.OMB_OLLAMA_URL || undefined,
@@ -21283,8 +21292,12 @@ const handleRequest = async (req, res) => {
21283
21292
  else {
21284
21293
  requestedApprovalMode = approvalModeFor(existingBot ?? {});
21285
21294
  }
21295
+ if (!botWorkModes.permits(requestedApprovalMode))
21296
+ return json(res, 403, { error: "This mode is disabled in Settings" });
21286
21297
  const currentApprovalMode = approvalModeFor(existingBot ?? {});
21287
21298
  const approvalChangeRequested = body.approvalMode !== undefined || body.autoApprove !== undefined;
21299
+ if (currentApprovalMode === "plan" && requestedApprovalMode !== "plan")
21300
+ return json(res, 403, { error: "Approve the plan in the app before leaving Plan" });
21288
21301
  if (existingBot?.busy && approvalChangeRequested && requestedApprovalMode !== currentApprovalMode) {
21289
21302
  return json(res, 409, {
21290
21303
  error: "stop this bot's turn before changing its approval level",
@@ -21299,13 +21312,13 @@ const handleRequest = async (req, res) => {
21299
21312
  const targetSelection = normalizedSelection ?? existingBot?.modelSelection;
21300
21313
  if (normalizedSelection && selectedTask) {
21301
21314
  const mode = approvalModeFor(selectedTask);
21302
- if ((mode === "full" || mode === "custom") &&
21315
+ if ((mode === "full" || mode === "custom" || mode === "plan") &&
21303
21316
  (!supportsApprovalMode(registry.cliTarget(normalizedSelection.instanceId)?.driverKind, mode) ||
21304
21317
  registry.cliTarget(normalizedSelection.instanceId)?.driverKind !== registry.cliTarget(selectedTask.modelSelection.instanceId)?.driverKind)) {
21305
21318
  return json(res, 400, { error: "Choose Ask for the selected thread before changing providers with elevated permissions" });
21306
21319
  }
21307
21320
  }
21308
- if ((requestedApprovalMode === "full" || requestedApprovalMode === "custom") &&
21321
+ if ((requestedApprovalMode === "full" || requestedApprovalMode === "custom" || requestedApprovalMode === "plan") &&
21309
21322
  (body.approvalMode !== undefined || normalizedSelection !== undefined) &&
21310
21323
  (!targetSelection || !supportsApprovalMode(registry.cliTarget(targetSelection.instanceId)?.driverKind, requestedApprovalMode) ||
21311
21324
  (existingBot && normalizedSelection && registry.cliTarget(normalizedSelection.instanceId)?.driverKind !== registry.cliTarget(existingBot.modelSelection.instanceId)?.driverKind))) {
@@ -23009,14 +23022,19 @@ const handleRequest = async (req, res) => {
23009
23022
  if (!checked.ok)
23010
23023
  return json(res, checked.status, { error: checked.error });
23011
23024
  patch.modelSelection = checked.selection;
23025
+ if (approvalModeFor(current) === "plan" && !supportsApprovalMode(registry.cliTarget(checked.selection.instanceId)?.driverKind, "plan"))
23026
+ return json(res, 400, { error: "Choose Ask before switching from Plan to this engine" });
23012
23027
  }
23013
23028
  if (body.approvalMode !== undefined || body.autoApprove !== undefined) {
23014
23029
  if (body.autoApprove !== undefined && typeof body.autoApprove !== "boolean")
23015
23030
  return json(res, 400, { error: "autoApprove must be a boolean" });
23016
23031
  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")
23032
+ if (!botWorkModes.permits(mode))
23033
+ return json(res, 403, { error: "This mode is disabled in Settings" });
23034
+ if (approvalModeFor(current) === "plan" && mode !== "plan")
23035
+ return json(res, 403, { error: "Approve the plan in the app before leaving Plan" });
23036
+ // A settings PATCH cannot manufacture an elevated grant.
23037
+ if (mode !== "ask" && mode !== "auto" && mode !== "edits" && mode !== "plan")
23020
23038
  return json(res, 403, { error: "Full and Custom access require trusted desktop confirmation" });
23021
23039
  if (approvalModeFor(current) === "custom")
23022
23040
  return json(res, 403, { error: "Leaving Custom approval requires confirmation in the packaged desktop app" });
@@ -23995,7 +24013,7 @@ const handleRequest = async (req, res) => {
23995
24013
  const testBlocked = mcpLaunchBlocker(parsed.server);
23996
24014
  if (testBlocked)
23997
24015
  return json(res, 409, { error: `${mcpTest[1]} cannot start yet: ${mcpLaunchBlockerReason(testBlocked)}`, code: testBlocked });
23998
- if (mcpProbesInFlight >= MAX_CONCURRENT_MCP_PROBES) {
24016
+ if (mcpProbesInFlight >= MAX_CONCURRENT_MCP_PROBES && !interactiveMcpProbe.has(bridgedMcpServer(parsed.server, true))) {
23999
24017
  return json(res, 429, { error: "Two MCP connection tests are already running." });
24000
24018
  }
24001
24019
  const controller = new AbortController();
@@ -24006,11 +24024,9 @@ const handleRequest = async (req, res) => {
24006
24024
  res.once("close", disconnect);
24007
24025
  mcpProbesInFlight += 1;
24008
24026
  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);
24027
+ // Test may open a browser even without ?connect=1. Keep its callback
24028
+ // listener alive for the same MFA window as Reconnect.
24029
+ const probed = await interactiveMcpProbe(bridgedMcpServer(parsed.server, true), controller.signal);
24014
24030
  if (probed.ok) {
24015
24031
  mcpAuthState.noteOk(mcpTest[1]);
24016
24032
  mcpHealth.noteOk(mcpTest[1]);
@@ -24049,7 +24065,7 @@ const handleRequest = async (req, res) => {
24049
24065
  const reconnectBlocked = mcpLaunchBlocker(parsed.server);
24050
24066
  if (reconnectBlocked)
24051
24067
  return json(res, 409, { error: `${name} cannot start yet: ${mcpLaunchBlockerReason(reconnectBlocked)}`, code: reconnectBlocked });
24052
- if (mcpProbesInFlight >= MAX_CONCURRENT_MCP_PROBES) {
24068
+ if (mcpProbesInFlight >= MAX_CONCURRENT_MCP_PROBES && !interactiveMcpProbe.has(bridgedMcpServer(parsed.server, true))) {
24053
24069
  return json(res, 429, { error: "Two MCP connection tests are already running." });
24054
24070
  }
24055
24071
  const controller = new AbortController();
@@ -24060,7 +24076,7 @@ const handleRequest = async (req, res) => {
24060
24076
  res.once("close", disconnect);
24061
24077
  mcpProbesInFlight += 1;
24062
24078
  try {
24063
- const probed = await probeMcpServer(bridgedMcpServer(parsed.server, true), MCP_SIGN_IN_TIMEOUT_MS, controller.signal);
24079
+ const probed = await interactiveMcpProbe(bridgedMcpServer(parsed.server, true), controller.signal);
24064
24080
  if (probed.ok) {
24065
24081
  mcpAuthState.noteOk(name);
24066
24082
  mcpHealth.noteOk(name);
@@ -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
  }
@@ -0,0 +1,14 @@
1
+ // Atomic Engineering, 2026-10-31. Harness-owned Plan tool policy.
2
+ // Unknown tools, MCP annotations and shell command strings are never trusted.
3
+ export const PLAN_READ_TOOLS = ["Read", "Glob", "Grep"];
4
+ export const PLAN_REFUSAL = "Plan mode blocked this action. Review the plan and approve it in Atomic before making changes.";
5
+ export function planToolAllowed(tool) {
6
+ return typeof tool === "string" && PLAN_READ_TOOLS.includes(tool);
7
+ }
8
+ export function claudePlanHook(node, helper) {
9
+ const quote = (s) => "'" + s.replace(/'/g, "'\\''") + "'";
10
+ const command = process.platform === "win32"
11
+ ? '"%OMB_PLAN_NODE%" "%OMB_PLAN_HOOK%" || exit /b 2'
12
+ : `${quote(node)} ${quote(helper)} || exit 2`;
13
+ return [{ matcher: "", hooks: [{ type: "command", command, timeout: 5 }] }];
14
+ }
@@ -44,6 +44,7 @@ export const SPAWNED_PROXIES = {
44
44
  hook: resolveProxy("hooks/omb-hook"),
45
45
  // the RTK add-on's Claude PreToolUse hook (bypassPermissions turns only)
46
46
  rtkHook: resolveProxy("hooks/rtk-hook"),
47
+ planHook: resolveProxy("hooks/plan-hook"),
47
48
  // Loaded by the external `pi` process via `-e`, not by this server — but
48
49
  // resolved through the same single source of truth so the packaged layout
49
50
  // check can assert it ships.
@@ -2618,16 +2618,16 @@ export class Store {
2618
2618
  return task;
2619
2619
  }
2620
2620
  /** One durable write: never leave only part of a bot's threads updated. */
2621
- setAllThreadApprovalMode(botId, mode) {
2621
+ setAllThreadApprovalMode(botId, mode, preservePlan = false) {
2622
2622
  const bot = this.bot(botId);
2623
2623
  if (!bot)
2624
2624
  return null;
2625
2625
  const patch = { approvalMode: mode, autoApprove: false, alwaysAllow: [] };
2626
- const tasks = (bot.tasks ?? []).map(task => ({ ...task, ...patch }));
2627
- const next = { ...bot, ...patch, approvalGrant: undefined, tasks };
2626
+ const tasks = (bot.tasks ?? []).map(task => preservePlan && approvalModeFor(this.projectBotForTask(botId, task.threadId)) === "plan" ? { ...task } : { ...task, ...patch });
2627
+ const next = { ...bot, ...(preservePlan && approvalModeFor(bot) === "plan" ? {} : patch), approvalGrant: undefined, tasks };
2628
2628
  this.saveBots(this.bots.map(candidate => candidate === bot ? next : candidate));
2629
2629
  bot.tasks?.forEach((task, index) => Object.assign(task, tasks[index]));
2630
- Object.assign(bot, patch, { approvalGrant: undefined });
2630
+ Object.assign(bot, { ...next, tasks: bot.tasks });
2631
2631
  this.emit({ type: "bot", botId });
2632
2632
  return bot;
2633
2633
  }
@@ -2,13 +2,15 @@
2
2
  * permission mode, passed through: OpenMausBot never decides a permission
3
3
  * itself (Full access aside, which answers residual prompts because that is
4
4
  * what the person granted). The order is the order the selector shows. */
5
- export const APPROVAL_MODES = ["ask", "edits", "auto", "full", "custom"];
5
+ export const APPROVAL_MODES = ["ask", "edits", "auto", "full", "custom", "plan"];
6
6
  /** Only providers with an implemented permission mapping may expose a level.
7
7
  * `edits` (auto-accept edits) exists where the engine has such a mode:
8
8
  * Claude and Grok `acceptEdits`, Antigravity `auto_edit`, Qwen `auto-edit`,
9
9
  * Gemini `auto_edit`, Mistral Vibe `accept-edits`. Codex's Ask already runs `workspace-write`, so an edits
10
10
  * level would change nothing there. */
11
11
  export function supportsApprovalMode(driverKind, mode) {
12
+ if (mode === "plan")
13
+ return ["atomicEngine", "codex", "claudeAgent", "grok", "openai-compat", "minimax", "mistral", "ollamaCloud"].includes(driverKind ?? "");
12
14
  if (mode === "custom")
13
15
  return driverKind === "codex";
14
16
  if (mode === "edits")
@@ -127,7 +127,7 @@ export function engineBrand(engine) {
127
127
  const name = engine.managed?.organizationName?.trim() || (generic(engine.displayName) ? "OpenAI-compatible endpoint" : engine.displayName.trim());
128
128
  return { key: `endpoint:${name.toLowerCase()}`, name, group: "flatPlans" };
129
129
  }
130
- if (engine.driverKind === "customAcp") {
130
+ if (["customAcp", "atomicEngine"].includes(engine.driverKind)) {
131
131
  if (engine.instanceId === ATOMIC_ENGINE_ID || /^atomic engine$/i.test(engine.displayName.trim()))
132
132
  return { key: "atomic-engine", name: "Atomic Engine", group: "atomic" };
133
133
  const name = generic(engine.displayName) ? "Custom agent" : engine.displayName.trim();