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.
- package/dist/assets/index-BFPYw6P-.js +296 -0
- package/dist/assets/{index-D1RGhLUK.js → index-Bpai86ua.js} +1 -1
- package/dist/assets/index-D1odUdTi.css +1 -0
- package/dist/index.html +2 -2
- package/dist-server/companion/src/listener.js +12 -2
- package/dist-server/container-mcp.js +28 -2
- package/dist-server/drivers/agents-proxy.js +202 -16
- package/dist-server/drivers/pi-mcp-extension.ts +1 -1
- package/dist-server/index.js +9443 -3023
- package/dist-server/local-computer.js +6 -1
- package/dist-server/mcp-gate.js +543 -0
- package/dist-server/mcp-server.js +38 -0
- package/dist-server/openmausbot.js +1893 -583
- package/dist-server/pair-cli.js +1893 -583
- package/dist-server/proxy-paths.js +1 -0
- package/dist-server/server/agent-tool-policy.js +29 -0
- package/dist-server/server/bot-overview.js +28 -0
- package/dist-server/server/bot-package.js +55 -2
- package/dist-server/server/browser-engine.js +2 -1
- package/dist-server/server/browser-live.js +12 -7
- package/dist-server/server/calendar-calls.js +1 -0
- package/dist-server/server/cli.js +102 -1
- package/dist-server/server/config.js +94 -6
- package/dist-server/server/drivers/acp/core.js +2 -0
- package/dist-server/server/drivers/acp/cursor.js +1 -1
- package/dist-server/server/drivers/acp/grok.js +5 -1
- package/dist-server/server/drivers/agents-proxy.js +195 -16
- package/dist-server/server/drivers/boxagent.js +1 -1
- package/dist-server/server/drivers/claude.js +301 -23
- package/dist-server/server/drivers/codex.js +13 -4
- package/dist-server/server/drivers/pi.js +1 -1
- package/dist-server/server/fleet-agent.js +161 -0
- package/dist-server/server/fleet-cli.js +225 -0
- package/dist-server/server/fleet-client.js +45 -0
- package/dist-server/server/fleet.js +410 -0
- package/dist-server/server/harness/bus.js +1 -1
- package/dist-server/server/index.js +836 -201
- package/dist-server/server/local-computer.js +5 -1
- package/dist-server/server/mcp-gate-config.js +56 -0
- package/dist-server/server/mcp-gate.js +227 -0
- package/dist-server/server/mcp-registry.js +71 -3
- package/dist-server/server/mcp-trim.js +182 -0
- package/dist-server/server/memory-journal.js +440 -0
- package/dist-server/server/memory-store.js +308 -0
- package/dist-server/server/message-db.js +90 -3
- package/dist-server/server/package-export.js +25 -0
- package/dist-server/server/peer-approval.js +1 -1
- package/dist-server/server/prices.js +16 -0
- package/dist-server/server/provider-auth-sessions.js +3 -0
- package/dist-server/server/provider-key-check.js +66 -0
- package/dist-server/server/proxy-paths.js +1 -0
- package/dist-server/server/redact.js +15 -0
- package/dist-server/server/resume-recovery.js +46 -0
- package/dist-server/server/routines.js +1 -0
- package/dist-server/server/screen-frame-gate.js +9 -6
- package/dist-server/server/skill-learn.js +15 -1
- package/dist-server/server/skill-library.js +5 -5
- package/dist-server/server/spend.js +61 -0
- package/dist-server/server/store.js +7 -3
- package/dist-server/server/system-prompt.js +28 -6
- package/dist-server/server/tailscale.js +2 -2
- package/dist-server/server/team-backup.js +35 -0
- package/dist-server/server/tool-summary.js +39 -0
- package/dist-server/server/tts/speech-text.js +3 -1
- package/dist-server/server/turn-context.js +18 -0
- package/dist-server/server/usage-ledger.js +239 -0
- package/dist-server/server/webhook-ingress.js +7 -2
- package/dist-server/server/workspace-backup-http.js +264 -0
- package/dist-server/server/workspace-backup-maintenance.js +40 -0
- package/dist-server/server/workspace-backup-policy.js +28 -0
- package/dist-server/server/workspace-backup.js +1022 -0
- package/dist-server/server/workspace.js +321 -22
- package/dist-server/shared/approval-mode.js +1 -1
- package/dist-server/shared/learn-request.js +6 -0
- package/dist-server/shared/team-backup.js +13 -2
- package/dist-server/shared/workspace-backup-client.js +15 -0
- package/dist-server/shared/workspace-backup.js +1 -0
- package/dist-server/vps-container-mcp.js +28 -2
- package/package.json +1 -1
- package/skills/create-verification-skill/SKILL.md +2 -3
- package/dist/assets/index-B2F__IRS.css +0 -1
- 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,
|
|
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,
|
|
75
|
-
import {
|
|
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:
|
|
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
|
|
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 =
|
|
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, {
|
|
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 =
|
|
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
|
-
|
|
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
|
-
])
|
|
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
|
-
|
|
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 === "
|
|
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 (!
|
|
7961
|
-
return json(res, 403, { error: "
|
|
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 (!
|
|
7977
|
-
return json(res, 403, { error: "
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
|
11344
|
-
// workspace)
|
|
11345
|
-
//
|
|
11346
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
11360
|
-
|
|
11361
|
-
|
|
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
|
-
|
|
11365
|
-
|
|
11366
|
-
|
|
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
|
},
|