openmausbot 0.1.70 → 0.1.72

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 (82) hide show
  1. package/dist/assets/index-BFPYw6P-.js +296 -0
  2. package/dist/assets/{index-D1RGhLUK.js → index-Bpai86ua.js} +1 -1
  3. package/dist/assets/index-D1odUdTi.css +1 -0
  4. package/dist/index.html +2 -2
  5. package/dist-server/companion/src/listener.js +12 -2
  6. package/dist-server/container-mcp.js +28 -2
  7. package/dist-server/drivers/agents-proxy.js +202 -16
  8. package/dist-server/drivers/pi-mcp-extension.ts +1 -1
  9. package/dist-server/index.js +9443 -3023
  10. package/dist-server/local-computer.js +6 -1
  11. package/dist-server/mcp-gate.js +543 -0
  12. package/dist-server/mcp-server.js +38 -0
  13. package/dist-server/openmausbot.js +1893 -583
  14. package/dist-server/pair-cli.js +1893 -583
  15. package/dist-server/proxy-paths.js +1 -0
  16. package/dist-server/server/agent-tool-policy.js +29 -0
  17. package/dist-server/server/bot-overview.js +28 -0
  18. package/dist-server/server/bot-package.js +55 -2
  19. package/dist-server/server/browser-engine.js +2 -1
  20. package/dist-server/server/browser-live.js +12 -7
  21. package/dist-server/server/calendar-calls.js +1 -0
  22. package/dist-server/server/cli.js +102 -1
  23. package/dist-server/server/config.js +94 -6
  24. package/dist-server/server/drivers/acp/core.js +2 -0
  25. package/dist-server/server/drivers/acp/cursor.js +1 -1
  26. package/dist-server/server/drivers/acp/grok.js +5 -1
  27. package/dist-server/server/drivers/agents-proxy.js +195 -16
  28. package/dist-server/server/drivers/boxagent.js +1 -1
  29. package/dist-server/server/drivers/claude.js +301 -23
  30. package/dist-server/server/drivers/codex.js +13 -4
  31. package/dist-server/server/drivers/pi.js +1 -1
  32. package/dist-server/server/fleet-agent.js +161 -0
  33. package/dist-server/server/fleet-cli.js +225 -0
  34. package/dist-server/server/fleet-client.js +45 -0
  35. package/dist-server/server/fleet.js +410 -0
  36. package/dist-server/server/harness/bus.js +1 -1
  37. package/dist-server/server/index.js +836 -201
  38. package/dist-server/server/local-computer.js +5 -1
  39. package/dist-server/server/mcp-gate-config.js +56 -0
  40. package/dist-server/server/mcp-gate.js +227 -0
  41. package/dist-server/server/mcp-registry.js +71 -3
  42. package/dist-server/server/mcp-trim.js +182 -0
  43. package/dist-server/server/memory-journal.js +440 -0
  44. package/dist-server/server/memory-store.js +308 -0
  45. package/dist-server/server/message-db.js +90 -3
  46. package/dist-server/server/package-export.js +25 -0
  47. package/dist-server/server/peer-approval.js +1 -1
  48. package/dist-server/server/prices.js +16 -0
  49. package/dist-server/server/provider-auth-sessions.js +3 -0
  50. package/dist-server/server/provider-key-check.js +66 -0
  51. package/dist-server/server/proxy-paths.js +1 -0
  52. package/dist-server/server/redact.js +15 -0
  53. package/dist-server/server/resume-recovery.js +46 -0
  54. package/dist-server/server/routines.js +1 -0
  55. package/dist-server/server/screen-frame-gate.js +9 -6
  56. package/dist-server/server/skill-learn.js +15 -1
  57. package/dist-server/server/skill-library.js +5 -5
  58. package/dist-server/server/spend.js +61 -0
  59. package/dist-server/server/store.js +7 -3
  60. package/dist-server/server/system-prompt.js +28 -6
  61. package/dist-server/server/tailscale.js +2 -2
  62. package/dist-server/server/team-backup.js +35 -0
  63. package/dist-server/server/tool-summary.js +39 -0
  64. package/dist-server/server/tts/speech-text.js +3 -1
  65. package/dist-server/server/turn-context.js +18 -0
  66. package/dist-server/server/usage-ledger.js +239 -0
  67. package/dist-server/server/webhook-ingress.js +7 -2
  68. package/dist-server/server/workspace-backup-http.js +264 -0
  69. package/dist-server/server/workspace-backup-maintenance.js +40 -0
  70. package/dist-server/server/workspace-backup-policy.js +28 -0
  71. package/dist-server/server/workspace-backup.js +1022 -0
  72. package/dist-server/server/workspace.js +321 -22
  73. package/dist-server/shared/approval-mode.js +1 -1
  74. package/dist-server/shared/learn-request.js +6 -0
  75. package/dist-server/shared/team-backup.js +13 -2
  76. package/dist-server/shared/workspace-backup-client.js +15 -0
  77. package/dist-server/shared/workspace-backup.js +1 -0
  78. package/dist-server/vps-container-mcp.js +28 -2
  79. package/package.json +1 -1
  80. package/skills/create-verification-skill/SKILL.md +2 -3
  81. package/dist/assets/index-B2F__IRS.css +0 -1
  82. package/dist/assets/index-CLj3t5Ba.js +0 -280
@@ -17,7 +17,7 @@ import { updateClaudeCli } from "./claude-update.js";
17
17
  import { configuredAccountDirectory, assertSeparateClaudeAccount, claudeAccountInfo, createClaudeAccountSchema, instanceSettingsSchema, newClaudeAccount } from "./claude-accounts.js";
18
18
  import { BrowserCleanupCoordinator, finalizeBrowserCleanupMutation, requireBrowserCleanupAcknowledged, } from "./browser-lifecycle-cleanup.js";
19
19
  import * as checkpoints from "./checkpoints.js";
20
- import { appendDecision, readDecisions } from "./decision-log.js";
20
+ import { appendDecision, readDecisions, flushDecisionLog } from "./decision-log.js";
21
21
  import { validateBotCwd } from "./bot-cwd.js";
22
22
  import { ATTACHMENTS_DIR, attachmentExists, cleanupStaleAttachmentPartials, deleteAttachment, extensionForMime, FILE_MAX_BYTES, IMAGE_MAX_BYTES, readAttachment, saveFile, saveImage, saveImageUpload, validateAttachmentUploadId, } from "./attachments.js";
23
23
  import { messageFileDisposition, messageFileDownloadName, messageAttachmentName, messageFileRoots, messageImageTargetAt, messageReferencesFile, openMessageFile, } from "./message-file.js";
@@ -33,22 +33,27 @@ import { chiefOfStaffSystemPrompt } from "./chief-of-staff.js";
33
33
  import { peerAllowed, peerName, peerRosterSystemPrompt, reachablePeers, roomPeerRosterSystemPrompt, roomRosterLine } from "./peer-roster.js";
34
34
  import { openMausStatusSystemPrompt } from "./openmaus-status-capsule.js";
35
35
  import { containerComputerAction, containerComputerExists, containerComputerFrame, containerComputerMcp, containerComputerScreenshot, containerComputerStatus, containerRuntimeStatus, localVmRecreatableOnDemand, perBotLocalVmTarget, SHARED_LOCAL_VM_TARGET, setupCommands, } from "./container-computer.js";
36
- import { ensureDirs, instanceConfigs, loadConfig, localVmMaxInstances, localVmMode, parseConfigPatch, roomTurnTimeoutMinutes, maxConcurrentBotThreads, saveConfig, showToolCallsEnabled, skillRecorderEnabled, builtInBrowserEnabled, browserProfileReplacementConflict, browserProfilePartitionTarget, syncCredentialEnv, withInstanceCli, persistableInstanceConfigs, vpsSshAlias, DATA_DIR, EVENTS_DIR, NATIVE_DIR, customMcpServers, } from "./config.js";
36
+ import { ensureDirs, instanceConfigs, loadConfig, localVmMaxInstances, localVmMode, parseConfigPatch, roomTurnTimeoutMinutes, maxConcurrentBotThreads, saveConfig, showToolCallsEnabled, skillAuthoringEnabled, builtInBrowserEnabled, browserProfileReplacementConflict, browserProfilePartitionTarget, syncCredentialEnv, withInstanceCli, persistableInstanceConfigs, vpsSshAlias, DATA_DIR, EVENTS_DIR, NATIVE_DIR, customMcpServers, } from "./config.js";
37
37
  import { ComputerControl } from "./computer-control.js";
38
38
  import { MAX_REMOTE_COMMAND_LENGTH } from "./remote-computer.js";
39
39
  import { augmentedPath, findCliCandidates, resetPathCache } from "./env-path.js";
40
40
  import { registerEnginesBinDir } from "./engine-install.js";
41
+ import { appendUsage, parseUsageRange, readUsage, summarizeUsage, usageCsv, USAGE_GROUPINGS, flushUsageLedger } from "./usage-ledger.js";
42
+ import { checkProviderKey, PROVIDER_KEY_KINDS } from "./provider-key-check.js";
43
+ import { assertWithinBudget, noteSpend, spendState } from "./spend.js";
44
+ import { fleetAvailable, fleetRequest, fleetSocketPath } from "./fleet-client.js";
45
+ import { entitled } from "./enterprise.js";
41
46
  import { describeSpawnFailure, execCli } from "./procs.js";
42
47
  import { blockedTarget, buildNotification } from "./notify.js";
43
48
  import { isEffortLevel, newId, } from "./contracts.js";
44
49
  import { RETRY_MAX_ATTEMPTS } from "./drivers/retry.js";
45
50
  import { decodeGeneratedImage } from "./generated-image.js";
46
- import { MAX_MCP_SERVERS, listMcpServers, parseMcpServerMutation, parseStoredMcpServer, } from "./mcp-registry.js";
51
+ import { MAX_MCP_SERVERS, listMcpServers, mcpServerNameError, parseMcpServerMutation, parseMcpServersImport, parseStoredMcpServer, } from "./mcp-registry.js";
47
52
  import { probeMcpServer } from "./mcp-probe.js";
48
53
  import { GROUP_GOAL_MAX_TURNS, groupGoalAssignmentKey, groupGoalCompletionTurnId, groupGoalCoordinatorInstructions, groupGoalWorkerInstructions, parseGroupGoalDecision, resolveGroupGoalMember, selectGroupGoalCoordinator, } from "./group-goal-run.js";
49
54
  import { BUILT_IN_DRIVERS } from "./drivers/builtIn.js";
50
55
  import { getOrCreateChannel, mirrorActivity, mirrorExchange, mirrorReply } from "./comms-visibility.js";
51
- import { readMessageText, recallMessages, searchMessages } from "./message-db.js";
56
+ import { readMessageText, recallMessages, searchMessages, closeMessageDb } from "./message-db.js";
52
57
  import { claimRecallCrossings, recallCrossingLabel } from "./recall-disclosure.js";
53
58
  /** A session_read answer competes with the transcript for the context
54
59
  * window; a computer-use turn's output can run to hundreds of KB. */
@@ -67,19 +72,21 @@ import { decideRoomPost, emptyRoomPostBudget } from "./room-post-budget.js";
67
72
  import { isProjectEmoji, mentionedBots, roomResponders, sectionKey, Store, } from "./store.js";
68
73
  import * as tts from "./tts/index.js";
69
74
  import { narrateTool, toUtterances } from "./tts/speech-text.js";
70
- import { buildTurnContext, engineIsFresh } from "./turn-context.js";
75
+ import { buildRecoveryText, buildTurnContext, engineIsFresh } from "./turn-context.js";
71
76
  import { extractTurnImages } from "./turn-images.js";
72
77
  import { TurnWatchdog } from "./turn-watchdog.js";
73
78
  import { TurnResources, workspaceResource } from "./turn-resources.js";
74
- import { ensureWorkspace, ensureTaskWorkspace, updateMemory, listMemoryTopics, isMemoryTopicName, memorySystemPrompt, SESSION_SEARCH_SYSTEM_PROMPT, workspaceDir, } from "./workspace.js";
75
- import { readMemoryFile, readMemoryTopic, writeMemoryFile, MEMORY_FILE_MAX_BYTES, } from "./workspace.js";
79
+ import { ensureWorkspace, ensureTaskWorkspace, updateMemory, appendMemoryLog, isMemoryTopicName, memorySystemPrompt, memorySourceLabel, searchMemoryFiles, SESSION_SEARCH_SYSTEM_PROMPT, workspaceDir, } from "./workspace.js";
80
+ import { readMemoryTopic } from "./workspace.js";
81
+ import { MEMORY_INDEX, MemoryStoreError, memoryCapacity, memoryOverview, openMemoryLocation, readMemoryDoc, } from "./memory-store.js";
82
+ import { beginMemoryTurn, endMemoryTurn, flushAllMemoryJournals, flushMemoryJournal, journalMemoryDelete, journalMemoryWrite, readMemoryJournal, revertMemoryChange, } from "./memory-journal.js";
76
83
  import { readSectionContext, sectionContextKey, sectionContextLabel, sectionContextSystemPrompt, writeSectionContext, SECTION_CONTEXT_MAX_BYTES, } from "./section-context.js";
77
- import { applyStagedSkillWrite, getStagedSkillWrite, installSkill, listSkills, listStagedSkillWrites, readSkillFile, rejectStagedSkillWrite, removeSkill, setSkillEnabled, skillsSystemPrompt, stageSkillWrite, } from "./skills.js";
84
+ import { applyStagedSkillWrite, getStagedSkillWrite, installSkill, listSkills, listStagedSkillWrites, isSkillName, readSkillFile, rejectStagedSkillWrite, removeSkill, setSkillEnabled, skillsSystemPrompt, stageSkillWrite, } from "./skills.js";
78
85
  import { fetchSkillFromSource } from "./skill-fetch.js";
79
86
  import { expandLearnTurnText, learnSource } from "./skill-learn.js";
80
87
  import { expandSetupTurnText, setupModeActive, setupSystemPrompt } from "./setup-mode.js";
81
88
  import { checkSoulDrift, readSoulDrift, soulFile, writeSoulMirror } from "./bot-folder.js";
82
- import { buildSystemPrompt, computerPrompt, mentionPrompt, COMPOSIO_PROMPT, CREDENTIAL_PROMPT, THREADS_PROMPT, LEARN_PROMPT, PROFILE_PROMPT, ROUTINE_PROMPT, ROUTINE_EXECUTION_PROMPT, WEBHOOK_PROMPT, } from "./system-prompt.js";
89
+ import { buildSystemPrompt, computerPrompt, mentionPrompt, COMPOSIO_PROMPT, customMcpPrompt, CREDENTIAL_PROMPT, THREADS_PROMPT, LEARN_PROMPT, PROFILE_PROMPT, ROUTINE_PROMPT, ROUTINE_EXECUTION_PROMPT, WEBHOOK_PROMPT, } from "./system-prompt.js";
83
90
  import { readCuaConnection, gatedLocalComputer } from "./local-computer.js";
84
91
  import { discoverExistingPerBotLocalVms, localVmInventoryEntry, shouldArmLocalVmIdle, } from "./local-vm-inventory.js";
85
92
  import { LocalVmIdleTimer } from "./local-vm-idle.js";
@@ -103,7 +110,7 @@ import { flushAllProfileHistory, flushProfileHistory, readHistory, recordProfile
103
110
  import { fetchBotDirectory, matchDirectoryBots } from "./bot-directory.js";
104
111
  import { scoutProject, suggestTeam } from "./project-scout.js";
105
112
  import { fetchGithubTeam, fetchLibraryTeam, fetchTeamCatalog } from "./team-library.js";
106
- import { isBotPackage, packageAgentAsMember, parseBotPackage, renderBotPackageMarkdown } from "./bot-package.js";
113
+ import { BOT_PACKAGE_MAX_SKILLS, isBotPackage, packageAgentAsMember, parseBotPackage, renderBotPackageMarkdown } from "./bot-package.js";
107
114
  import { createTeamManifest, importedMemberProfile, parseTeamManifest } from "./team-manifest.js";
108
115
  import { readThreadEvents } from "./thread-events.js";
109
116
  import { listenWebhookIngress, webhookCredential } from "./webhook-ingress.js";
@@ -121,7 +128,10 @@ import { PendingTurnCancellations, ProviderTurnGenerationRegistry, RetiredTurnRe
121
128
  import { createGracefulShutdown } from "./graceful-shutdown.js";
122
129
  import { acquireDataDirLeaseForProcess } from "./data-dir-lease.js";
123
130
  import { describeEdition, editionStatus, loadEnterpriseLayer } from "./enterprise.js";
124
- import { environmentDescriptor, loadEnvironmentId } from "./environment.js";
131
+ import { environmentDescriptor, loadEnvironmentId, serverVersion } from "./environment.js";
132
+ import { WorkspaceBackupMaintenance } from "./workspace-backup-maintenance.js";
133
+ import { createWorkspaceBackupRoutes, isWorkspaceBackupSessionControl } from "./workspace-backup-http.js";
134
+ import { applyPendingWorkspaceRestore, readLastWorkspaceRestore } from "./workspace-backup.js";
125
135
  import { createCustomDomainVerifier, customDomainIpv4, normalizeCustomDomain } from "./custom-domain.js";
126
136
  import { createEmailSignIn, parseAllowList } from "./account-signin.js";
127
137
  import { ProviderAuthSessions } from "./provider-auth-sessions.js";
@@ -165,6 +175,16 @@ function releaseDataDirLeaseAtExit() {
165
175
  }
166
176
  }
167
177
  process.once("exit", releaseDataDirLeaseAtExit);
178
+ // Restore before constructing any long-lived config, Store, session or provider
179
+ // objects. Replacing files underneath a live Store would overwrite restored data.
180
+ let workspaceRestore = { restored: false };
181
+ if (existsSync(join(DATA_DIR, ".backups"))) {
182
+ workspaceRestore = applyPendingWorkspaceRestore(DATA_DIR);
183
+ if (!workspaceRestore.restored && !workspaceRestore.rolledBack) {
184
+ workspaceRestore = readLastWorkspaceRestore(DATA_DIR) ?? workspaceRestore;
185
+ }
186
+ }
187
+ const workspaceMaintenance = new WorkspaceBackupMaintenance();
168
188
  // Only after ensureDirs(): it performs the one-time rename of the legacy data
169
189
  // dir, which must not find a freshly created ~/.openmausbot already there.
170
190
  // Remote clients (server/request-auth.ts, server/sessions.ts): a stable identity
@@ -212,6 +232,17 @@ const registry = new ProviderRegistry(BUILT_IN_DRIVERS);
212
232
  // Engines installed from Settings live under the data directory and win over
213
233
  // any other copy on PATH.
214
234
  registerEnginesBinDir();
235
+ // Who asked for the next turn on a thread, noted where a message comes in
236
+ // and read by the usage ledger when the turn settles. The last note stands
237
+ // until the next message: a resumed connector or a drained queued send
238
+ // still belongs to the person who wrote, and routine or peer turns are
239
+ // told apart before this is consulted.
240
+ const turnTriggers = new Map();
241
+ function noteTurnTrigger(threadId, auth) {
242
+ turnTriggers.set(threadId, auth.kind === "session"
243
+ ? { kind: "user", ...(auth.session.email ? { email: auth.session.email } : {}), label: auth.session.label }
244
+ : { kind: "owner" });
245
+ }
215
246
  const providerAuthSessions = new ProviderAuthSessions();
216
247
  await registry.load(instanceConfigs(cfg));
217
248
  const bundledSkills = loadBundledSkills();
@@ -884,6 +915,54 @@ function checkedModelSelection(raw, current, requireAvailableModel = false) {
884
915
  }
885
916
  return { ok: true, selection };
886
917
  }
918
+ function checkedExportSkillNames(value, bots) {
919
+ // Sharing instructions is explicit: ordinary package exports include none.
920
+ if (value === undefined)
921
+ return { ok: true, names: [] };
922
+ if (!Array.isArray(value) || value.some((name) => typeof name !== "string" || !isSkillName(name))) {
923
+ return { ok: false, error: "skillIds must be a list of exact imported skill names" };
924
+ }
925
+ const names = [...value];
926
+ if (new Set(names).size !== names.length)
927
+ return { ok: false, error: "skillIds must not contain duplicates" };
928
+ if (names.length > BOT_PACKAGE_MAX_SKILLS)
929
+ return { ok: false, error: `skillIds must contain at most ${BOT_PACKAGE_MAX_SKILLS} names` };
930
+ const available = new Set(bots.flatMap((bot) => listSkills(bot.id).map((skill) => skill.name)));
931
+ const unknown = names.find((name) => !available.has(name));
932
+ if (unknown)
933
+ return { ok: false, error: `skillIds contains unknown imported skill "${unknown}"` };
934
+ return { ok: true, names };
935
+ }
936
+ function collectExportSkills(bots, names) {
937
+ const selected = new Set(names);
938
+ const byBot = new Map();
939
+ if (!selected.size)
940
+ return byBot;
941
+ for (const bot of bots) {
942
+ const assigned = [];
943
+ for (const listing of listSkills(bot.id)) {
944
+ if (!selected.has(listing.name))
945
+ continue;
946
+ const instructions = readSkillFile(bot.id, listing.name);
947
+ if (instructions === null) {
948
+ throw new Error(`Skill "${listing.name}" changed or is unavailable and cannot be exported safely`);
949
+ }
950
+ const skill = {
951
+ name: listing.name,
952
+ description: listing.description,
953
+ ...(listing.source ? { source: listing.source } : {}),
954
+ ...(listing.license ? { license: listing.license } : {}),
955
+ ...(listing.compatibility ? { compatibility: listing.compatibility } : {}),
956
+ instructions,
957
+ };
958
+ // The shared exporter validates duplicate bytes and metadata together.
959
+ assigned.push(skill);
960
+ }
961
+ if (assigned.length)
962
+ byBot.set(bot.id, assigned);
963
+ }
964
+ return byBot;
965
+ }
887
966
  function checkedGroupResponder(value, memberIds) {
888
967
  if (!value || typeof value !== "object" || Array.isArray(value))
889
968
  return null;
@@ -1010,12 +1089,13 @@ function previewSystemPrompt(bot) {
1010
1089
  id: "setup",
1011
1090
  label: "Setup",
1012
1091
  text: setupSystemPrompt(agentsMounted && setupModeActive({ soul: bot.soul, description: bot.description, text: "" }), {
1013
- skills: skillRecorderEnabled(cfg),
1092
+ skills: skillAuthoringEnabled(cfg),
1014
1093
  cwd: bot.cwd,
1015
1094
  }),
1016
1095
  },
1017
1096
  { id: "computer", label: "Computer", text: computerPrompt(computerPromptKind) },
1018
1097
  { id: "composio", label: "Connected apps", text: caps?.composioMcp && bot.composio !== false && composio.configured(cfg) ? COMPOSIO_PROMPT : "" },
1098
+ { id: "mcp", label: "MCP servers", text: caps?.customMcp ? customMcpPrompt(Object.keys(customMcpServers(cfg, bot.mcpServers))) : "" },
1019
1099
  { id: "browser", label: "Browser", text: caps?.browserMcp && builtInBrowserEnabled(cfg) && bot.browser !== false && bot.computer !== "off" ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
1020
1100
  { id: "coordination", label: "Team", text: agentsMounted && coordination ? ` ${coordination}` : "" },
1021
1101
  { id: "credential", label: "Credentials", text: agentsMounted ? CREDENTIAL_PROMPT : "" },
@@ -1447,6 +1527,165 @@ function groupGoalCoordinatorTurnForEvent(event) {
1447
1527
  function groupIsWorking(group) {
1448
1528
  return Boolean(group.busyBotId) || Boolean(groupTurnOperations.get(group.id)?.size);
1449
1529
  }
1530
+ // The public and Chief room tools use the same synchronous validation and write.
1531
+ // Keep authorization at each ingress; no internal caller gains public admin scope.
1532
+ function createChannel(value) {
1533
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1534
+ throw Object.assign(new Error("channel must be a JSON object"), { status: 400 });
1535
+ }
1536
+ const body = value;
1537
+ const roster = checkedMemberIds(body.memberIds);
1538
+ if (!roster.ok)
1539
+ throw Object.assign(new Error(roster.error), { status: 400 });
1540
+ const { memberIds } = roster;
1541
+ if (body.name !== undefined && typeof body.name !== "string") {
1542
+ throw Object.assign(new Error("channel name must be a string"), { status: 400 });
1543
+ }
1544
+ const name = body.name?.trim() || `${store.bot(memberIds[0]).name} & co.`;
1545
+ if (name.length > 100)
1546
+ throw Object.assign(new Error("channel name must be at most 100 characters"), { status: 400 });
1547
+ let section;
1548
+ if (body.section !== undefined && body.section !== null) {
1549
+ if (typeof body.section !== "string")
1550
+ throw Object.assign(new Error("context must be a string"), { status: 400 });
1551
+ section = body.section.trim() || undefined;
1552
+ if (section && section.length > 60) {
1553
+ throw Object.assign(new Error("context must be at most 60 characters"), { status: 400 });
1554
+ }
1555
+ }
1556
+ let setup;
1557
+ if (body.setup !== undefined) {
1558
+ if (!body.setup || typeof body.setup !== "object" || Array.isArray(body.setup)) {
1559
+ throw Object.assign(new Error("setup must be an object"), { status: 400 });
1560
+ }
1561
+ const requested = body.setup;
1562
+ if (typeof requested.bulletin !== "string") {
1563
+ throw Object.assign(new Error("setup.bulletin must be a string"), { status: 400 });
1564
+ }
1565
+ if (requested.bulletin.length > 12_000) {
1566
+ throw Object.assign(new Error("setup.bulletin must be at most 12000 characters"), { status: 400 });
1567
+ }
1568
+ const responder = checkedGroupResponder(requested.defaultResponder, memberIds);
1569
+ if (!responder)
1570
+ throw Object.assign(new Error("invalid setup.defaultResponder"), { status: 400 });
1571
+ setup = { bulletin: requested.bulletin, defaultResponder: responder, completed: true };
1572
+ }
1573
+ return store.createGroup(name, memberIds, false, section, setup);
1574
+ }
1575
+ function updateChannel(groupId, value) {
1576
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1577
+ throw Object.assign(new Error("body must be a JSON object"), { status: 400 });
1578
+ }
1579
+ const body = value;
1580
+ const existing = store.group(groupId);
1581
+ if (!existing)
1582
+ throw Object.assign(new Error("no such room"), { status: 404 });
1583
+ if (body.memberIds !== undefined && phoneSecretSubmissions.hasGroup(existing.id)) {
1584
+ throw Object.assign(new Error("this channel is securely saving a credential — try again when it finishes"), { status: 409 });
1585
+ }
1586
+ if (channelTaskBlocked(existing) &&
1587
+ (body.memberIds !== undefined || body.defaultResponder !== undefined || body.bulletin !== undefined)) {
1588
+ throw Object.assign(new Error("this channel is working or waiting on you — finish that turn first"), { status: 409 });
1589
+ }
1590
+ const patch = {};
1591
+ if (body.name !== undefined) {
1592
+ if (typeof body.name !== "string")
1593
+ throw Object.assign(new Error("room name must be a string"), { status: 400 });
1594
+ const name = body.name.trim();
1595
+ if (!name)
1596
+ throw Object.assign(new Error("room name must not be empty"), { status: 400 });
1597
+ if (name.length > 100)
1598
+ throw Object.assign(new Error("room name must be at most 100 characters"), { status: 400 });
1599
+ patch.name = name;
1600
+ }
1601
+ if (body.bulletin !== undefined) {
1602
+ if (typeof body.bulletin !== "string")
1603
+ throw Object.assign(new Error("bulletin must be a string"), { status: 400 });
1604
+ if (body.bulletin.length > 12_000) {
1605
+ throw Object.assign(new Error("bulletin must be at most 12000 characters"), { status: 400 });
1606
+ }
1607
+ patch.bulletin = body.bulletin;
1608
+ }
1609
+ if (body.unread !== undefined) {
1610
+ if (typeof body.unread !== "boolean")
1611
+ throw Object.assign(new Error("unread must be true or false"), { status: 400 });
1612
+ patch.unread = body.unread;
1613
+ }
1614
+ if (body.memberIds !== undefined) {
1615
+ // A DM is the pair it was opened for; only real rooms have a roster.
1616
+ if (existing.dm)
1617
+ throw Object.assign(new Error("direct-message channels cannot change members"), { status: 400 });
1618
+ const roster = checkedMemberIds(body.memberIds);
1619
+ if (!roster.ok)
1620
+ throw Object.assign(new Error(roster.error.replace("channel", "room")), { status: 400 });
1621
+ const removedGoalLead = routines.listRoutines().some((routine) => routine.enabled &&
1622
+ routine.target === "room-goal" &&
1623
+ routine.groupId === existing.id &&
1624
+ !roster.memberIds.includes(routine.botId)) || routines.listRuns().some((run) => run.target === "room-goal" &&
1625
+ run.groupId === existing.id &&
1626
+ ["queued", "running", "waiting"].includes(run.status) &&
1627
+ !roster.memberIds.includes(run.botId));
1628
+ if (removedGoalLead) {
1629
+ throw Object.assign(new Error("pause or reassign this room's team-goal routine before removing its lead"), { status: 409 });
1630
+ }
1631
+ patch.memberIds = roster.memberIds;
1632
+ }
1633
+ if (body.defaultResponder !== undefined) {
1634
+ const memberIds = patch.memberIds ?? existing.memberIds;
1635
+ const responder = checkedGroupResponder(body.defaultResponder, memberIds);
1636
+ if (!responder)
1637
+ throw Object.assign(new Error("invalid default responder"), { status: 400 });
1638
+ patch.defaultResponder = responder;
1639
+ }
1640
+ if (body.cwd !== undefined) {
1641
+ if (existing.dm)
1642
+ throw Object.assign(new Error("direct-message channels cannot have a working folder"), { status: 400 });
1643
+ if (existing.pinnedCwd !== undefined) {
1644
+ throw Object.assign(new Error("the room's working folder is fixed after its first turn"), { status: 409 });
1645
+ }
1646
+ const checked = validateBotCwd(body.cwd);
1647
+ if (!checked.ok)
1648
+ throw Object.assign(new Error(checked.error), { status: 400 });
1649
+ patch.cwd = checked.cwd ?? undefined;
1650
+ }
1651
+ // one pinned message per room; null/"" clears. The id is not
1652
+ // validated against the transcript here — a pin whose message was
1653
+ // edited away or deleted simply resolves to nothing in the UI.
1654
+ if (body.pinnedMessageId !== undefined) {
1655
+ if (body.pinnedMessageId === null || body.pinnedMessageId === "")
1656
+ patch.pinnedMessageId = undefined;
1657
+ else if (typeof body.pinnedMessageId === "string" && /^[\w-]+$/.test(body.pinnedMessageId)) {
1658
+ patch.pinnedMessageId = body.pinnedMessageId;
1659
+ }
1660
+ else
1661
+ throw Object.assign(new Error("pinnedMessageId must be a message id"), { status: 400 });
1662
+ }
1663
+ // same contract as a bot's sidebar section: null/"" clears, 60 chars max
1664
+ if (body.section !== undefined) {
1665
+ if (body.section === null)
1666
+ patch.section = undefined;
1667
+ else if (typeof body.section !== "string")
1668
+ throw Object.assign(new Error("section must be a string"), { status: 400 });
1669
+ else {
1670
+ const trimmed = body.section.trim();
1671
+ if (!trimmed)
1672
+ patch.section = undefined;
1673
+ else if (trimmed.length > 60)
1674
+ throw Object.assign(new Error("section must be at most 60 characters"), { status: 400 });
1675
+ else
1676
+ patch.section = trimmed;
1677
+ }
1678
+ }
1679
+ const group = store.patchGroup(groupId, patch);
1680
+ if (!group)
1681
+ throw Object.assign(new Error("no such room"), { status: 404 });
1682
+ return group;
1683
+ }
1684
+ const channelTaskBlocked = (group) => groupIsWorking(group) ||
1685
+ store.groupTasks(group.id).some((task) => store.messagesFor(task.threadId).some((message) => message.kind === "options" &&
1686
+ message.card?.requestId &&
1687
+ !message.card.answered &&
1688
+ !message.card.dismissed));
1450
1689
  function publicGroupState(group) {
1451
1690
  return { ...group, working: groupIsWorking(group) };
1452
1691
  }
@@ -1899,7 +2138,7 @@ function broadcast(payload) {
1899
2138
  replayBuffer.push({ seq, kind, frame: kind === "screen" ? null : frame, clientFrame: kind === "screen" ? null : clientFrame });
1900
2139
  if (replayBuffer.length > REPLAY_MAX)
1901
2140
  replayBuffer.shift();
1902
- for (const client of [...sseClients]) {
2141
+ for (const client of Array.from(sseClients)) {
1903
2142
  if (!wants(client, kind))
1904
2143
  continue;
1905
2144
  // Screen frames are replaceable and durable events are not: see
@@ -2205,6 +2444,18 @@ bus.subscribe((event) => {
2205
2444
  else if (event.type !== "session.exited")
2206
2445
  watchdog.touch(event.threadId);
2207
2446
  });
2447
+ // Memory journal turn boundary (server/memory-journal.ts). A bot's own
2448
+ // file-tool writes to MEMORY.md and memory/ have no hook to tap, so the
2449
+ // diff against the baseline taken at dispatch is made when the turn
2450
+ // settles — session.exited too, because a turn that died may still have
2451
+ // written. Fire-and-forget by construction: endMemoryTurn swallows its own
2452
+ // failures and never reaches the fold below.
2453
+ bus.subscribe((event) => {
2454
+ if (shouldIgnoreProviderEvent(event))
2455
+ return;
2456
+ if (event.type === "turn.completed" || event.type === "session.exited")
2457
+ endMemoryTurn(event.threadId);
2458
+ });
2208
2459
  // Bots currently working with nobody at the keyboard — a webhook turn, or a
2209
2460
  // turn a webhook-driven bot handed to a teammate. Auto mode is a decision
2210
2461
  // someone made for turns they were present for, so these don't inherit it:
@@ -2616,7 +2867,7 @@ bus.subscribe((event) => {
2616
2867
  const existing = store.messagesFor(event.threadId).find((m) => m.id === messageId)?.tool;
2617
2868
  toolName = existing?.name ?? "tool";
2618
2869
  store.patchMessage(event.threadId, messageId, {
2619
- tool: { name: toolName, ok: event.ok, spoken: existing?.spoken },
2870
+ tool: { name: toolName, ok: event.ok, spoken: existing?.spoken, summary: existing?.summary },
2620
2871
  });
2621
2872
  toolMessageByItem.delete(itemKey);
2622
2873
  }
@@ -2648,7 +2899,7 @@ bus.subscribe((event) => {
2648
2899
  const message = pushMessage({
2649
2900
  role: "bot",
2650
2901
  kind: "activity",
2651
- tool: { name, spoken: narrateTool(name) ?? undefined },
2902
+ tool: { name, spoken: narrateTool(name) ?? undefined, summary: event.summary },
2652
2903
  });
2653
2904
  if (event.itemId)
2654
2905
  toolMessageByItem.set(`${event.threadId}:${event.itemId}`, message.id);
@@ -3015,6 +3266,28 @@ bus.subscribe((event) => {
3015
3266
  cachedInput: tokens?.cachedInput,
3016
3267
  costUsd: event.cost ?? null,
3017
3268
  });
3269
+ // and write the same figures to the month's ledger, which outlives
3270
+ // the task and answers "what did we spend, by whom" for a period
3271
+ const settledTask = store.taskByThread(bot.id, event.threadId);
3272
+ const selection = settledTask?.modelSelection ?? bot.modelSelection;
3273
+ appendUsage(DATA_DIR, {
3274
+ botId: bot.id,
3275
+ botName: bot.name,
3276
+ threadId: event.threadId,
3277
+ instanceId: selection.instanceId,
3278
+ driverKind: registry.get(selection.instanceId)?.driverKind ?? "unknown",
3279
+ model: selection.model,
3280
+ input: tokens?.input ?? 0,
3281
+ output: tokens?.output ?? 0,
3282
+ ...(typeof tokens?.cachedInput === "number" ? { cachedInput: tokens.cachedInput } : {}),
3283
+ costUsd: event.cost ?? null,
3284
+ trigger: routineRun
3285
+ ? { kind: "routine", routineId: routineRun.routineId, label: routineRun.routineName }
3286
+ : internal
3287
+ ? { kind: "bot", ...(settledTask?.openedBy?.botId ? { botId: settledTask.openedBy.botId } : {}) }
3288
+ : turnTriggers.get(event.threadId) ?? { kind: "owner" },
3289
+ });
3290
+ noteSpend(DATA_DIR, event.cost ?? null);
3018
3291
  const routineReportThread = routineRun ? routineSourceThread(routineRun) : null;
3019
3292
  // A routine's result belongs to its reporting thread's unread state.
3020
3293
  // Its internal execution should not light up the sidebar as well.
@@ -3707,6 +3980,7 @@ async function finalScreenFrame(_botId, threadId) {
3707
3980
  }
3708
3981
  // ── turn dispatch (upstream ProviderCommandReactor, miniature) ──────────
3709
3982
  async function startTurn(botId, text, opts) {
3983
+ workspaceMaintenance.assertAvailable();
3710
3984
  const profile = store.bot(botId);
3711
3985
  if (!profile)
3712
3986
  throw Object.assign(new Error("no such bot"), { status: 404 });
@@ -3735,6 +4009,9 @@ async function startTurn(botId, text, opts) {
3735
4009
  throw Object.assign(new Error(transitionError), { status: 409 });
3736
4010
  if (providerFleetReloading)
3737
4011
  throw Object.assign(new Error("provider settings are being updated — try again shortly"), { status: 409 });
4012
+ // A workspace at its monthly spend limit starts no turn of any kind: a
4013
+ // person's message, a routine, a peer hop or a webhook all stop here.
4014
+ assertWithinBudget(cfg, DATA_DIR);
3738
4015
  if (checkpointRestoreLeases.has(botId)) {
3739
4016
  throw Object.assign(new Error("this bot's project files are being restored — wait for the restore to finish"), {
3740
4017
  status: 409,
@@ -3880,7 +4157,7 @@ async function startTurn(botId, text, opts) {
3880
4157
  // that never mounts agent tools (or a turn already at the comms-depth cap)
3881
4158
  // must not be steered into — or told about — tools it cannot call.
3882
4159
  const agentsMounted = commsDepth < MAX_COMMS_DEPTH && instance.adapter.capabilities.agentsMcp === true;
3883
- const skillAuthoring = skillRecorderEnabled(cfg) && agentsMounted;
4160
+ const skillAuthoring = skillAuthoringEnabled(cfg) && agentsMounted;
3884
4161
  // Setup mode's turn-text rewrite (parseSetupCommand/expandSetupTurnText)
3885
4162
  // must not run ahead of a system prompt that can't explain it: a driver
3886
4163
  // without agent tools sees the user's literal "/setup ..." message. The
@@ -3901,6 +4178,10 @@ async function startTurn(botId, text, opts) {
3901
4178
  // this already-built turn must either keep its old session or replay on the
3902
4179
  // following turn, never start a blank session with no transcript.
3903
4180
  const resumeCursor = resume ? task.resumeCursors[instanceId] : undefined;
4181
+ // A cursor the provider no longer honours must not brick the thread: the
4182
+ // driver may fall back to ONE fresh session, and this is what it sends
4183
+ // there, so the new session is not blank (server/resume-recovery.ts).
4184
+ const recoveryText = resumeCursor !== undefined ? buildRecoveryText({ text: turnText, transcript }) : undefined;
3904
4185
  const persona = [
3905
4186
  `You are ${bot.name}, a personal bot in OpenMausBot.`,
3906
4187
  bot.title && `Role: ${bot.title}.`,
@@ -3959,7 +4240,7 @@ async function startTurn(botId, text, opts) {
3959
4240
  // composio — only to a driver that can mount them. Their tools are
3960
4241
  // never pre-allowed, so every call rides the normal permission flow.
3961
4242
  if (instance.adapter.capabilities.customMcp === true) {
3962
- const custom = customMcpServers(cfg);
4243
+ const custom = customMcpServers(cfg, bot.mcpServers);
3963
4244
  if (Object.keys(custom).length)
3964
4245
  integrations.custom = custom;
3965
4246
  }
@@ -3968,8 +4249,11 @@ async function startTurn(botId, text, opts) {
3968
4249
  // desk, not the whole house — and the workspace is where its
3969
4250
  // MEMORY.md lives. API/box engines have no local filesystem story.
3970
4251
  const worksInWorkspace = instance.driverKind !== "grok" && instance.driverKind !== "boxAgent";
3971
- if (worksInWorkspace)
4252
+ if (worksInWorkspace) {
3972
4253
  ensureWorkspace(bot.id);
4254
+ // baseline for the journal's turn-boundary diff (see the bus hook)
4255
+ beginMemoryTurn(bot.id, threadId);
4256
+ }
3973
4257
  const privateWorkspace = worksInWorkspace ? ensureTaskWorkspace(bot.id, threadId) : undefined;
3974
4258
  const skillInstructions = renderSkillInstructions(selectedSkills, {
3975
4259
  includeRoot: worksInWorkspace && opts?.runOn !== "cloud",
@@ -4316,6 +4600,7 @@ async function startTurn(botId, text, opts) {
4316
4600
  // gated on the integration, not the key: the hint only goes to a
4317
4601
  // bot whose driver actually mounted the tools
4318
4602
  { id: "composio", label: "Connected apps", text: integrations.composio ? COMPOSIO_PROMPT : "" },
4603
+ { id: "mcp", label: "MCP servers", text: customMcpPrompt(Object.keys(integrations.custom ?? {})) },
4319
4604
  { id: "browser", label: "Browser", text: integrations.browser ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
4320
4605
  { id: "coordination", label: "Team", text: coordinationPrompt ? ` ${coordinationPrompt}` : "" },
4321
4606
  { id: "credential", label: "Credentials", text: credentialPrompt },
@@ -4343,8 +4628,11 @@ async function startTurn(botId, text, opts) {
4343
4628
  // the active task's own session — another task's cursor would
4344
4629
  // resume the wrong conversation and defeat the context bubble
4345
4630
  resumeCursor,
4631
+ ...(recoveryText !== undefined ? { recoveryText } : {}),
4346
4632
  transcript,
4347
4633
  system: prompt.text,
4634
+ systemStable: prompt.stable,
4635
+ systemVolatile: prompt.volatile,
4348
4636
  integrations,
4349
4637
  cwd,
4350
4638
  }), () => !directTurnClaimExists(bot.id, dispatchClaimId, threadId), async () => {
@@ -4986,7 +5274,10 @@ const webhooks = new WebhookManager({
4986
5274
  let webhookIngress = null;
4987
5275
  let webhookIngressError = null;
4988
5276
  try {
4989
- webhookIngress = await listenWebhookIngress(webhooks, { port: WEBHOOK_PORT, publicBaseUrl: WEBHOOK_PUBLIC_URL });
5277
+ webhookIngress = await listenWebhookIngress(webhooks, {
5278
+ port: WEBHOOK_PORT, publicBaseUrl: WEBHOOK_PUBLIC_URL,
5279
+ claimRequest: () => workspaceMaintenance.request(),
5280
+ });
4990
5281
  const advertised = WEBHOOK_PUBLIC_URL ? ` (advertised as ${webhookIngress.baseUrl})` : "";
4991
5282
  console.log(`openmausbot webhook receiver on http://${webhookIngress.host}:${webhookIngress.port}${advertised}`);
4992
5283
  }
@@ -5065,6 +5356,10 @@ operation,
5065
5356
  // execution setting changes in that gap, rebuild the turn once from the
5066
5357
  // fresh bot rather than mixing a stale adapter with fresh permissions.
5067
5358
  setupRetry = 0) {
5359
+ if (workspaceMaintenance.active) {
5360
+ onDispatchError?.("A workspace backup or restore is in progress.");
5361
+ return false;
5362
+ }
5068
5363
  if (isCancelled?.())
5069
5364
  return false;
5070
5365
  if (providerFleetReloading) {
@@ -5141,7 +5436,7 @@ setupRetry = 0) {
5141
5436
  };
5142
5437
  try {
5143
5438
  const integrations = {};
5144
- const skillAuthoring = skillRecorderEnabled(cfg) &&
5439
+ const skillAuthoring = skillAuthoringEnabled(cfg) &&
5145
5440
  hop === 0 &&
5146
5441
  !skillAuthoringClaim.claimed &&
5147
5442
  !cardContinuation &&
@@ -5185,7 +5480,7 @@ setupRetry = 0) {
5185
5480
  }
5186
5481
  // user-configured MCP servers: same gating as the 1:1 site above.
5187
5482
  if (instance.adapter.capabilities.customMcp === true) {
5188
- const custom = customMcpServers(cfg);
5483
+ const custom = customMcpServers(cfg, bot.mcpServers);
5189
5484
  if (Object.keys(custom).length)
5190
5485
  integrations.custom = custom;
5191
5486
  }
@@ -5382,6 +5677,9 @@ setupRetry = 0) {
5382
5677
  // conversation, not a different bot
5383
5678
  const worksInWorkspace = instance.driverKind !== "grok" && instance.driverKind !== "boxAgent";
5384
5679
  const workspace = worksInWorkspace ? ensureWorkspace(bot.id) : undefined;
5680
+ // a room member's memory writes are journaled the same as a 1:1 turn's
5681
+ if (workspace)
5682
+ beginMemoryTurn(bot.id, threadId);
5385
5683
  // The room's folder pins here — on the first turn that actually
5386
5684
  // dispatches, not at PATCH time — so a folder set on a never-used room
5387
5685
  // still takes effect, while a room that already worked somewhere never
@@ -5395,18 +5693,21 @@ setupRetry = 0) {
5395
5693
  store.patchBot(bot.id, { soulDrift: drift.drift });
5396
5694
  }
5397
5695
  const roomSystem = buildSystemPrompt(system, store.bot(bot.id)?.soul ?? bot.soul ?? "", [
5696
+ { id: "mcp", label: "MCP servers", text: customMcpPrompt(Object.keys(integrations.custom ?? {})) },
5398
5697
  { id: "computer", label: "Computer", text: computerPrompt(roomVmTarget ? localVmMode(cfg) === "per-bot" ? "vm-private" : "vm-shared" : null) },
5399
5698
  { id: "browser", label: "Browser", text: integrations.browser ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
5400
5699
  { id: "recall", label: "Recall", text: integrations.agents ? SESSION_SEARCH_SYSTEM_PROMPT : "" },
5401
5700
  { id: "section-context", label: "Section context", text: sectionContextSystemPrompt(bot.section) },
5402
5701
  // the room path has always put a newline before memory and trimmed
5403
5702
  // the block's leading space; keep that so existing prompts are
5404
- // byte-identical
5405
- { id: "memory", label: "Memory", text: workspace ? `\n${memorySystemPrompt(bot.id).trim()}` : "" },
5703
+ // byte-identical. The write guidance follows the tools actually
5704
+ // mounted, exactly as the 1:1 path decides it: memory_update is on the
5705
+ // agents server, so a room turn with it must be told to use it too.
5706
+ { id: "memory", label: "Memory", text: workspace ? `\n${memorySystemPrompt(bot.id, { managedWrites: Boolean(integrations.agents) }).trim()}` : "" },
5406
5707
  { id: "skills", label: "Skills index", text: workspace ? skillsSystemPrompt(bot.id) : "" },
5407
5708
  { id: "skill-instructions", label: "Skill instructions", text: renderSkillInstructions(selectedSkills, { includeRoot: Boolean(workspace) }) },
5408
5709
  { id: "playbooks", label: "Playbooks", text: installedPlaybookInstructions(text, bot.playbooks) },
5409
- ]).text;
5710
+ ]);
5410
5711
  // run the turn and wait for it to settle, folding the reply text so a
5411
5712
  // chained @mention can be routed afterwards
5412
5713
  // Claim only after setup succeeded. An unavailable, busy, unsupported, or
@@ -5509,7 +5810,9 @@ setupRetry = 0) {
5509
5810
  text,
5510
5811
  images: turnImages,
5511
5812
  approvalMode: approvalModeForTurn(readyBot, false, threadId),
5512
- system: roomSystem,
5813
+ system: roomSystem.text,
5814
+ systemStable: roomSystem.stable,
5815
+ systemVolatile: roomSystem.volatile,
5513
5816
  cwd,
5514
5817
  integrations,
5515
5818
  ...memberTurnSelection(readyBot.modelSelection),
@@ -7022,6 +7325,19 @@ async function perBotLocalVmCountForModeChange() {
7022
7325
  function configStatus() {
7023
7326
  return {
7024
7327
  xai: { configured: Boolean(cfg.xai?.key) },
7328
+ anthropic: { configured: Boolean(cfg.anthropic?.key) },
7329
+ // a fleet agent on this server means Settings → Workspaces has something to drive
7330
+ fleet: { available: fleetAvailable(fleetSocketPath()) },
7331
+ // what this build is entitled to, so Settings shows only what works here
7332
+ edition: (({ edition, features }) => ({ edition, features }))(editionStatus()),
7333
+ // settings, not secrets: the cap and the operator's own price list
7334
+ budgets: {
7335
+ ...(cfg.budgets?.monthlyUsd !== undefined ? { monthlyUsd: cfg.budgets.monthlyUsd } : {}),
7336
+ ...(cfg.budgets?.warnAtPercent !== undefined ? { warnAtPercent: cfg.budgets.warnAtPercent } : {}),
7337
+ },
7338
+ billing: { currency: cfg.billing?.currency ?? "USD", prices: cfg.billing?.prices ?? {} },
7339
+ // the base URL is a setting, not a secret; the key stays write-only
7340
+ openaiCompat: { configured: Boolean(cfg.openaiCompat?.key), url: cfg.openaiCompat?.url ?? "" },
7025
7341
  composio: {
7026
7342
  configured: composio.configured(cfg),
7027
7343
  mode: composio.connectionMode(cfg),
@@ -7044,7 +7360,7 @@ function configStatus() {
7044
7360
  maxInstances: localVmMaxInstances(cfg),
7045
7361
  },
7046
7362
  features: {
7047
- skillRecorder: skillRecorderEnabled(cfg),
7363
+ skillAuthoring: skillAuthoringEnabled(cfg),
7048
7364
  showToolCalls: showToolCallsEnabled(cfg),
7049
7365
  browser: builtInBrowserEnabled(cfg),
7050
7366
  },
@@ -7235,6 +7551,26 @@ function json(res, status, body) {
7235
7551
  res.writeHead(status, { "content-type": "application/json" });
7236
7552
  res.end(data);
7237
7553
  }
7554
+ /** A store refusal is a client error with a status of its own (400 path,
7555
+ * 409 conflict, 413 too large); a 409 also carries what is on disk now so
7556
+ * the editor can show the bot's version instead of guessing. Anything else
7557
+ * is a real failure and goes to the handler's catch-all. */
7558
+ function replyMemoryError(res, error) {
7559
+ if (!(error instanceof MemoryStoreError))
7560
+ throw error;
7561
+ if (error.code === "conflict") {
7562
+ return json(res, error.status, { error: error.message, code: error.code, currentHash: error.currentHash, current: error.current });
7563
+ }
7564
+ return json(res, error.status, { error: error.message, code: error.code });
7565
+ }
7566
+ /** The journal row as the panel shows it: the full prior text stays on
7567
+ * the server (a revert needs it there, the list does not), and the thread
7568
+ * id becomes the chat title people recognise. */
7569
+ function journalEntryForClient(botId, entry) {
7570
+ const { before: _before, ...visible } = entry;
7571
+ const threadTitle = entry.threadId ? store.taskByThread(botId, entry.threadId)?.title : undefined;
7572
+ return threadTitle ? { ...visible, threadTitle } : visible;
7573
+ }
7238
7574
  function readBody(req, limit = 1_000_000) {
7239
7575
  return new Promise((resolve, reject) => {
7240
7576
  let data = "";
@@ -7279,6 +7615,39 @@ function readBody(req, limit = 1_000_000) {
7279
7615
  // requests from any loopback connection and any web page that DNS-rebinds
7280
7616
  // onto it. Reject non-loopback Hosts outright (defeats rebinding) and
7281
7617
  // origins outside loopback (blocks remote-web CSRF).
7618
+ const workspaceBackupRoutes = createWorkspaceBackupRoutes({
7619
+ dataDir: DATA_DIR,
7620
+ appVersion: serverVersion(),
7621
+ readBody,
7622
+ restored: workspaceRestore,
7623
+ status: () => ({ busy: workspaceMaintenance.active, pendingRestore: workspaceMaintenance.pendingRestore }),
7624
+ authorized: (req, original) => {
7625
+ const current = resolveRequestAuth(req, {
7626
+ sessions, cookieName: SESSION_COOKIE, streamPath: "/api/events",
7627
+ url: new URL(req.url ?? "/", `http://localhost:${PORT}`),
7628
+ loopbackMutationToken: desktopMutationToken, companionMutationToken,
7629
+ }).auth;
7630
+ return Boolean(current?.scopes.includes("admin") && current.kind === original.kind &&
7631
+ (current.kind !== "session" || (original.kind === "session" && current.session.id === original.session.id)));
7632
+ },
7633
+ exclusive: (work, keepLocked) => workspaceMaintenance.run(work, {
7634
+ idle: () => !providerFleetReloading && !providerAuthSessions.active && !browserEngineInstall &&
7635
+ !routines?.isTicking && !calendarCalls?.isTicking &&
7636
+ !localVmImageBusy && !localVmProvisionBusy && !localVmModeChangeBusy &&
7637
+ !localVmLifecycleBusy.size && !boxLifecycleBusyBots.size && !orphanBoxLifecycleBusyIds.size &&
7638
+ !computerProviderConfigTransitions.size && !checkpointRestoreLeases.size &&
7639
+ store.bots.every((bot) => !botHasActiveTurn(bot.id) && !routines?.activeRunForBot(bot.id) && !computerControl.snapshot(bot.id).held) &&
7640
+ store.groups.every((group) => !groupIsWorking(group)),
7641
+ pause: () => { routines?.stop(); calendarCalls?.stop(); watchdog.stop(); },
7642
+ resume: () => { routines?.start(); calendarCalls?.start(); watchdog.start(); },
7643
+ flush: async () => {
7644
+ await Promise.all([flushAllProfileHistory(), flushAllMemoryJournals(), flushUsageLedger(DATA_DIR), flushDecisionLog(DATA_DIR)]);
7645
+ // With writers gated and work idle, release our WAL connection for the
7646
+ // consistent snapshot. Store reopens it lazily after maintenance.
7647
+ closeMessageDb();
7648
+ },
7649
+ }, keepLocked),
7650
+ });
7282
7651
  const handleRequest = async (req, res) => {
7283
7652
  let url;
7284
7653
  try {
@@ -7291,6 +7660,7 @@ const handleRequest = async (req, res) => {
7291
7660
  const method = req.method ?? "GET";
7292
7661
  /** scratch for route matches, shared by every `path.match` below */
7293
7662
  let m = null;
7663
+ let releaseWorkspaceRequest;
7294
7664
  try {
7295
7665
  // ── who is asking (server/request-auth.ts) ──────────────────────────
7296
7666
  // Two public routes come first: what this server is, and turning a pairing
@@ -7408,6 +7778,13 @@ const handleRequest = async (req, res) => {
7408
7778
  if (!gate.auth)
7409
7779
  return json(res, gate.status, { error: gate.error });
7410
7780
  const auth = gate.auth;
7781
+ if (await workspaceBackupRoutes(req, res, path, auth))
7782
+ return;
7783
+ // Count ordinary requests until their asynchronous handler returns, not
7784
+ // merely until the browser disconnects. A cancelled upload can still write.
7785
+ if (path.startsWith("/api/") && path !== "/api/events" && path !== "/api/health" && !isWorkspaceBackupSessionControl(method, path)) {
7786
+ releaseWorkspaceRequest = workspaceMaintenance.request();
7787
+ }
7411
7788
  // ── sessions: who am I, tickets, pairing and revocation ─────────────
7412
7789
  if (method === "GET" && path === "/api/auth/session") {
7413
7790
  return json(res, 200, auth.kind === "loopback"
@@ -7602,10 +7979,23 @@ const handleRequest = async (req, res) => {
7602
7979
  throw Object.assign(new Error("the internal turn capability has expired"), { status: 401 });
7603
7980
  }
7604
7981
  };
7982
+ // Where an entry came from, as the person will read it in MEMORY.md:
7983
+ // the room or the thread title, never a bare id unless nothing else
7984
+ // names the conversation.
7985
+ const memorySource = () => memorySourceLabel({
7986
+ room: store.groupByThread(internalCapability.threadId),
7987
+ task: store.taskByThread(internalSender.id, internalCapability.threadId),
7988
+ threadId: internalCapability.threadId,
7989
+ });
7605
7990
  if (method === "POST" && path === "/api/internal/memory") {
7606
7991
  const body = await readInternalBody();
7607
- const result = updateMemory(internalSender.id, { action: body.action, text: body.text, oldText: body.oldText });
7608
- return json(res, result.ok ? 200 : result.code === "conflict" ? 409 : result.code === "too-large" ? 413 : 400, result);
7992
+ const result = updateMemory(internalSender.id, { action: body.action, text: body.text, oldText: body.oldText }, { source: memorySource() });
7993
+ return json(res, result.ok ? 200 : result.code === "conflict" ? 409 : result.code === "over-budget" ? 413 : 400, result);
7994
+ }
7995
+ if (method === "POST" && path === "/api/internal/memory/log") {
7996
+ const body = await readInternalBody();
7997
+ const result = appendMemoryLog(internalSender.id, body.text, { source: memorySource() });
7998
+ return json(res, result.ok ? 200 : 400, result);
7609
7999
  }
7610
8000
  if (method === "POST" && path === "/api/internal/browser/mcp") {
7611
8001
  const body = await readInternalBody();
@@ -7910,6 +8300,16 @@ const handleRequest = async (req, res) => {
7910
8300
  return json(res, 400, { error: "q is required" });
7911
8301
  const rawLimit = Number(url.searchParams.get("limit"));
7912
8302
  const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? Math.min(Math.trunc(rawLimit), 25) : 12;
8303
+ // Memory files ride along by default: the bot's own notes are as
8304
+ // much its notebook as its transcripts, and scoped the same way —
8305
+ // the caller's own bot, never another's.
8306
+ const scope = url.searchParams.get("scope") ?? "all";
8307
+ if (scope !== "all" && scope !== "conversations" && scope !== "memory") {
8308
+ return json(res, 400, { error: "scope must be all, conversations, or memory" });
8309
+ }
8310
+ const memoryHits = scope === "conversations" ? [] : searchMemoryFiles(from.id, q, limit);
8311
+ if (scope === "memory")
8312
+ return json(res, 200, { hits: [], memoryHits });
7913
8313
  const ownThreads = [...new Set([from.threadId, ...(from.tasks ?? []).map((task) => task.threadId)])];
7914
8314
  // A room is the only place a recall can be a disclosure: in a 1:1 the
7915
8315
  // user already owns every thread the bot can reach.
@@ -7923,7 +8323,7 @@ const handleRequest = async (req, res) => {
7923
8323
  if (inRoom) {
7924
8324
  discloseRecall(from, fromThreadId, hits.filter((hit) => hit.crossed).map((hit) => hit.threadId));
7925
8325
  }
7926
- return json(res, 200, { hits });
8326
+ return json(res, 200, { hits, memoryHits });
7927
8327
  }
7928
8328
  // session_read: the whole message behind a session_search hit. Same
7929
8329
  // own-bot scope — a message id from another bot's thread reads as
@@ -7957,8 +8357,8 @@ const handleRequest = async (req, res) => {
7957
8357
  });
7958
8358
  }
7959
8359
  if (method === "GET" && path === "/api/internal/skills") {
7960
- if (!skillRecorderEnabled(cfg))
7961
- return json(res, 403, { error: "learned skills are not enabled" });
8360
+ if (!skillAuthoringEnabled(cfg))
8361
+ return json(res, 403, { error: "skill authoring is not enabled in Settings" });
7962
8362
  if (!internalCapability.skillAuthoring) {
7963
8363
  return json(res, 403, { error: "skill authoring is not enabled for this turn" });
7964
8364
  }
@@ -7973,8 +8373,8 @@ const handleRequest = async (req, res) => {
7973
8373
  });
7974
8374
  }
7975
8375
  if (method === "POST" && path === "/api/internal/skills/stage") {
7976
- if (!skillRecorderEnabled(cfg))
7977
- return json(res, 403, { error: "learned skills are not enabled" });
8376
+ if (!skillAuthoringEnabled(cfg))
8377
+ return json(res, 403, { error: "skill authoring is not enabled in Settings" });
7978
8378
  if (!internalCapability.skillAuthoring) {
7979
8379
  return json(res, 403, { error: "skill authoring is not enabled for this turn" });
7980
8380
  }
@@ -8611,6 +9011,86 @@ const handleRequest = async (req, res) => {
8611
9011
  model: safeBot.modelSelection.model,
8612
9012
  });
8613
9013
  }
9014
+ if (method === "POST" && (path === "/api/internal/create-room" || path === "/api/internal/manage-room")) {
9015
+ const body = await readInternalBody();
9016
+ const chief = store.bot(internalCapability.botId);
9017
+ if (chief.hidden || !chief.chiefOfStaff || !connectorThread(chief.id, internalCapability.threadId)) {
9018
+ return json(res, 403, { error: "only an active section Chief of Staff can manage rooms" });
9019
+ }
9020
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
9021
+ return json(res, 400, { error: "room request must be a JSON object" });
9022
+ }
9023
+ // ponytail: no new room-administration approval flow. Fail closed for
9024
+ // chiefs whose peer changes need review; add a proposal card if needed.
9025
+ if (chief.approvePeerComms) {
9026
+ return json(res, 403, { error: "peer approval is required; ask the user to make this room change" });
9027
+ }
9028
+ // Section labels are a permission boundary, not bot-owned organization.
9029
+ // A Chief may not recruit excluded peers or acquire a foreign transcript.
9030
+ const allowedIds = new Set([chief.id, ...reachablePeers(store.bots, chief).map((bot) => bot.id)]);
9031
+ const allowedRoster = (ids) => ids.includes(chief.id) && ids.every((id) => allowedIds.has(id));
9032
+ if (body.section !== undefined && (typeof body.section !== "string" || sectionKey(body.section) !== sectionKey(chief.section))) {
9033
+ return json(res, 403, { error: "rooms must stay in your own section; ask the user to move them" });
9034
+ }
9035
+ if (path === "/api/internal/create-room") {
9036
+ if ((internalCapability.createdRooms ?? 0) >= 4) {
9037
+ return json(res, 429, { error: "you can create at most 4 rooms in one turn" });
9038
+ }
9039
+ const parsed = z.object({
9040
+ fromBotId: z.string().optional(), fromThreadId: z.string().optional(),
9041
+ name: z.string().trim().min(1).max(100), memberIds: z.array(z.string()).min(1).max(100),
9042
+ section: z.string().optional(), bulletin: z.string().max(12_000).optional(),
9043
+ }).strict().safeParse(body);
9044
+ if (!parsed.success)
9045
+ return json(res, 400, { error: "provide a room name, memberIds, and optional bulletin (at most 12000 characters)" });
9046
+ const memberIds = [...new Set([chief.id, ...parsed.data.memberIds])];
9047
+ if (!allowedRoster(memberIds)) {
9048
+ return json(res, 403, { error: "include yourself and only active, allowed peers from your section" });
9049
+ }
9050
+ const group = createChannel({
9051
+ name: redactSecretsInText(parsed.data.name), memberIds, section: chief.section,
9052
+ setup: { bulletin: redactSecretsInText(parsed.data.bulletin ?? ""), defaultResponder: { kind: "member", botId: chief.id } },
9053
+ });
9054
+ internalCapability.createdRooms = (internalCapability.createdRooms ?? 0) + 1;
9055
+ return json(res, 201, { id: group.id, name: group.name, section: group.section || "General", memberIds: group.memberIds, memberCount: group.memberIds.length });
9056
+ }
9057
+ const parsed = z.object({
9058
+ fromBotId: z.string().optional(), fromThreadId: z.string().optional(),
9059
+ roomId: z.string(), action: z.enum(["add_members", "remove_members", "set_members", "rename", "set_bulletin"]),
9060
+ memberIds: z.array(z.string()).min(1).max(100).optional(),
9061
+ name: z.string().trim().min(1).max(100).optional(), bulletin: z.string().max(12_000).optional(),
9062
+ }).strict().safeParse(body);
9063
+ if (!parsed.success)
9064
+ return json(res, 400, { error: "provide roomId and a supported room action; section moves are user-only" });
9065
+ const room = store.group(parsed.data.roomId);
9066
+ if (!room || room.dm || sectionKey(room.section) !== sectionKey(chief.section) || !allowedRoster(room.memberIds)) {
9067
+ return json(res, 403, { error: "you can only manage rooms you belong to with allowed peers in your own section" });
9068
+ }
9069
+ const { action, memberIds, name, bulletin } = parsed.data;
9070
+ const patch = {};
9071
+ if (action === "rename") {
9072
+ if (name === undefined)
9073
+ return json(res, 400, { error: "rename requires a name" });
9074
+ patch.name = redactSecretsInText(name);
9075
+ }
9076
+ else if (action === "set_bulletin") {
9077
+ if (bulletin === undefined)
9078
+ return json(res, 400, { error: "set_bulletin requires bulletin text; use an empty string to clear it" });
9079
+ patch.bulletin = redactSecretsInText(bulletin);
9080
+ }
9081
+ else {
9082
+ if (!memberIds || memberIds.some((id) => !allowedIds.has(id))) {
9083
+ return json(res, 403, { error: "memberIds must name only yourself or active, allowed peers in your section" });
9084
+ }
9085
+ const next = action === "add_members" ? [...new Set([...room.memberIds, ...memberIds])]
9086
+ : action === "remove_members" ? room.memberIds.filter((id) => !memberIds.includes(id)) : memberIds;
9087
+ if (!allowedRoster(next))
9088
+ return json(res, 403, { error: "keep yourself in the room; only the user can remove its managing Chief" });
9089
+ patch.memberIds = next;
9090
+ }
9091
+ const updated = updateChannel(room.id, patch);
9092
+ return json(res, 200, { ok: true, memberIds: updated.memberIds, memberCount: updated.memberIds.length, message: `Updated room “${updated.name}”.` });
9093
+ }
8614
9094
  if (method === "POST" && path === "/api/internal/request-credential") {
8615
9095
  const body = await readInternalBody();
8616
9096
  const from = internalSender;
@@ -9447,47 +9927,7 @@ const handleRequest = async (req, res) => {
9447
9927
  }
9448
9928
  // ── channels (persisted internally as groups) ───────────────────────
9449
9929
  if (method === "POST" && path === "/api/groups") {
9450
- const body = await readBody(req);
9451
- if (!body || typeof body !== "object" || Array.isArray(body)) {
9452
- return json(res, 400, { error: "channel must be a JSON object" });
9453
- }
9454
- const roster = checkedMemberIds(body.memberIds);
9455
- if (!roster.ok)
9456
- return json(res, 400, { error: roster.error });
9457
- const { memberIds } = roster;
9458
- if (body.name !== undefined && typeof body.name !== "string") {
9459
- return json(res, 400, { error: "channel name must be a string" });
9460
- }
9461
- const name = body.name?.trim() || `${store.bot(memberIds[0]).name} & co.`;
9462
- if (name.length > 100)
9463
- return json(res, 400, { error: "channel name must be at most 100 characters" });
9464
- let section;
9465
- if (body.section !== undefined && body.section !== null) {
9466
- if (typeof body.section !== "string")
9467
- return json(res, 400, { error: "context must be a string" });
9468
- section = body.section.trim() || undefined;
9469
- if (section && section.length > 60) {
9470
- return json(res, 400, { error: "context must be at most 60 characters" });
9471
- }
9472
- }
9473
- let setup;
9474
- if (body.setup !== undefined) {
9475
- if (!body.setup || typeof body.setup !== "object" || Array.isArray(body.setup)) {
9476
- return json(res, 400, { error: "setup must be an object" });
9477
- }
9478
- const requested = body.setup;
9479
- if (typeof requested.bulletin !== "string") {
9480
- return json(res, 400, { error: "setup.bulletin must be a string" });
9481
- }
9482
- if (requested.bulletin.length > 12_000) {
9483
- return json(res, 400, { error: "setup.bulletin must be at most 12000 characters" });
9484
- }
9485
- const responder = checkedGroupResponder(requested.defaultResponder, memberIds);
9486
- if (!responder)
9487
- return json(res, 400, { error: "invalid setup.defaultResponder" });
9488
- setup = { bulletin: requested.bulletin, defaultResponder: responder, completed: true };
9489
- }
9490
- const group = store.createGroup(name, memberIds, false, section, setup);
9930
+ const group = createChannel(await readBody(req));
9491
9931
  return json(res, 201, { group: { ...publicGroupState(group), messages: [] } });
9492
9932
  }
9493
9933
  if (method === "POST" && path === "/api/teams/export") {
@@ -9506,12 +9946,17 @@ const handleRequest = async (req, res) => {
9506
9946
  return json(res, 200, createTeamBackup(store, routines.listRoutines(), name));
9507
9947
  }
9508
9948
  if (body.format === "package") {
9949
+ const selectedBots = store.bots.filter((bot) => !bot.hidden);
9950
+ const skillNames = checkedExportSkillNames(body.skillIds, selectedBots);
9951
+ if (!skillNames.ok)
9952
+ return json(res, 400, { error: skillNames.error });
9509
9953
  const document = createBotPackageExport({
9510
9954
  name,
9511
9955
  authorName: profileName,
9512
- bots: store.bots,
9956
+ bots: selectedBots,
9513
9957
  groups: store.groups,
9514
9958
  routines: routines.listRoutines(),
9959
+ skillsByBot: collectExportSkills(selectedBots, skillNames.names),
9515
9960
  });
9516
9961
  return json(res, 200, {
9517
9962
  name: document.package.name,
@@ -9656,8 +10101,8 @@ const handleRequest = async (req, res) => {
9656
10101
  const pkg = packageDocument?.package;
9657
10102
  const importName = pkg?.name ?? manifest.team.name;
9658
10103
  const sourceMembers = pkg
9659
- ? pkg.agents.map((agent) => ({ member: packageAgentAsMember(agent), playbookKeys: agent.playbooks ?? [] }))
9660
- : manifest.team.members.map((member) => ({ member, playbookKeys: [] }));
10104
+ ? pkg.agents.map((agent) => ({ member: packageAgentAsMember(agent), playbookKeys: agent.playbooks ?? [], skillNames: agent.skills ?? [] }))
10105
+ : manifest.team.members.map((member) => ({ member, playbookKeys: [], skillNames: [] }));
9661
10106
  const importedBots = [];
9662
10107
  const createdGroups = [];
9663
10108
  const createdRoutineIds = [];
@@ -9680,6 +10125,7 @@ const handleRequest = async (req, res) => {
9680
10125
  }
9681
10126
  }
9682
10127
  const playbookByKey = new Map((pkg?.playbooks ?? []).map((playbook) => [playbook.key, playbook]));
10128
+ const packageSkillByName = new Map((pkg?.skills?.entries ?? []).map((skill) => [skill.name, skill]));
9683
10129
  for (const source of sourceMembers) {
9684
10130
  const member = source.member;
9685
10131
  // importedMemberProfile is the authority boundary: persona fields
@@ -9713,6 +10159,17 @@ const handleRequest = async (req, res) => {
9713
10159
  }
9714
10160
  : {}),
9715
10161
  });
10162
+ for (const skillName of source.skillNames) {
10163
+ const skill = packageSkillByName.get(skillName);
10164
+ if (!skill)
10165
+ throw new Error(`Package skill "${skillName}" is unavailable`);
10166
+ const installed = installSkill(created.id, skill.source ?? `package:${pkg.id}`, [
10167
+ { path: "SKILL.md", content: skill.instructions },
10168
+ ]);
10169
+ if ("error" in installed) {
10170
+ throw new Error(`Package skill "${skillName}" could not be imported: ${installed.error}`);
10171
+ }
10172
+ }
9716
10173
  memberIds.set(member.key, created.id);
9717
10174
  }
9718
10175
  // A package is an explicit structure import: its rooms are created
@@ -9835,11 +10292,6 @@ const handleRequest = async (req, res) => {
9835
10292
  return json(res, 200, { group: publicGroupState(updated) });
9836
10293
  }
9837
10294
  // ── channel tasks: separate conversations for the same team ────────
9838
- const channelTaskBlocked = (group) => groupIsWorking(group) ||
9839
- store.groupTasks(group.id).some((task) => store.messagesFor(task.threadId).some((message) => message.kind === "options" &&
9840
- message.card?.requestId &&
9841
- !message.card.answered &&
9842
- !message.card.dismissed));
9843
10295
  // A scheduled goal starts in a detached task. Let the user open the
9844
10296
  // exact task that owns the live operation (or a durable approval card)
9845
10297
  // so they can observe or unblock it; switching to an unrelated task is
@@ -9955,113 +10407,7 @@ const handleRequest = async (req, res) => {
9955
10407
  if (field)
9956
10408
  return json(res, 403, { error: `forbidden: this session may rename or mark a room, not change "${field}" (needs the admin scope)` });
9957
10409
  }
9958
- if (!body || typeof body !== "object" || Array.isArray(body)) {
9959
- return json(res, 400, { error: "body must be a JSON object" });
9960
- }
9961
- const existing = store.group(m[1]);
9962
- if (!existing)
9963
- return json(res, 404, { error: "no such room" });
9964
- if (body.memberIds !== undefined && phoneSecretSubmissions.hasGroup(existing.id)) {
9965
- return json(res, 409, { error: "this channel is securely saving a credential — try again when it finishes" });
9966
- }
9967
- if (channelTaskBlocked(existing) &&
9968
- (body.memberIds !== undefined || body.defaultResponder !== undefined || body.bulletin !== undefined)) {
9969
- return json(res, 409, { error: "this channel is working or waiting on you — finish that turn first" });
9970
- }
9971
- const patch = {};
9972
- if (body.name !== undefined) {
9973
- if (typeof body.name !== "string")
9974
- return json(res, 400, { error: "room name must be a string" });
9975
- const name = body.name.trim();
9976
- if (!name)
9977
- return json(res, 400, { error: "room name must not be empty" });
9978
- if (name.length > 100)
9979
- return json(res, 400, { error: "room name must be at most 100 characters" });
9980
- patch.name = name;
9981
- }
9982
- if (body.bulletin !== undefined) {
9983
- if (typeof body.bulletin !== "string")
9984
- return json(res, 400, { error: "bulletin must be a string" });
9985
- if (body.bulletin.length > 12_000) {
9986
- return json(res, 400, { error: "bulletin must be at most 12000 characters" });
9987
- }
9988
- patch.bulletin = body.bulletin;
9989
- }
9990
- if (body.unread !== undefined) {
9991
- if (typeof body.unread !== "boolean")
9992
- return json(res, 400, { error: "unread must be true or false" });
9993
- patch.unread = body.unread;
9994
- }
9995
- if (body.memberIds !== undefined) {
9996
- // A DM is the pair it was opened for; only real rooms have a roster.
9997
- if (existing.dm)
9998
- return json(res, 400, { error: "direct-message channels cannot change members" });
9999
- const roster = checkedMemberIds(body.memberIds);
10000
- if (!roster.ok)
10001
- return json(res, 400, { error: roster.error.replace("channel", "room") });
10002
- const removedGoalLead = routines.listRoutines().some((routine) => routine.enabled &&
10003
- routine.target === "room-goal" &&
10004
- routine.groupId === existing.id &&
10005
- !roster.memberIds.includes(routine.botId)) || routines.listRuns().some((run) => run.target === "room-goal" &&
10006
- run.groupId === existing.id &&
10007
- ["queued", "running", "waiting"].includes(run.status) &&
10008
- !roster.memberIds.includes(run.botId));
10009
- if (removedGoalLead) {
10010
- return json(res, 409, {
10011
- error: "pause or reassign this room's team-goal routine before removing its lead",
10012
- });
10013
- }
10014
- patch.memberIds = roster.memberIds;
10015
- }
10016
- if (body.defaultResponder !== undefined) {
10017
- const memberIds = patch.memberIds ?? existing.memberIds;
10018
- const responder = checkedGroupResponder(body.defaultResponder, memberIds);
10019
- if (!responder)
10020
- return json(res, 400, { error: "invalid default responder" });
10021
- patch.defaultResponder = responder;
10022
- }
10023
- if (body.cwd !== undefined) {
10024
- if (existing.dm)
10025
- return json(res, 400, { error: "direct-message channels cannot have a working folder" });
10026
- if (existing.pinnedCwd !== undefined) {
10027
- return json(res, 409, { error: "the room's working folder is fixed after its first turn" });
10028
- }
10029
- const checked = validateBotCwd(body.cwd);
10030
- if (!checked.ok)
10031
- return json(res, 400, { error: checked.error });
10032
- patch.cwd = checked.cwd ?? undefined;
10033
- }
10034
- // one pinned message per room; null/"" clears. The id is not
10035
- // validated against the transcript here — a pin whose message was
10036
- // edited away or deleted simply resolves to nothing in the UI.
10037
- if (body.pinnedMessageId !== undefined) {
10038
- if (body.pinnedMessageId === null || body.pinnedMessageId === "")
10039
- patch.pinnedMessageId = undefined;
10040
- else if (typeof body.pinnedMessageId === "string" && /^[\w-]+$/.test(body.pinnedMessageId)) {
10041
- patch.pinnedMessageId = body.pinnedMessageId;
10042
- }
10043
- else
10044
- return json(res, 400, { error: "pinnedMessageId must be a message id" });
10045
- }
10046
- // same contract as a bot's sidebar section: null/"" clears, 60 chars max
10047
- if (body.section !== undefined) {
10048
- if (body.section === null)
10049
- patch.section = undefined;
10050
- else if (typeof body.section !== "string")
10051
- return json(res, 400, { error: "section must be a string" });
10052
- else {
10053
- const trimmed = body.section.trim();
10054
- if (!trimmed)
10055
- patch.section = undefined;
10056
- else if (trimmed.length > 60)
10057
- return json(res, 400, { error: "section must be at most 60 characters" });
10058
- else
10059
- patch.section = trimmed;
10060
- }
10061
- }
10062
- const group = store.patchGroup(m[1], patch);
10063
- if (!group)
10064
- return json(res, 404, { error: "no such room" });
10410
+ const group = updateChannel(m[1], body);
10065
10411
  return json(res, 200, { group: publicGroupState(group) });
10066
10412
  }
10067
10413
  m = path.match(/^\/api\/groups\/([\w-]+)\/read$/);
@@ -10123,6 +10469,13 @@ const handleRequest = async (req, res) => {
10123
10469
  return json(res, 400, { error: "threadId must be a task id" });
10124
10470
  }
10125
10471
  const threadId = body.threadId ?? group.threadId;
10472
+ noteTurnTrigger(threadId, auth);
10473
+ try {
10474
+ assertWithinBudget(cfg, DATA_DIR);
10475
+ }
10476
+ catch (error) {
10477
+ return json(res, 409, { error: error instanceof Error ? error.message : String(error), code: "spend_cap" });
10478
+ }
10126
10479
  const ownsThread = group.dm
10127
10480
  ? group.threadId === threadId
10128
10481
  : Boolean(store.groupTaskByThread(group.id, threadId));
@@ -10602,6 +10955,27 @@ const handleRequest = async (req, res) => {
10602
10955
  return json(res, 400, { error: "composio must be true or false" });
10603
10956
  patch.composio = body.composio;
10604
10957
  }
10958
+ // Per-bot selection of app-wide MCP servers. Omitted keeps the current
10959
+ // selection; null restores all enabled servers; [] explicitly mounts none.
10960
+ let requestedMcpServers = existingBot?.mcpServers;
10961
+ if (body.mcpServers !== undefined) {
10962
+ if (auth.kind === "session" && !sessions.isLive(auth.session.id)) {
10963
+ return json(res, 401, { error: "unauthorized: this session has expired or was revoked" });
10964
+ }
10965
+ if (body.mcpServers === null) {
10966
+ requestedMcpServers = undefined;
10967
+ }
10968
+ else if (!Array.isArray(body.mcpServers) || body.mcpServers.some((t) => typeof t !== "string" || mcpServerNameError(t) !== null)) {
10969
+ return json(res, 400, { error: "mcpServers must be a list of server names, or null" });
10970
+ }
10971
+ else {
10972
+ requestedMcpServers = [...new Set(body.mcpServers)];
10973
+ if (requestedMcpServers.length > MAX_MCP_SERVERS) {
10974
+ return json(res, 400, { error: `Select at most ${MAX_MCP_SERVERS} MCP servers.` });
10975
+ }
10976
+ }
10977
+ patch.mcpServers = requestedMcpServers;
10978
+ }
10605
10979
  // per-bot gate on the app's built-in browser
10606
10980
  if (body.browser !== undefined) {
10607
10981
  if (typeof body.browser !== "boolean")
@@ -10818,6 +11192,10 @@ const handleRequest = async (req, res) => {
10818
11192
  if (Array.isArray(patch.alwaysAllow) && patch.alwaysAllow.some((key) => !(existingBot?.alwaysAllow ?? []).includes(key))) {
10819
11193
  loosened.push("alwaysAllow");
10820
11194
  }
11195
+ if (body.mcpServers !== undefined && Array.isArray(existingBot?.mcpServers) &&
11196
+ (requestedMcpServers === undefined || requestedMcpServers.some((name) => !existingBot.mcpServers.includes(name)))) {
11197
+ loosened.push("mcpServers");
11198
+ }
10821
11199
  const browserOrigin = typeof req.headers.origin === "string" && req.headers.origin.trim() !== "";
10822
11200
  if (loosened.length && auth.kind === "loopback" && !DESKTOP_MANAGED && !browserOrigin) {
10823
11201
  if (store.bots.some((candidate) => candidate.busy)) {
@@ -10846,6 +11224,23 @@ const handleRequest = async (req, res) => {
10846
11224
  sectionKey(existingBot?.section) !== sectionKey(section);
10847
11225
  let bot;
10848
11226
  const freshBrowserBot = store.bot(m[1]);
11227
+ // Custom servers are process configuration: a running turn cannot
11228
+ // unmount them. Recheck after any awaited runtime revocation above.
11229
+ if (body.mcpServers !== undefined) {
11230
+ if (auth.kind === "session" && !sessions.isLive(auth.session.id)) {
11231
+ return json(res, 401, { error: "unauthorized: this session has expired or was revoked" });
11232
+ }
11233
+ if (Array.isArray(freshBrowserBot?.mcpServers) &&
11234
+ (requestedMcpServers === undefined || requestedMcpServers.some((name) => !freshBrowserBot.mcpServers.includes(name))) &&
11235
+ auth.kind === "loopback" && !DESKTOP_MANAGED && !browserOrigin && store.bots.some((candidate) => candidate.busy)) {
11236
+ return json(res, 409, { error: "A bot is working right now, so this change has to come from the desktop app or a paired device. Try again once every bot is idle." });
11237
+ }
11238
+ if (freshBrowserBot &&
11239
+ JSON.stringify(requestedMcpServers?.toSorted()) !== JSON.stringify(freshBrowserBot.mcpServers?.toSorted()) &&
11240
+ (freshBrowserBot.busy || activeGroupTurnForBot(freshBrowserBot.id))) {
11241
+ return json(res, 409, { error: "Stop this bot's turns before changing its MCP servers." });
11242
+ }
11243
+ }
10849
11244
  if (normalizedSelection && selectedTask) {
10850
11245
  const current = store.projectBotForTask(selectedTask.id, selectedTask.threadId);
10851
11246
  if (!current)
@@ -11339,31 +11734,130 @@ const handleRequest = async (req, res) => {
11339
11734
  broadcast({ kind: "bot", bot: visible });
11340
11735
  return json(res, 200, { bot: visible });
11341
11736
  }
11342
- // ── bot memory: MEMORY.md + memory/ topic files ─────────────────────
11343
- // The files already belong to the user (plain markdown in the bot's
11344
- // workspace); these routes only make them visible without a trip to
11345
- // the filesystem. Reads never create the workspace — a bot that has
11346
- // not run yet simply has nothing to show.
11737
+ // ── bot memory: MEMORY.md + memory/ topic and log files ──────────────
11738
+ // The files already belong to the person (plain markdown in the bot's
11739
+ // workspace). server/memory-store.ts decides which paths can be reached
11740
+ // and refuses a save whose expectedHash no longer matches the file;
11741
+ // server/memory-journal.ts records every change made here so it can be
11742
+ // read back and reverted. Reads never create the workspace — a bot that
11743
+ // has not run yet simply has nothing to show. Admin scope by default
11744
+ // (request-auth.ts), like the profile routes above.
11347
11745
  m = path.match(/^\/api\/bots\/([\w-]+)\/memory$/);
11348
11746
  if (m && method === "GET") {
11349
11747
  if (!store.bot(m[1]))
11350
11748
  return json(res, 404, { error: "no such bot" });
11351
- return json(res, 200, { ...readMemoryFile(m[1]), topics: listMemoryTopics(m[1]) });
11749
+ try {
11750
+ const overview = memoryOverview(m[1]);
11751
+ // `text` and `truncated` ride along one release for clients of the
11752
+ // old whole-file shape; the panel reads the file through /memory/file
11753
+ return json(res, 200, { ...overview, text: readMemoryDoc(m[1], MEMORY_INDEX).text, truncated: overview.index.truncated });
11754
+ }
11755
+ catch (error) {
11756
+ return replyMemoryError(res, error);
11757
+ }
11352
11758
  }
11353
11759
  if (m && method === "PUT") {
11760
+ // The pre-panel whole-file write, kept one release: no hash check, so
11761
+ // it can still overwrite a note the bot just wrote — journaled as the
11762
+ // person's so at least the journal can undo it.
11354
11763
  if (!store.bot(m[1]))
11355
11764
  return json(res, 404, { error: "no such bot" });
11356
11765
  const parsed = z.object({ text: z.string() }).safeParse(await readBody(req));
11357
11766
  if (!parsed.success)
11358
11767
  return json(res, 400, { error: "text must be a string" });
11359
- if (Buffer.byteLength(parsed.data.text, "utf8") > MEMORY_FILE_MAX_BYTES) {
11360
- return json(res, 400, {
11361
- error: `memory is capped at ${MEMORY_FILE_MAX_BYTES / 1024}KB — move longer notes into memory/<topic>.md files`,
11768
+ try {
11769
+ const { doc } = journalMemoryWrite(m[1], MEMORY_INDEX, parsed.data.text, { actor: "person", via: "api" });
11770
+ return json(res, 200, { ok: true, hash: doc.hash, truncated: memoryCapacity(doc.text).truncated });
11771
+ }
11772
+ catch (error) {
11773
+ // the old route answered 400 for an oversized body; keep that for
11774
+ // its callers while the new route says 413
11775
+ if (error instanceof MemoryStoreError && error.code === "too-large")
11776
+ return json(res, 400, { error: error.message });
11777
+ return replyMemoryError(res, error);
11778
+ }
11779
+ }
11780
+ m = path.match(/^\/api\/bots\/([\w-]+)\/memory\/file$/);
11781
+ if (m && method === "GET") {
11782
+ if (!store.bot(m[1]))
11783
+ return json(res, 404, { error: "no such bot" });
11784
+ try {
11785
+ return json(res, 200, readMemoryDoc(m[1], url.searchParams.get("path") ?? MEMORY_INDEX));
11786
+ }
11787
+ catch (error) {
11788
+ return replyMemoryError(res, error);
11789
+ }
11790
+ }
11791
+ if (m && method === "PUT") {
11792
+ if (!store.bot(m[1]))
11793
+ return json(res, 404, { error: "no such bot" });
11794
+ const parsed = z
11795
+ .object({ path: z.string().default(MEMORY_INDEX), text: z.string(), expectedHash: z.string().optional() })
11796
+ .safeParse(await readBody(req));
11797
+ if (!parsed.success)
11798
+ return json(res, 400, { error: "text must be a string; path and expectedHash are optional strings" });
11799
+ try {
11800
+ const { doc, entry } = journalMemoryWrite(m[1], parsed.data.path, parsed.data.text, {
11801
+ actor: "person",
11802
+ via: "ui",
11803
+ expectedHash: parsed.data.expectedHash,
11362
11804
  });
11805
+ return json(res, 200, { ok: true, ...doc, entry: entry ? journalEntryForClient(m[1], entry) : null, overview: memoryOverview(m[1]) });
11806
+ }
11807
+ catch (error) {
11808
+ return replyMemoryError(res, error);
11363
11809
  }
11364
- writeMemoryFile(m[1], parsed.data.text);
11365
- // truncated echoes back so the editor can warn about the load budget
11366
- return json(res, 200, { ok: true, truncated: readMemoryFile(m[1]).truncated });
11810
+ }
11811
+ if (m && method === "DELETE") {
11812
+ if (!store.bot(m[1]))
11813
+ return json(res, 404, { error: "no such bot" });
11814
+ const file = url.searchParams.get("path") ?? "";
11815
+ try {
11816
+ const entry = journalMemoryDelete(m[1], file, { actor: "person", via: "ui" });
11817
+ return json(res, 200, { ok: true, path: file, entry: entry ? journalEntryForClient(m[1], entry) : null, overview: memoryOverview(m[1]) });
11818
+ }
11819
+ catch (error) {
11820
+ return replyMemoryError(res, error);
11821
+ }
11822
+ }
11823
+ m = path.match(/^\/api\/bots\/([\w-]+)\/memory\/journal$/);
11824
+ if (m && method === "GET") {
11825
+ if (!store.bot(m[1]))
11826
+ return json(res, 404, { error: "no such bot" });
11827
+ // the row a save queued a moment ago may not have reached disk yet
11828
+ await flushMemoryJournal(m[1]);
11829
+ const limit = Math.min(200, Math.max(1, Number(url.searchParams.get("limit") ?? 50) || 50));
11830
+ const botId = m[1];
11831
+ return json(res, 200, { entries: readMemoryJournal(botId, limit).map((entry) => journalEntryForClient(botId, entry)) });
11832
+ }
11833
+ m = path.match(/^\/api\/bots\/([\w-]+)\/memory\/journal\/([\w-]+)\/revert$/);
11834
+ if (m && method === "POST") {
11835
+ if (!store.bot(m[1]))
11836
+ return json(res, 404, { error: "no such bot" });
11837
+ await flushMemoryJournal(m[1]);
11838
+ const result = revertMemoryChange(m[1], m[2]);
11839
+ if (!result.ok)
11840
+ return json(res, result.status, { error: result.error });
11841
+ return json(res, 200, { ok: true, ...result.doc, entry: result.entry ? journalEntryForClient(m[1], result.entry) : null, overview: memoryOverview(m[1]) });
11842
+ }
11843
+ m = path.match(/^\/api\/bots\/([\w-]+)\/memory\/open$/);
11844
+ if (m && method === "POST") {
11845
+ if (!store.bot(m[1]))
11846
+ return json(res, 404, { error: "no such bot" });
11847
+ const parsed = z.object({ target: z.enum(["obsidian", "folder"]) }).safeParse(await readBody(req));
11848
+ if (!parsed.success)
11849
+ return json(res, 400, { error: "target must be obsidian or folder" });
11850
+ // The folder is on this machine's disk; opening it only makes sense
11851
+ // from this machine. A paired phone or a remote browser gets the path
11852
+ // to open by hand instead.
11853
+ const workspacePath = memoryOverview(m[1]).workspacePath;
11854
+ if (auth.kind !== "loopback") {
11855
+ return json(res, 403, { error: `This only works on the computer running OpenMausBot. The memory folder there is ${workspacePath}`, workspacePath });
11856
+ }
11857
+ const opened = await openMemoryLocation(m[1], parsed.data.target);
11858
+ if (!opened.ok)
11859
+ return json(res, 500, { error: opened.error, workspacePath: opened.workspacePath });
11860
+ return json(res, 200, opened);
11367
11861
  }
11368
11862
  m = path.match(/^\/api\/bots\/([\w-]+)\/memory\/topics\/([^/]+)$/);
11369
11863
  if (m && method === "GET") {
@@ -11488,6 +11982,15 @@ const handleRequest = async (req, res) => {
11488
11982
  // receipt after a task switch, while a genuinely new send still has to
11489
11983
  // target the task that is active now.
11490
11984
  const threadId = body.threadId ?? bot.threadId;
11985
+ noteTurnTrigger(threadId, auth);
11986
+ // The send is acknowledged before the turn starts, so a workspace at its
11987
+ // spend limit is refused here, where the person can see it.
11988
+ try {
11989
+ assertWithinBudget(cfg, DATA_DIR);
11990
+ }
11991
+ catch (error) {
11992
+ return json(res, 409, { error: error instanceof Error ? error.message : String(error), code: "spend_cap" });
11993
+ }
11491
11994
  if (!store.taskByThread(bot.id, threadId)) {
11492
11995
  return json(res, 409, { error: "the bot switched tasks before it could receive the message" });
11493
11996
  }
@@ -12263,6 +12766,39 @@ const handleRequest = async (req, res) => {
12263
12766
  }
12264
12767
  return json(res, 202, { installing: true });
12265
12768
  }
12769
+ // ── the fleet: client workspaces on this server, through the root agent ──
12770
+ // Admin scope by default plus the `admin` entitlement; the socket's own
12771
+ // permissions decide whether this workspace may drive the agent at all.
12772
+ const fleetRoute = /^\/api\/fleet(?:\/(workspaces(?:\/([a-z0-9-]+)(?:\/(users|suspend|resume))?)?|upgrade))?$/.exec(path);
12773
+ if (fleetRoute) {
12774
+ if (!entitled("admin"))
12775
+ return json(res, 403, { error: "Workspaces need an enterprise licence with the admin feature." });
12776
+ const socket = fleetSocketPath();
12777
+ if (!fleetAvailable(socket))
12778
+ return json(res, 404, { error: "No fleet agent on this server. Run `openmausbot fleet init --domain … --operator <this user>` as root." });
12779
+ const [, resource, slug, sub] = fleetRoute;
12780
+ let forward = null;
12781
+ if (method === "GET" && !resource)
12782
+ forward = { method: "GET", path: "/workspaces" };
12783
+ else if (method === "POST" && resource === "workspaces")
12784
+ forward = { method: "POST", path: "/workspaces", body: await readBody(req, 256 * 1024) };
12785
+ else if (method === "POST" && resource === "upgrade")
12786
+ forward = { method: "POST", path: "/upgrade" };
12787
+ else if (slug && method === "POST" && (sub === "users" || sub === "suspend" || sub === "resume"))
12788
+ forward = { method: "POST", path: `/workspaces/${slug}/${sub}`, ...(sub === "users" ? { body: await readBody(req, 8192) } : {}) };
12789
+ else if (slug && method === "DELETE" && !sub)
12790
+ forward = { method: "DELETE", path: `/workspaces/${slug}`, body: await readBody(req, 8192) };
12791
+ if (!forward)
12792
+ return json(res, 405, { error: "no such fleet operation" });
12793
+ res.setHeader("cache-control", "no-store");
12794
+ try {
12795
+ const reply = await fleetRequest(socket, forward.method, forward.path, forward.body);
12796
+ return json(res, reply.status, reply.body ?? {});
12797
+ }
12798
+ catch (error) {
12799
+ return json(res, 502, { error: error instanceof Error ? error.message : String(error) });
12800
+ }
12801
+ }
12266
12802
  // Which edition this server runs and why (see server/enterprise.ts). Read-only.
12267
12803
  if (method === "GET" && path === "/api/edition") {
12268
12804
  return json(res, 200, editionStatus());
@@ -12293,6 +12829,62 @@ const handleRequest = async (req, res) => {
12293
12829
  // Read-only like the inspector above: the rows were written at the
12294
12830
  // request.opened fold and in answerRequest; this only reads them back,
12295
12831
  // newest last, same order as thread events.
12832
+ // ── the usage ledger: what this workspace spent over a period ──
12833
+ // Read-only over the month files usage-ledger.ts appends at turn.completed.
12834
+ // Admin scope by default, like every route not opened to clients.
12835
+ if (method === "GET" && (path === "/api/usage" || path === "/api/usage.csv")) {
12836
+ const range = parseUsageRange(url.searchParams.get("from"), url.searchParams.get("to"));
12837
+ if (!range)
12838
+ return json(res, 400, { error: "from and to must be YYYY-MM-DD, from no later than to, at most a year apart" });
12839
+ const rows = readUsage(DATA_DIR, range);
12840
+ // The operator's price list is applied only with the billing entitlement.
12841
+ const prices = entitled("billing") && cfg.billing?.prices && Object.keys(cfg.billing.prices).length ? cfg.billing.prices : null;
12842
+ if (path === "/api/usage.csv") {
12843
+ const stamp = (date) => date.toISOString().slice(0, 10);
12844
+ res.writeHead(200, {
12845
+ "content-type": "text/csv; charset=utf-8",
12846
+ "content-disposition": `attachment; filename="usage-${stamp(range.from)}-${stamp(range.to)}.csv"`,
12847
+ "cache-control": "no-store",
12848
+ });
12849
+ res.end(usageCsv(rows, prices));
12850
+ return;
12851
+ }
12852
+ const requested = url.searchParams.get("groupBy") ?? "bot";
12853
+ if (!USAGE_GROUPINGS.includes(requested)) {
12854
+ return json(res, 400, { error: `groupBy must be one of ${USAGE_GROUPINGS.join(", ")}` });
12855
+ }
12856
+ const groupBy = requested;
12857
+ res.setHeader("cache-control", "no-store");
12858
+ return json(res, 200, {
12859
+ from: range.from.toISOString(),
12860
+ to: range.to.toISOString(),
12861
+ groupBy,
12862
+ ...summarizeUsage(rows, groupBy, prices),
12863
+ budget: spendState(cfg, DATA_DIR),
12864
+ billing: prices ? { currency: cfg.billing?.currency ?? "USD" } : null,
12865
+ });
12866
+ }
12867
+ // ── provider key check: does a pasted or saved key open the provider's door ──
12868
+ // One read-only request from this server; the verdict never carries the
12869
+ // key. Admin scope by default, like every route not opened to clients.
12870
+ if (method === "POST" && path === "/api/keys/test") {
12871
+ const body = await readBody(req, 8192);
12872
+ const provider = body?.provider;
12873
+ if (!PROVIDER_KEY_KINDS.includes(provider)) {
12874
+ return json(res, 400, { error: `provider must be one of ${PROVIDER_KEY_KINDS.join(", ")}` });
12875
+ }
12876
+ const kind = provider;
12877
+ const saved = kind === "anthropic" ? cfg.anthropic : kind === "openaiCompat" ? cfg.openaiCompat : cfg.xai;
12878
+ const pasted = typeof body?.key === "string" ? body.key.trim() : "";
12879
+ const key = pasted || saved?.key || "";
12880
+ if (!key)
12881
+ return json(res, 400, { error: "No key to test. Paste one or save one first." });
12882
+ if (key.length > 512)
12883
+ return json(res, 400, { error: "That does not look like an API key." });
12884
+ const url = typeof body?.url === "string" && body.url.trim() ? body.url.trim() : saved?.url;
12885
+ res.setHeader("cache-control", "no-store");
12886
+ return json(res, 200, await checkProviderKey({ provider: kind, key, url }));
12887
+ }
12296
12888
  if (method === "GET" && path === "/api/decisions") {
12297
12889
  const rawLimit = url.searchParams.get("limit");
12298
12890
  const parsedLimit = rawLimit === null ? undefined : Number(rawLimit);
@@ -12615,6 +13207,42 @@ const handleRequest = async (req, res) => {
12615
13207
  mcpConfigBusy = false;
12616
13208
  }
12617
13209
  }
13210
+ // Paste-to-add: the {"mcpServers": {...}} block every other agent tool
13211
+ // writes. Same rules as POST: disabled until explicitly enabled.
13212
+ if (method === "POST" && path === "/api/mcp/servers/import") {
13213
+ if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
13214
+ return json(res, 415, { error: "content-type must be application/json" });
13215
+ }
13216
+ if (mcpConfigBusy)
13217
+ return json(res, 409, { error: "MCP servers are already being updated." });
13218
+ mcpConfigBusy = true;
13219
+ try {
13220
+ const body = await readBody(req);
13221
+ const text = typeof body?.json === "string" ? body.json : "";
13222
+ if (auth.kind === "session" && !sessions.isLive(auth.session.id)) {
13223
+ return json(res, 401, { error: "unauthorized: this session has expired or was revoked" });
13224
+ }
13225
+ if (!text.trim())
13226
+ return json(res, 400, { error: "Paste the JSON block first." });
13227
+ const parsed = parseMcpServersImport(text);
13228
+ if (!parsed.ok)
13229
+ return json(res, 400, { error: parsed.error });
13230
+ const current = cfg.mcpServers ?? {};
13231
+ const names = Object.keys(parsed.servers);
13232
+ const taken = names.filter((name) => Object.hasOwn(current, name));
13233
+ if (taken.length) {
13234
+ return json(res, 409, { error: `Already added: ${taken.join(", ")}. Remove or rename ${taken.length === 1 ? "it" : "them"} first.` });
13235
+ }
13236
+ if (Object.keys(current).length + names.length > MAX_MCP_SERVERS) {
13237
+ return json(res, 400, { error: `You can add at most ${MAX_MCP_SERVERS} MCP servers.` });
13238
+ }
13239
+ persistMcpServers({ ...current, ...parsed.servers });
13240
+ return json(res, 201, { ...mcpServerResponse(), added: names });
13241
+ }
13242
+ finally {
13243
+ mcpConfigBusy = false;
13244
+ }
13245
+ }
12618
13246
  const mcpServerRoute = /^\/api\/mcp\/servers\/([a-z][a-z0-9_-]{0,31})$/.exec(path);
12619
13247
  if (mcpServerRoute && ["PUT", "PATCH", "DELETE"].includes(method)) {
12620
13248
  if (method !== "DELETE" && !String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
@@ -13480,6 +14108,9 @@ const handleRequest = async (req, res) => {
13480
14108
  const status = e?.status ?? 500;
13481
14109
  return json(res, status, { error: e instanceof Error ? e.message : String(e) });
13482
14110
  }
14111
+ finally {
14112
+ releaseWorkspaceRequest?.();
14113
+ }
13483
14114
  };
13484
14115
  const server = createServer(handleRequest);
13485
14116
  calendarCalls.start();
@@ -13560,11 +14191,15 @@ const gracefulShutdown = createGracefulShutdown({
13560
14191
  await browserRuntime.closeAll();
13561
14192
  },
13562
14193
  () => flushAllProfileHistory(),
14194
+ () => flushAllMemoryJournals(),
14195
+ () => flushUsageLedger(DATA_DIR),
14196
+ () => flushDecisionLog(DATA_DIR),
13563
14197
  ],
13564
14198
  // Cleanup jobs run concurrently. Release only after they settle (or reach
13565
14199
  // the shutdown deadline), immediately before the process exits, so no new
13566
14200
  // server can overlap with a still-mutating old one.
13567
14201
  exit: (code) => {
14202
+ closeMessageDb();
13568
14203
  releaseDataDirLeaseAtExit();
13569
14204
  process.exit(code);
13570
14205
  },