openmausbot 0.1.69 → 0.1.71

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/dist/assets/{index-BmHupgVJ.js → index-CJtyBonr.js} +1 -1
  2. package/dist/assets/index-CYLdYtYp.css +1 -0
  3. package/dist/assets/index-Di4ovwu5.js +289 -0
  4. package/dist/index.html +2 -2
  5. package/dist-server/container-mcp.js +6 -2
  6. package/dist-server/drivers/agents-proxy.js +305 -16
  7. package/dist-server/drivers/pi-mcp-extension.ts +1 -1
  8. package/dist-server/index.js +3141 -1124
  9. package/dist-server/local-computer-proxy.js +2 -0
  10. package/dist-server/local-computer.js +2 -1
  11. package/dist-server/mcp-gate.js +543 -0
  12. package/dist-server/mcp-server.js +38 -0
  13. package/dist-server/openmausbot.js +772 -260
  14. package/dist-server/pair-cli.js +772 -260
  15. package/dist-server/prepare-cloudflared.js +20 -3
  16. package/dist-server/proxy-paths.js +1 -0
  17. package/dist-server/server/agent-tool-policy.js +29 -0
  18. package/dist-server/server/bot-package.js +55 -2
  19. package/dist-server/server/browser-live.js +12 -7
  20. package/dist-server/server/config.js +40 -4
  21. package/dist-server/server/delegations.js +132 -74
  22. package/dist-server/server/drivers/acp/core.js +2 -0
  23. package/dist-server/server/drivers/acp/cursor.js +1 -1
  24. package/dist-server/server/drivers/acp/grok.js +5 -1
  25. package/dist-server/server/drivers/agents-proxy.js +324 -16
  26. package/dist-server/server/drivers/boxagent.js +1 -1
  27. package/dist-server/server/drivers/claude-login-auth.js +102 -9
  28. package/dist-server/server/drivers/claude.js +298 -21
  29. package/dist-server/server/drivers/codex.js +13 -4
  30. package/dist-server/server/drivers/pi.js +1 -1
  31. package/dist-server/server/engine-install.js +137 -0
  32. package/dist-server/server/env-path.js +12 -0
  33. package/dist-server/server/harness/bus.js +1 -1
  34. package/dist-server/server/harness/registry.js +31 -6
  35. package/dist-server/server/index.js +860 -215
  36. package/dist-server/server/local-computer.js +1 -1
  37. package/dist-server/server/mcp-gate-config.js +56 -0
  38. package/dist-server/server/mcp-gate.js +227 -0
  39. package/dist-server/server/mcp-trim.js +182 -0
  40. package/dist-server/server/memory-journal.js +440 -0
  41. package/dist-server/server/memory-store.js +308 -0
  42. package/dist-server/server/message-db.js +90 -3
  43. package/dist-server/server/package-export.js +25 -0
  44. package/dist-server/server/peer-approval.js +1 -1
  45. package/dist-server/server/peer-provenance.js +6 -2
  46. package/dist-server/server/proxy-paths.js +1 -0
  47. package/dist-server/server/redact.js +15 -0
  48. package/dist-server/server/resume-recovery.js +46 -0
  49. package/dist-server/server/routines.js +116 -55
  50. package/dist-server/server/screen-frame-gate.js +9 -6
  51. package/dist-server/server/skill-library.js +5 -5
  52. package/dist-server/server/steer-queue.js +20 -1
  53. package/dist-server/server/store.js +29 -7
  54. package/dist-server/server/system-prompt.js +14 -1
  55. package/dist-server/server/team-backup.js +53 -4
  56. package/dist-server/server/tool-summary.js +39 -0
  57. package/dist-server/server/tts/speech-text.js +3 -1
  58. package/dist-server/server/turn-context.js +18 -0
  59. package/dist-server/server/workspace.js +321 -22
  60. package/dist-server/shared/approval-mode.js +1 -1
  61. package/dist-server/shared/team-backup.js +14 -2
  62. package/dist-server/vps-container-mcp.js +6 -2
  63. package/package.json +1 -1
  64. package/skills/create-verification-skill/SKILL.md +2 -3
  65. package/dist/assets/index-COp5lMnv.js +0 -279
  66. package/dist/assets/index-Jk7TIeyW.css +0 -1
@@ -33,10 +33,11 @@ import { chiefOfStaffSystemPrompt } from "./chief-of-staff.js";
33
33
  import { peerAllowed, peerName, peerRosterSystemPrompt, reachablePeers, roomPeerRosterSystemPrompt, roomRosterLine } from "./peer-roster.js";
34
34
  import { openMausStatusSystemPrompt } from "./openmaus-status-capsule.js";
35
35
  import { containerComputerAction, containerComputerExists, containerComputerFrame, containerComputerMcp, containerComputerScreenshot, containerComputerStatus, containerRuntimeStatus, localVmRecreatableOnDemand, perBotLocalVmTarget, SHARED_LOCAL_VM_TARGET, setupCommands, } from "./container-computer.js";
36
- import { ensureDirs, instanceConfigs, loadConfig, localVmMaxInstances, localVmMode, parseConfigPatch, roomTurnTimeoutMinutes, maxConcurrentBotThreads, saveConfig, showToolCallsEnabled, skillRecorderEnabled, builtInBrowserEnabled, browserProfileReplacementConflict, browserProfilePartitionTarget, syncCredentialEnv, withInstanceCli, persistableInstanceConfigs, vpsSshAlias, DATA_DIR, EVENTS_DIR, NATIVE_DIR, customMcpServers, } from "./config.js";
36
+ import { ensureDirs, instanceConfigs, loadConfig, localVmMaxInstances, localVmMode, parseConfigPatch, roomTurnTimeoutMinutes, maxConcurrentBotThreads, saveConfig, showToolCallsEnabled, skillAuthoringEnabled, builtInBrowserEnabled, browserProfileReplacementConflict, browserProfilePartitionTarget, syncCredentialEnv, withInstanceCli, persistableInstanceConfigs, vpsSshAlias, DATA_DIR, EVENTS_DIR, NATIVE_DIR, customMcpServers, } from "./config.js";
37
37
  import { ComputerControl } from "./computer-control.js";
38
38
  import { MAX_REMOTE_COMMAND_LENGTH } from "./remote-computer.js";
39
39
  import { augmentedPath, findCliCandidates, resetPathCache } from "./env-path.js";
40
+ import { registerEnginesBinDir } from "./engine-install.js";
40
41
  import { describeSpawnFailure, execCli } from "./procs.js";
41
42
  import { blockedTarget, buildNotification } from "./notify.js";
42
43
  import { isEffortLevel, newId, } from "./contracts.js";
@@ -54,7 +55,7 @@ import { claimRecallCrossings, recallCrossingLabel } from "./recall-disclosure.j
54
55
  const SESSION_READ_MAX_CHARS = 8_000;
55
56
  import { promptWithReply, transcriptText } from "./replies.js";
56
57
  import { _loadPending, buildDelegationFailurePrompt, buildDelegationRevivalPrompt, DelegationWakeBudget, discardDelegations, drainDelegations, findDelegationReceipt, pendingDelegationInfo, pendingDelegationSnapshot, pendingThreads, queueDelegation, recordDelegationReceipt, releaseDelegationsWaitingOn, summarizeDelegatedActivity } from "./delegations.js";
57
- import { cancelSteeredMessage, drainSteeredMessages, onSteeredQueueChange, queuedSteerSnapshot, queuedSteeredMessage, queueSteeredMessage, } from "./steer-queue.js";
58
+ import { cancelSteeredMessage, drainSteeredMessages, onSteeredQueueChange, queuedSteerSnapshot, queuedSteeredMessage, queuedThreadPosition, queueSteeredMessage, } from "./steer-queue.js";
58
59
  import { cancelChannelMessage, drainChannelMessages, queuedChannelMessage, queueChannelMessage, } from "./channel-queue.js";
59
60
  import { acceptedSendMatch, parseSendId, sendFingerprint, SendSequencer, } from "./send-idempotency.js";
60
61
  import { EventBus } from "./harness/bus.js";
@@ -66,19 +67,21 @@ import { decideRoomPost, emptyRoomPostBudget } from "./room-post-budget.js";
66
67
  import { isProjectEmoji, mentionedBots, roomResponders, sectionKey, Store, } from "./store.js";
67
68
  import * as tts from "./tts/index.js";
68
69
  import { narrateTool, toUtterances } from "./tts/speech-text.js";
69
- import { buildTurnContext, engineIsFresh } from "./turn-context.js";
70
+ import { buildRecoveryText, buildTurnContext, engineIsFresh } from "./turn-context.js";
70
71
  import { extractTurnImages } from "./turn-images.js";
71
72
  import { TurnWatchdog } from "./turn-watchdog.js";
72
73
  import { TurnResources, workspaceResource } from "./turn-resources.js";
73
- import { ensureWorkspace, ensureTaskWorkspace, updateMemory, listMemoryTopics, isMemoryTopicName, memorySystemPrompt, SESSION_SEARCH_SYSTEM_PROMPT, workspaceDir, } from "./workspace.js";
74
- import { readMemoryFile, readMemoryTopic, writeMemoryFile, MEMORY_FILE_MAX_BYTES, } from "./workspace.js";
74
+ import { ensureWorkspace, ensureTaskWorkspace, updateMemory, appendMemoryLog, isMemoryTopicName, memorySystemPrompt, memorySourceLabel, searchMemoryFiles, SESSION_SEARCH_SYSTEM_PROMPT, workspaceDir, } from "./workspace.js";
75
+ import { readMemoryTopic } from "./workspace.js";
76
+ import { MEMORY_INDEX, MemoryStoreError, memoryCapacity, memoryOverview, openMemoryLocation, readMemoryDoc, } from "./memory-store.js";
77
+ import { beginMemoryTurn, endMemoryTurn, flushAllMemoryJournals, flushMemoryJournal, journalMemoryDelete, journalMemoryWrite, readMemoryJournal, revertMemoryChange, } from "./memory-journal.js";
75
78
  import { readSectionContext, sectionContextKey, sectionContextLabel, sectionContextSystemPrompt, writeSectionContext, SECTION_CONTEXT_MAX_BYTES, } from "./section-context.js";
76
- import { applyStagedSkillWrite, getStagedSkillWrite, installSkill, listSkills, listStagedSkillWrites, readSkillFile, rejectStagedSkillWrite, removeSkill, setSkillEnabled, skillsSystemPrompt, stageSkillWrite, } from "./skills.js";
79
+ import { applyStagedSkillWrite, getStagedSkillWrite, installSkill, listSkills, listStagedSkillWrites, isSkillName, readSkillFile, rejectStagedSkillWrite, removeSkill, setSkillEnabled, skillsSystemPrompt, stageSkillWrite, } from "./skills.js";
77
80
  import { fetchSkillFromSource } from "./skill-fetch.js";
78
81
  import { expandLearnTurnText, learnSource } from "./skill-learn.js";
79
82
  import { expandSetupTurnText, setupModeActive, setupSystemPrompt } from "./setup-mode.js";
80
83
  import { checkSoulDrift, readSoulDrift, soulFile, writeSoulMirror } from "./bot-folder.js";
81
- import { buildSystemPrompt, computerPrompt, mentionPrompt, COMPOSIO_PROMPT, CREDENTIAL_PROMPT, LEARN_PROMPT, PROFILE_PROMPT, ROUTINE_PROMPT, ROUTINE_EXECUTION_PROMPT, WEBHOOK_PROMPT, } from "./system-prompt.js";
84
+ 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";
82
85
  import { readCuaConnection, gatedLocalComputer } from "./local-computer.js";
83
86
  import { discoverExistingPerBotLocalVms, localVmInventoryEntry, shouldArmLocalVmIdle, } from "./local-vm-inventory.js";
84
87
  import { LocalVmIdleTimer } from "./local-vm-idle.js";
@@ -102,7 +105,7 @@ import { flushAllProfileHistory, flushProfileHistory, readHistory, recordProfile
102
105
  import { fetchBotDirectory, matchDirectoryBots } from "./bot-directory.js";
103
106
  import { scoutProject, suggestTeam } from "./project-scout.js";
104
107
  import { fetchGithubTeam, fetchLibraryTeam, fetchTeamCatalog } from "./team-library.js";
105
- import { isBotPackage, packageAgentAsMember, parseBotPackage, renderBotPackageMarkdown } from "./bot-package.js";
108
+ import { BOT_PACKAGE_MAX_SKILLS, isBotPackage, packageAgentAsMember, parseBotPackage, renderBotPackageMarkdown } from "./bot-package.js";
106
109
  import { createTeamManifest, importedMemberProfile, parseTeamManifest } from "./team-manifest.js";
107
110
  import { readThreadEvents } from "./thread-events.js";
108
111
  import { listenWebhookIngress, webhookCredential } from "./webhook-ingress.js";
@@ -208,6 +211,9 @@ function customDomainStatus() {
208
211
  };
209
212
  }
210
213
  const registry = new ProviderRegistry(BUILT_IN_DRIVERS);
214
+ // Engines installed from Settings live under the data directory and win over
215
+ // any other copy on PATH.
216
+ registerEnginesBinDir();
211
217
  const providerAuthSessions = new ProviderAuthSessions();
212
218
  await registry.load(instanceConfigs(cfg));
213
219
  const bundledSkills = loadBundledSkills();
@@ -426,6 +432,7 @@ function agentsIntegration(botId, threadId, depth, skillAuthoring, generation) {
426
432
  kind: "agents",
427
433
  skillAuthoring,
428
434
  createdBots: 0,
435
+ openedThreads: 0,
429
436
  });
430
437
  return {
431
438
  command: process.execPath,
@@ -672,7 +679,7 @@ async function browserIntegration(botId, profile, turn) {
672
679
  if (!turn)
673
680
  return { profile: partitionId, session, spec, integration: spec };
674
681
  const token = mintInternalCapability({ botId, ...turn, browserSession: session,
675
- kind: "browser", depth: 0, skillAuthoring: false, createdBots: 0 });
682
+ kind: "browser", depth: 0, skillAuthoring: false, createdBots: 0, openedThreads: 0 });
676
683
  return { profile: partitionId, session, spec, integration: {
677
684
  command: process.execPath, args: [SPAWNED_PROXIES.browser], env: {
678
685
  ...AGENTS_NODE_FLAG, OMB_BROWSER_TOKEN: token, OMB_HARNESS_URL: `http://127.0.0.1:${PORT}`,
@@ -708,6 +715,7 @@ function connectedAppsIntegration(botId, threadId, generation) {
708
715
  kind: "connectors",
709
716
  skillAuthoring: false,
710
717
  createdBots: 0,
718
+ openedThreads: 0,
711
719
  });
712
720
  return composio.mcpIntegration(cfg, {
713
721
  harnessUrl: `http://127.0.0.1:${PORT}`,
@@ -760,6 +768,7 @@ function controlIntegration(botId, threadId, generation, localVmTarget) {
760
768
  ...(localVmTarget ? { localVmTarget } : {}),
761
769
  skillAuthoring: false,
762
770
  createdBots: 0,
771
+ openedThreads: 0,
763
772
  }),
764
773
  };
765
774
  }
@@ -877,6 +886,54 @@ function checkedModelSelection(raw, current, requireAvailableModel = false) {
877
886
  }
878
887
  return { ok: true, selection };
879
888
  }
889
+ function checkedExportSkillNames(value, bots) {
890
+ // Sharing instructions is explicit: ordinary package exports include none.
891
+ if (value === undefined)
892
+ return { ok: true, names: [] };
893
+ if (!Array.isArray(value) || value.some((name) => typeof name !== "string" || !isSkillName(name))) {
894
+ return { ok: false, error: "skillIds must be a list of exact imported skill names" };
895
+ }
896
+ const names = [...value];
897
+ if (new Set(names).size !== names.length)
898
+ return { ok: false, error: "skillIds must not contain duplicates" };
899
+ if (names.length > BOT_PACKAGE_MAX_SKILLS)
900
+ return { ok: false, error: `skillIds must contain at most ${BOT_PACKAGE_MAX_SKILLS} names` };
901
+ const available = new Set(bots.flatMap((bot) => listSkills(bot.id).map((skill) => skill.name)));
902
+ const unknown = names.find((name) => !available.has(name));
903
+ if (unknown)
904
+ return { ok: false, error: `skillIds contains unknown imported skill "${unknown}"` };
905
+ return { ok: true, names };
906
+ }
907
+ function collectExportSkills(bots, names) {
908
+ const selected = new Set(names);
909
+ const byBot = new Map();
910
+ if (!selected.size)
911
+ return byBot;
912
+ for (const bot of bots) {
913
+ const assigned = [];
914
+ for (const listing of listSkills(bot.id)) {
915
+ if (!selected.has(listing.name))
916
+ continue;
917
+ const instructions = readSkillFile(bot.id, listing.name);
918
+ if (instructions === null) {
919
+ throw new Error(`Skill "${listing.name}" changed or is unavailable and cannot be exported safely`);
920
+ }
921
+ const skill = {
922
+ name: listing.name,
923
+ description: listing.description,
924
+ ...(listing.source ? { source: listing.source } : {}),
925
+ ...(listing.license ? { license: listing.license } : {}),
926
+ ...(listing.compatibility ? { compatibility: listing.compatibility } : {}),
927
+ instructions,
928
+ };
929
+ // The shared exporter validates duplicate bytes and metadata together.
930
+ assigned.push(skill);
931
+ }
932
+ if (assigned.length)
933
+ byBot.set(bot.id, assigned);
934
+ }
935
+ return byBot;
936
+ }
880
937
  function checkedGroupResponder(value, memberIds) {
881
938
  if (!value || typeof value !== "object" || Array.isArray(value))
882
939
  return null;
@@ -1003,7 +1060,7 @@ function previewSystemPrompt(bot) {
1003
1060
  id: "setup",
1004
1061
  label: "Setup",
1005
1062
  text: setupSystemPrompt(agentsMounted && setupModeActive({ soul: bot.soul, description: bot.description, text: "" }), {
1006
- skills: skillRecorderEnabled(cfg),
1063
+ skills: skillAuthoringEnabled(cfg),
1007
1064
  cwd: bot.cwd,
1008
1065
  }),
1009
1066
  },
@@ -1440,6 +1497,165 @@ function groupGoalCoordinatorTurnForEvent(event) {
1440
1497
  function groupIsWorking(group) {
1441
1498
  return Boolean(group.busyBotId) || Boolean(groupTurnOperations.get(group.id)?.size);
1442
1499
  }
1500
+ // The public and Chief room tools use the same synchronous validation and write.
1501
+ // Keep authorization at each ingress; no internal caller gains public admin scope.
1502
+ function createChannel(value) {
1503
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1504
+ throw Object.assign(new Error("channel must be a JSON object"), { status: 400 });
1505
+ }
1506
+ const body = value;
1507
+ const roster = checkedMemberIds(body.memberIds);
1508
+ if (!roster.ok)
1509
+ throw Object.assign(new Error(roster.error), { status: 400 });
1510
+ const { memberIds } = roster;
1511
+ if (body.name !== undefined && typeof body.name !== "string") {
1512
+ throw Object.assign(new Error("channel name must be a string"), { status: 400 });
1513
+ }
1514
+ const name = body.name?.trim() || `${store.bot(memberIds[0]).name} & co.`;
1515
+ if (name.length > 100)
1516
+ throw Object.assign(new Error("channel name must be at most 100 characters"), { status: 400 });
1517
+ let section;
1518
+ if (body.section !== undefined && body.section !== null) {
1519
+ if (typeof body.section !== "string")
1520
+ throw Object.assign(new Error("context must be a string"), { status: 400 });
1521
+ section = body.section.trim() || undefined;
1522
+ if (section && section.length > 60) {
1523
+ throw Object.assign(new Error("context must be at most 60 characters"), { status: 400 });
1524
+ }
1525
+ }
1526
+ let setup;
1527
+ if (body.setup !== undefined) {
1528
+ if (!body.setup || typeof body.setup !== "object" || Array.isArray(body.setup)) {
1529
+ throw Object.assign(new Error("setup must be an object"), { status: 400 });
1530
+ }
1531
+ const requested = body.setup;
1532
+ if (typeof requested.bulletin !== "string") {
1533
+ throw Object.assign(new Error("setup.bulletin must be a string"), { status: 400 });
1534
+ }
1535
+ if (requested.bulletin.length > 12_000) {
1536
+ throw Object.assign(new Error("setup.bulletin must be at most 12000 characters"), { status: 400 });
1537
+ }
1538
+ const responder = checkedGroupResponder(requested.defaultResponder, memberIds);
1539
+ if (!responder)
1540
+ throw Object.assign(new Error("invalid setup.defaultResponder"), { status: 400 });
1541
+ setup = { bulletin: requested.bulletin, defaultResponder: responder, completed: true };
1542
+ }
1543
+ return store.createGroup(name, memberIds, false, section, setup);
1544
+ }
1545
+ function updateChannel(groupId, value) {
1546
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1547
+ throw Object.assign(new Error("body must be a JSON object"), { status: 400 });
1548
+ }
1549
+ const body = value;
1550
+ const existing = store.group(groupId);
1551
+ if (!existing)
1552
+ throw Object.assign(new Error("no such room"), { status: 404 });
1553
+ if (body.memberIds !== undefined && phoneSecretSubmissions.hasGroup(existing.id)) {
1554
+ throw Object.assign(new Error("this channel is securely saving a credential — try again when it finishes"), { status: 409 });
1555
+ }
1556
+ if (channelTaskBlocked(existing) &&
1557
+ (body.memberIds !== undefined || body.defaultResponder !== undefined || body.bulletin !== undefined)) {
1558
+ throw Object.assign(new Error("this channel is working or waiting on you — finish that turn first"), { status: 409 });
1559
+ }
1560
+ const patch = {};
1561
+ if (body.name !== undefined) {
1562
+ if (typeof body.name !== "string")
1563
+ throw Object.assign(new Error("room name must be a string"), { status: 400 });
1564
+ const name = body.name.trim();
1565
+ if (!name)
1566
+ throw Object.assign(new Error("room name must not be empty"), { status: 400 });
1567
+ if (name.length > 100)
1568
+ throw Object.assign(new Error("room name must be at most 100 characters"), { status: 400 });
1569
+ patch.name = name;
1570
+ }
1571
+ if (body.bulletin !== undefined) {
1572
+ if (typeof body.bulletin !== "string")
1573
+ throw Object.assign(new Error("bulletin must be a string"), { status: 400 });
1574
+ if (body.bulletin.length > 12_000) {
1575
+ throw Object.assign(new Error("bulletin must be at most 12000 characters"), { status: 400 });
1576
+ }
1577
+ patch.bulletin = body.bulletin;
1578
+ }
1579
+ if (body.unread !== undefined) {
1580
+ if (typeof body.unread !== "boolean")
1581
+ throw Object.assign(new Error("unread must be true or false"), { status: 400 });
1582
+ patch.unread = body.unread;
1583
+ }
1584
+ if (body.memberIds !== undefined) {
1585
+ // A DM is the pair it was opened for; only real rooms have a roster.
1586
+ if (existing.dm)
1587
+ throw Object.assign(new Error("direct-message channels cannot change members"), { status: 400 });
1588
+ const roster = checkedMemberIds(body.memberIds);
1589
+ if (!roster.ok)
1590
+ throw Object.assign(new Error(roster.error.replace("channel", "room")), { status: 400 });
1591
+ const removedGoalLead = routines.listRoutines().some((routine) => routine.enabled &&
1592
+ routine.target === "room-goal" &&
1593
+ routine.groupId === existing.id &&
1594
+ !roster.memberIds.includes(routine.botId)) || routines.listRuns().some((run) => run.target === "room-goal" &&
1595
+ run.groupId === existing.id &&
1596
+ ["queued", "running", "waiting"].includes(run.status) &&
1597
+ !roster.memberIds.includes(run.botId));
1598
+ if (removedGoalLead) {
1599
+ throw Object.assign(new Error("pause or reassign this room's team-goal routine before removing its lead"), { status: 409 });
1600
+ }
1601
+ patch.memberIds = roster.memberIds;
1602
+ }
1603
+ if (body.defaultResponder !== undefined) {
1604
+ const memberIds = patch.memberIds ?? existing.memberIds;
1605
+ const responder = checkedGroupResponder(body.defaultResponder, memberIds);
1606
+ if (!responder)
1607
+ throw Object.assign(new Error("invalid default responder"), { status: 400 });
1608
+ patch.defaultResponder = responder;
1609
+ }
1610
+ if (body.cwd !== undefined) {
1611
+ if (existing.dm)
1612
+ throw Object.assign(new Error("direct-message channels cannot have a working folder"), { status: 400 });
1613
+ if (existing.pinnedCwd !== undefined) {
1614
+ throw Object.assign(new Error("the room's working folder is fixed after its first turn"), { status: 409 });
1615
+ }
1616
+ const checked = validateBotCwd(body.cwd);
1617
+ if (!checked.ok)
1618
+ throw Object.assign(new Error(checked.error), { status: 400 });
1619
+ patch.cwd = checked.cwd ?? undefined;
1620
+ }
1621
+ // one pinned message per room; null/"" clears. The id is not
1622
+ // validated against the transcript here — a pin whose message was
1623
+ // edited away or deleted simply resolves to nothing in the UI.
1624
+ if (body.pinnedMessageId !== undefined) {
1625
+ if (body.pinnedMessageId === null || body.pinnedMessageId === "")
1626
+ patch.pinnedMessageId = undefined;
1627
+ else if (typeof body.pinnedMessageId === "string" && /^[\w-]+$/.test(body.pinnedMessageId)) {
1628
+ patch.pinnedMessageId = body.pinnedMessageId;
1629
+ }
1630
+ else
1631
+ throw Object.assign(new Error("pinnedMessageId must be a message id"), { status: 400 });
1632
+ }
1633
+ // same contract as a bot's sidebar section: null/"" clears, 60 chars max
1634
+ if (body.section !== undefined) {
1635
+ if (body.section === null)
1636
+ patch.section = undefined;
1637
+ else if (typeof body.section !== "string")
1638
+ throw Object.assign(new Error("section must be a string"), { status: 400 });
1639
+ else {
1640
+ const trimmed = body.section.trim();
1641
+ if (!trimmed)
1642
+ patch.section = undefined;
1643
+ else if (trimmed.length > 60)
1644
+ throw Object.assign(new Error("section must be at most 60 characters"), { status: 400 });
1645
+ else
1646
+ patch.section = trimmed;
1647
+ }
1648
+ }
1649
+ const group = store.patchGroup(groupId, patch);
1650
+ if (!group)
1651
+ throw Object.assign(new Error("no such room"), { status: 404 });
1652
+ return group;
1653
+ }
1654
+ const channelTaskBlocked = (group) => groupIsWorking(group) ||
1655
+ store.groupTasks(group.id).some((task) => store.messagesFor(task.threadId).some((message) => message.kind === "options" &&
1656
+ message.card?.requestId &&
1657
+ !message.card.answered &&
1658
+ !message.card.dismissed));
1443
1659
  function publicGroupState(group) {
1444
1660
  return { ...group, working: groupIsWorking(group) };
1445
1661
  }
@@ -1751,6 +1967,13 @@ store.onChange((change) => {
1751
1967
  break;
1752
1968
  case "thread.deleted":
1753
1969
  routines?.forgetRoutineRequestReceiptsForThread(change.threadId);
1970
+ // A deleted destination must not strand an approval in an internal
1971
+ // task. Keep each run's snapshot and expose its execution as fallback.
1972
+ for (const run of routines?.listRuns() ?? []) {
1973
+ if (run.resultsThreadId === change.threadId || run.sourceThreadId === change.threadId) {
1974
+ syncRoutineRunToSource(run);
1975
+ }
1976
+ }
1754
1977
  broadcast({ kind: "bot.queued", queues: publicBotQueuedMessages() });
1755
1978
  break;
1756
1979
  case "bot": {
@@ -1885,7 +2108,7 @@ function broadcast(payload) {
1885
2108
  replayBuffer.push({ seq, kind, frame: kind === "screen" ? null : frame, clientFrame: kind === "screen" ? null : clientFrame });
1886
2109
  if (replayBuffer.length > REPLAY_MAX)
1887
2110
  replayBuffer.shift();
1888
- for (const client of [...sseClients]) {
2111
+ for (const client of Array.from(sseClients)) {
1889
2112
  if (!wants(client, kind))
1890
2113
  continue;
1891
2114
  // Screen frames are replaceable and durable events are not: see
@@ -2191,6 +2414,18 @@ bus.subscribe((event) => {
2191
2414
  else if (event.type !== "session.exited")
2192
2415
  watchdog.touch(event.threadId);
2193
2416
  });
2417
+ // Memory journal turn boundary (server/memory-journal.ts). A bot's own
2418
+ // file-tool writes to MEMORY.md and memory/ have no hook to tap, so the
2419
+ // diff against the baseline taken at dispatch is made when the turn
2420
+ // settles — session.exited too, because a turn that died may still have
2421
+ // written. Fire-and-forget by construction: endMemoryTurn swallows its own
2422
+ // failures and never reaches the fold below.
2423
+ bus.subscribe((event) => {
2424
+ if (shouldIgnoreProviderEvent(event))
2425
+ return;
2426
+ if (event.type === "turn.completed" || event.type === "session.exited")
2427
+ endMemoryTurn(event.threadId);
2428
+ });
2194
2429
  // Bots currently working with nobody at the keyboard — a webhook turn, or a
2195
2430
  // turn a webhook-driven bot handed to a teammate. Auto mode is a decision
2196
2431
  // someone made for turns they were present for, so these don't inherit it:
@@ -2602,7 +2837,7 @@ bus.subscribe((event) => {
2602
2837
  const existing = store.messagesFor(event.threadId).find((m) => m.id === messageId)?.tool;
2603
2838
  toolName = existing?.name ?? "tool";
2604
2839
  store.patchMessage(event.threadId, messageId, {
2605
- tool: { name: toolName, ok: event.ok, spoken: existing?.spoken },
2840
+ tool: { name: toolName, ok: event.ok, spoken: existing?.spoken, summary: existing?.summary },
2606
2841
  });
2607
2842
  toolMessageByItem.delete(itemKey);
2608
2843
  }
@@ -2634,7 +2869,7 @@ bus.subscribe((event) => {
2634
2869
  const message = pushMessage({
2635
2870
  role: "bot",
2636
2871
  kind: "activity",
2637
- tool: { name, spoken: narrateTool(name) ?? undefined },
2872
+ tool: { name, spoken: narrateTool(name) ?? undefined, summary: event.summary },
2638
2873
  });
2639
2874
  if (event.itemId)
2640
2875
  toolMessageByItem.set(`${event.threadId}:${event.itemId}`, message.id);
@@ -3002,13 +3237,12 @@ bus.subscribe((event) => {
3002
3237
  costUsd: event.cost ?? null,
3003
3238
  });
3004
3239
  const routineReportThread = routineRun ? routineSourceThread(routineRun) : null;
3005
- const routineReportGroup = routineReportThread ? store.groupByThread(routineReportThread) : undefined;
3006
- // Group-origin routines belong to that channel's unread state. Their
3007
- // hidden execution task should not light up the bot's 1:1 sidebar too.
3240
+ // A routine's result belongs to its reporting thread's unread state.
3241
+ // Its internal execution should not light up the sidebar as well.
3008
3242
  // Neither should a peer's hop: the exchange is already recorded in the
3009
3243
  // pair channel and chipped into both threads, which is the whole of
3010
3244
  // what the person needs to be able to find it.
3011
- if (!routineReportGroup && !internal)
3245
+ if (!routineReportThread && !internal)
3012
3246
  store.patchTask(bot.id, event.threadId, { unread: true });
3013
3247
  // A failed peer turn stays a chip too. The bot that delegated is woken
3014
3248
  // with the failure and answers the person in its own thread — buzzing
@@ -3357,13 +3591,27 @@ bus.subscribe((event) => {
3357
3591
  /** How a drained delegation becomes a real turn on the target. Shared by
3358
3592
  * the settle-time drain and the boot-time drain of what a previous process
3359
3593
  * left queued. */
3360
- const runDelegatedTurn = (toBotId, text, commsDepth, sourceThreadId, channel, taskId, sourceBotId) => {
3594
+ const runDelegatedTurn = (toBotId, rawText, commsDepth, sourceThreadId, channel, taskId, sourceBotId, openedThreadId) => {
3361
3595
  // startTurn REJECTS on an ordinary condition — busy target, deleted bot,
3362
3596
  // unavailable provider. Unhandled, that rejection is fatal to the
3363
3597
  // harness (Node's default), which in the packaged app kills the server
3364
3598
  // child. Every delegation failure has to land as a chip instead.
3365
- const targetThreadId = store.bot(toBotId)?.threadId;
3599
+ // A fresh-thread handoff runs in the thread the opener created — the
3600
+ // drain already dropped it if that thread is gone — never in whatever
3601
+ // the person is looking at.
3602
+ const targetThreadId = openedThreadId ?? store.bot(toBotId)?.threadId;
3366
3603
  const target = store.bot(toBotId);
3604
+ const opener = store.bot(sourceBotId);
3605
+ const unattended = isUnattended(sourceBotId, sourceThreadId);
3606
+ // The first line of an opened thread is another bot's words: it carries
3607
+ // the same provenance note every relayed peer line does, structurally
3608
+ // (peerAsk) as well as in the text.
3609
+ const peerAsk = openedThreadId && opener
3610
+ ? { botId: opener.id, name: opener.name, unattended: unattended || undefined }
3611
+ : undefined;
3612
+ const text = openedThreadId && opener
3613
+ ? withPeerProvenance(rawText, { botName: opener.name, delivery: "start_thread", unattended })
3614
+ : rawText;
3367
3615
  if (targetThreadId) {
3368
3616
  delegationWatch.set(targetThreadId, {
3369
3617
  channelId: channel?.id,
@@ -3400,7 +3648,8 @@ const runDelegatedTurn = (toBotId, text, commsDepth, sourceThreadId, channel, ta
3400
3648
  return startTurn(toBotId, text, {
3401
3649
  threadId: targetThreadId,
3402
3650
  commsDepth,
3403
- unattended: isUnattended(sourceBotId, sourceThreadId),
3651
+ unattended,
3652
+ peerAsk,
3404
3653
  // startTurn schedules provider/integration setup after marking the bot
3405
3654
  // busy. Those asynchronous setup failures do not emit turn.completed,
3406
3655
  // so clear the watch and report them through this callback too.
@@ -3428,9 +3677,16 @@ function retryDelegationsWaitingOn(botId) {
3428
3677
  // Explicit idle releases (room/setup/reload/watchdog fallbacks) may not
3429
3678
  // publish turn.completed. They free a waiting source continuation too.
3430
3679
  drainDelegationWakes();
3431
- if (store.bot(botId)?.busy)
3680
+ // A bot still busy in one thread has nevertheless freed a slot: the
3681
+ // fresh-thread handoffs waiting on it can move, while the active-thread
3682
+ // ones keep waiting for it to go idle, as they always have.
3683
+ const stillBusy = store.bot(botId)?.busy === true;
3684
+ if (stillBusy && botAtThreadCapacity(botId))
3432
3685
  return;
3433
- for (const waitingThread of releaseDelegationsWaitingOn(botId)) {
3686
+ const released = stillBusy
3687
+ ? releaseDelegationsWaitingOn(botId, (item) => item.targetThreadId !== undefined)
3688
+ : releaseDelegationsWaitingOn(botId);
3689
+ for (const waitingThread of released) {
3434
3690
  drainThreadDelegations(waitingThread);
3435
3691
  }
3436
3692
  });
@@ -3474,13 +3730,15 @@ bus.subscribe((event) => {
3474
3730
  drainDelegationWakes();
3475
3731
  });
3476
3732
  function drainQueuedSends() {
3477
- drainSteeredMessages(store, (botId, threadId, prompt, userMessage, excludeIds) =>
3478
- // A plain attended turn — no automationSource, no unattended, no comms
3479
- // depth: exactly what typing the same words into an idle bot would run.
3733
+ drainSteeredMessages(store, (botId, threadId, prompt, userMessage, excludeIds, unattended) =>
3734
+ // A plain attended turn — no automationSource, no comms depth: exactly
3735
+ // what typing the same words into an idle bot would run. The one
3736
+ // exception is `unattended`: a thread a bot opened on itself while
3737
+ // nobody was watching stays unwatched through the wait for a slot.
3480
3738
  // Drain just appended the held lines; userMessage keeps startTurn
3481
3739
  // from duplicating the last one, and excludeIds drops every drained
3482
3740
  // line from the transcript-replay so they are not also in `prompt`.
3483
- startTurn(botId, prompt, { threadId, userMessage, excludeMessageIds: excludeIds }).then(() => undefined).catch((err) => {
3741
+ startTurn(botId, prompt, { threadId, userMessage, excludeMessageIds: excludeIds, unattended }).then(() => undefined).catch((err) => {
3484
3742
  store.appendMessage(threadId, {
3485
3743
  role: "bot",
3486
3744
  kind: "activity",
@@ -3511,6 +3769,36 @@ async function startOrQueueDirectMessage(botId, threadId, text, replyTo, sendId)
3511
3769
  const message = await startTurn(botId, text, { threadId, replyTo, sendId });
3512
3770
  return { ok: true, threadId, message };
3513
3771
  }
3772
+ /** How many start_thread calls one turn may make. Same spirit as the
3773
+ * create-bot ceiling above: a handful is a plan, more is a fan-out. */
3774
+ const MAX_THREADS_OPENED_PER_TURN = 5;
3775
+ /** A thread a bot opened on itself gets its first turn exactly the way a
3776
+ * person's message would: it runs now if the bot has a free slot, and
3777
+ * otherwise waits in the same composer queue, in line behind whatever the
3778
+ * bot already has waiting. The only inheritance is `unattended` — a bot
3779
+ * nobody is watching does not become watched by opening a thread. */
3780
+ async function startOrQueueOpenedThread(botId, threadId, text, unattended) {
3781
+ // A room turn holds the bot too (startTurn refuses a direct turn during
3782
+ // one); the drain's own block check already waits for it, so the words
3783
+ // queue here rather than bounce.
3784
+ if (botAtThreadCapacity(botId) || activeGroupTurnForBot(botId)) {
3785
+ queueSteeredMessage(botId, threadId, text, { reason: "capacity", unattended });
3786
+ return { state: "queued", position: queuedThreadPosition(botId, threadId) ?? 1 };
3787
+ }
3788
+ try {
3789
+ await startTurn(botId, text, { threadId, unattended });
3790
+ return { state: "running" };
3791
+ }
3792
+ catch (error) {
3793
+ const why = error instanceof Error ? error.message : String(error);
3794
+ store.appendMessage(threadId, {
3795
+ role: "bot",
3796
+ kind: "activity",
3797
+ tool: { name: `error: this thread could not start — ${why.slice(0, 120)}`, ok: false },
3798
+ });
3799
+ return { state: "failed", error: why };
3800
+ }
3801
+ }
3514
3802
  const screenPollers = new Map();
3515
3803
  /** The preview shares the box's single command endpoint with the agent's
3516
3804
  * own actions, so every frame we take is latency stolen from the work the
@@ -3766,6 +4054,10 @@ async function startTurn(botId, text, opts) {
3766
4054
  if (!opts?.cardContinuation) {
3767
4055
  if (commsDepth === 0 && opts?.automationSource === undefined && !opts?.unattended) {
3768
4056
  personAskAt.set(threadId, userMessage.at);
4057
+ // Continuing an old run as a normal conversation makes that task
4058
+ // visible again; these new replies are not routine report updates.
4059
+ if (task.routineRunId)
4060
+ store.patchTask(bot.id, threadId, { routineRunId: undefined });
3769
4061
  }
3770
4062
  else {
3771
4063
  personAskAt.delete(threadId);
@@ -3809,7 +4101,7 @@ async function startTurn(botId, text, opts) {
3809
4101
  // that never mounts agent tools (or a turn already at the comms-depth cap)
3810
4102
  // must not be steered into — or told about — tools it cannot call.
3811
4103
  const agentsMounted = commsDepth < MAX_COMMS_DEPTH && instance.adapter.capabilities.agentsMcp === true;
3812
- const skillAuthoring = skillRecorderEnabled(cfg) && agentsMounted;
4104
+ const skillAuthoring = skillAuthoringEnabled(cfg) && agentsMounted;
3813
4105
  // Setup mode's turn-text rewrite (parseSetupCommand/expandSetupTurnText)
3814
4106
  // must not run ahead of a system prompt that can't explain it: a driver
3815
4107
  // without agent tools sees the user's literal "/setup ..." message. The
@@ -3830,6 +4122,10 @@ async function startTurn(botId, text, opts) {
3830
4122
  // this already-built turn must either keep its old session or replay on the
3831
4123
  // following turn, never start a blank session with no transcript.
3832
4124
  const resumeCursor = resume ? task.resumeCursors[instanceId] : undefined;
4125
+ // A cursor the provider no longer honours must not brick the thread: the
4126
+ // driver may fall back to ONE fresh session, and this is what it sends
4127
+ // there, so the new session is not blank (server/resume-recovery.ts).
4128
+ const recoveryText = resumeCursor !== undefined ? buildRecoveryText({ text: turnText, transcript }) : undefined;
3833
4129
  const persona = [
3834
4130
  `You are ${bot.name}, a personal bot in OpenMausBot.`,
3835
4131
  bot.title && `Role: ${bot.title}.`,
@@ -3897,8 +4193,11 @@ async function startTurn(botId, text, opts) {
3897
4193
  // desk, not the whole house — and the workspace is where its
3898
4194
  // MEMORY.md lives. API/box engines have no local filesystem story.
3899
4195
  const worksInWorkspace = instance.driverKind !== "grok" && instance.driverKind !== "boxAgent";
3900
- if (worksInWorkspace)
4196
+ if (worksInWorkspace) {
3901
4197
  ensureWorkspace(bot.id);
4198
+ // baseline for the journal's turn-boundary diff (see the bus hook)
4199
+ beginMemoryTurn(bot.id, threadId);
4200
+ }
3902
4201
  const privateWorkspace = worksInWorkspace ? ensureTaskWorkspace(bot.id, threadId) : undefined;
3903
4202
  const skillInstructions = renderSkillInstructions(selectedSkills, {
3904
4203
  includeRoot: worksInWorkspace && opts?.runOn !== "cloud",
@@ -4167,7 +4466,7 @@ async function startTurn(botId, text, opts) {
4167
4466
  // model mostly did not think to make.
4168
4467
  ? peerRosterSystemPrompt(sectionPeers)
4169
4468
  : "";
4170
- const credentialPrompt = integrations.agents ? CREDENTIAL_PROMPT : "";
4469
+ const credentialPrompt = integrations.agents ? CREDENTIAL_PROMPT + THREADS_PROMPT : "";
4171
4470
  const routinePrompt = integrations.agents ? ROUTINE_PROMPT : "";
4172
4471
  const profilePrompt = integrations.agents ? PROFILE_PROMPT : "";
4173
4472
  const recallPrompt = integrations.agents ? SESSION_SEARCH_SYSTEM_PROMPT : "";
@@ -4272,8 +4571,11 @@ async function startTurn(botId, text, opts) {
4272
4571
  // the active task's own session — another task's cursor would
4273
4572
  // resume the wrong conversation and defeat the context bubble
4274
4573
  resumeCursor,
4574
+ ...(recoveryText !== undefined ? { recoveryText } : {}),
4275
4575
  transcript,
4276
4576
  system: prompt.text,
4577
+ systemStable: prompt.stable,
4578
+ systemVolatile: prompt.volatile,
4277
4579
  integrations,
4278
4580
  cwd,
4279
4581
  }), () => !directTurnClaimExists(bot.id, dispatchClaimId, threadId), async () => {
@@ -4383,6 +4685,13 @@ async function startTurn(botId, text, opts) {
4383
4685
  // The scheduler owns timing and receipts; the existing harness remains the
4384
4686
  // only owner of provider sessions, approvals, tools, computers and messages.
4385
4687
  function routineSourceOwner(run) {
4688
+ if (run.resultsThreadId) {
4689
+ const bot = store.bot(run.botId);
4690
+ const task = bot && store.taskByThread(bot.id, run.resultsThreadId);
4691
+ return bot && !bot.hidden && task && !task.routineRunId
4692
+ ? { bot, group: undefined, threadId: task.threadId }
4693
+ : null;
4694
+ }
4386
4695
  const threadId = run.sourceThreadId?.trim();
4387
4696
  if (!threadId)
4388
4697
  return null;
@@ -4396,7 +4705,8 @@ function routineSourceOwner(run) {
4396
4705
  // whose conversation held the card. Recheck their section before sharing
4397
4706
  // a result, since either bot may have moved since confirmation.
4398
4707
  const sourceBot = store.botByThread(threadId);
4399
- if (sourceBot && sectionKey(sourceBot.section) === sectionKey(bot.section)) {
4708
+ if (sourceBot && !sourceBot.hidden && !store.taskByThread(sourceBot.id, threadId)?.routineRunId &&
4709
+ sectionKey(sourceBot.section) === sectionKey(bot.section)) {
4400
4710
  return { bot: sourceBot, group: undefined, threadId };
4401
4711
  }
4402
4712
  const group = store.groupByThread(threadId);
@@ -4413,6 +4723,7 @@ function routineRunCard(run) {
4413
4723
  runId: run.id,
4414
4724
  routineId: run.routineId,
4415
4725
  routineName: redactSecretsInText(run.routineName),
4726
+ scheduledFor: run.scheduledFor,
4416
4727
  status: run.status,
4417
4728
  };
4418
4729
  if (run.goalStatus)
@@ -4455,8 +4766,16 @@ function routineRunFallbackText(card) {
4455
4766
  * another chat message. */
4456
4767
  function syncRoutineRunToSource(run) {
4457
4768
  const source = routineSourceOwner(run);
4458
- if (!source)
4769
+ if (!source) {
4770
+ const execution = run.threadId ? store.taskByThread(run.botId, run.threadId) : null;
4771
+ if (execution?.routineRunId === run.id) {
4772
+ store.patchTask(run.botId, execution.threadId, {
4773
+ routineRunId: undefined,
4774
+ ...(["waiting", "completed", "failed", "missed"].includes(run.status) ? { unread: true } : {}),
4775
+ });
4776
+ }
4459
4777
  return null;
4778
+ }
4460
4779
  const sourceThreadId = source.threadId;
4461
4780
  const card = routineRunCard(run);
4462
4781
  const text = routineRunFallbackText(card);
@@ -4477,6 +4796,18 @@ function syncRoutineRunToSource(run) {
4477
4796
  }
4478
4797
  store.appendMessage(sourceThreadId, message);
4479
4798
  }
4799
+ // Mark only the fresh execution, after its first running card persisted
4800
+ // and before dispatch adds any transcript. A user can later promote it by
4801
+ // sending a normal follow-up; replaying/marking the old receipt seen must
4802
+ // never hide that conversation again.
4803
+ if (run.target === "bot" && run.triggerSource !== "webhook" && run.threadId && run.threadId !== sourceThreadId) {
4804
+ const execution = store.taskByThread(run.botId, run.threadId);
4805
+ const freshExecution = execution && run.status === "running" && !execution.routineRunId &&
4806
+ store.messagesFor(run.threadId).length === 0;
4807
+ if (execution && (freshExecution || (execution.routineRunId === run.id && execution.unread))) {
4808
+ store.patchTask(run.botId, run.threadId, { routineRunId: run.id, unread: false });
4809
+ }
4810
+ }
4480
4811
  // Merely queueing/running is ambient progress. Attention and terminal
4481
4812
  // states become unread in the conversation where the user asked for them.
4482
4813
  if (statusChanged && ["waiting", "completed", "failed", "missed"].includes(run.status)) {
@@ -4541,7 +4872,7 @@ async function stopBotForEmergencyApprovalDowngrade(botId) {
4541
4872
  // Load queued handoffs before scheduler recovery can fail an interrupted
4542
4873
  // run. Its failure callback can then durably drop that work immediately;
4543
4874
  // nothing dispatches until the listener is ready below.
4544
- const commsBus = { store, broadcast };
4875
+ const commsBus = { store, broadcast, threadSlotFree: (botId) => !botAtThreadCapacity(botId) };
4545
4876
  _loadPending();
4546
4877
  routines = new RoutineManager({
4547
4878
  emit: broadcast,
@@ -4573,6 +4904,25 @@ routines = new RoutineManager({
4573
4904
  return task;
4574
4905
  },
4575
4906
  createGoalTask: (groupId, title) => store.createGroupTask(groupId, title, false),
4907
+ isResultsThread: (botId, threadId) => {
4908
+ const bot = store.bot(botId);
4909
+ const task = store.taskByThread(botId, threadId);
4910
+ return Boolean(bot && !bot.hidden && task && !task.routineRunId);
4911
+ },
4912
+ resolveResultsThread: (routine, forceNew) => {
4913
+ // A trusted chat source is already snapshotted as sourceThreadId on each
4914
+ // run. Keep it distinct: it can belong to a teammate or room, whereas an
4915
+ // explicit resultsThreadId must be a visible task owned by the running bot.
4916
+ if (!forceNew && routineSourceOwner(routine))
4917
+ return routine.resultsThreadId;
4918
+ return store.createTask(routine.botId, `${routine.name} · Results`, false)?.threadId;
4919
+ },
4920
+ discardResultsThread: (botId, threadId) => {
4921
+ const task = store.taskByThread(botId, threadId);
4922
+ if (task && !task.busy && !task.routineRunId && store.messagesFor(threadId).length === 0) {
4923
+ store.deleteTask(botId, threadId);
4924
+ }
4925
+ },
4576
4926
  startTurn: (botId, threadId, prompt, runOn, triggerSource, onDispatchError) => startTurn(botId, prompt, { threadId, runOn, automationSource: triggerSource, onDispatchError })
4577
4927
  .then(() => undefined),
4578
4928
  startGoal: async (groupId, threadId, prompt, coordinatorBotId, runId, _onDispatchError) => {
@@ -5022,7 +5372,7 @@ setupRetry = 0) {
5022
5372
  };
5023
5373
  try {
5024
5374
  const integrations = {};
5025
- const skillAuthoring = skillRecorderEnabled(cfg) &&
5375
+ const skillAuthoring = skillAuthoringEnabled(cfg) &&
5026
5376
  hop === 0 &&
5027
5377
  !skillAuthoringClaim.claimed &&
5028
5378
  !cardContinuation &&
@@ -5247,7 +5597,7 @@ setupRetry = 0) {
5247
5597
  readyGroup.bulletin.trim() && `Room bulletin (shared instructions for everyone):\n${readyGroup.bulletin.trim()}`,
5248
5598
  `Reply as yourself, briefly and conversationally. To bring a teammate in, mention them like @Name — they'll see the conversation and respond.`,
5249
5599
  outsideRoom.length > 0 && roomPeerRosterSystemPrompt(outsideRoom),
5250
- integrations.agents && CREDENTIAL_PROMPT.trim(),
5600
+ integrations.agents && (CREDENTIAL_PROMPT + THREADS_PROMPT).trim(),
5251
5601
  integrations.agents && ROUTINE_PROMPT.trim(),
5252
5602
  integrations.agents && PROFILE_PROMPT.trim(),
5253
5603
  skillAuthoring && LEARN_PROMPT.trim(),
@@ -5263,6 +5613,9 @@ setupRetry = 0) {
5263
5613
  // conversation, not a different bot
5264
5614
  const worksInWorkspace = instance.driverKind !== "grok" && instance.driverKind !== "boxAgent";
5265
5615
  const workspace = worksInWorkspace ? ensureWorkspace(bot.id) : undefined;
5616
+ // a room member's memory writes are journaled the same as a 1:1 turn's
5617
+ if (workspace)
5618
+ beginMemoryTurn(bot.id, threadId);
5266
5619
  // The room's folder pins here — on the first turn that actually
5267
5620
  // dispatches, not at PATCH time — so a folder set on a never-used room
5268
5621
  // still takes effect, while a room that already worked somewhere never
@@ -5282,12 +5635,14 @@ setupRetry = 0) {
5282
5635
  { id: "section-context", label: "Section context", text: sectionContextSystemPrompt(bot.section) },
5283
5636
  // the room path has always put a newline before memory and trimmed
5284
5637
  // the block's leading space; keep that so existing prompts are
5285
- // byte-identical
5286
- { id: "memory", label: "Memory", text: workspace ? `\n${memorySystemPrompt(bot.id).trim()}` : "" },
5638
+ // byte-identical. The write guidance follows the tools actually
5639
+ // mounted, exactly as the 1:1 path decides it: memory_update is on the
5640
+ // agents server, so a room turn with it must be told to use it too.
5641
+ { id: "memory", label: "Memory", text: workspace ? `\n${memorySystemPrompt(bot.id, { managedWrites: Boolean(integrations.agents) }).trim()}` : "" },
5287
5642
  { id: "skills", label: "Skills index", text: workspace ? skillsSystemPrompt(bot.id) : "" },
5288
5643
  { id: "skill-instructions", label: "Skill instructions", text: renderSkillInstructions(selectedSkills, { includeRoot: Boolean(workspace) }) },
5289
5644
  { id: "playbooks", label: "Playbooks", text: installedPlaybookInstructions(text, bot.playbooks) },
5290
- ]).text;
5645
+ ]);
5291
5646
  // run the turn and wait for it to settle, folding the reply text so a
5292
5647
  // chained @mention can be routed afterwards
5293
5648
  // Claim only after setup succeeded. An unavailable, busy, unsupported, or
@@ -5390,7 +5745,9 @@ setupRetry = 0) {
5390
5745
  text,
5391
5746
  images: turnImages,
5392
5747
  approvalMode: approvalModeForTurn(readyBot, false, threadId),
5393
- system: roomSystem,
5748
+ system: roomSystem.text,
5749
+ systemStable: roomSystem.stable,
5750
+ systemVolatile: roomSystem.volatile,
5394
5751
  cwd,
5395
5752
  integrations,
5396
5753
  ...memberTurnSelection(readyBot.modelSelection),
@@ -6925,7 +7282,7 @@ function configStatus() {
6925
7282
  maxInstances: localVmMaxInstances(cfg),
6926
7283
  },
6927
7284
  features: {
6928
- skillRecorder: skillRecorderEnabled(cfg),
7285
+ skillAuthoring: skillAuthoringEnabled(cfg),
6929
7286
  showToolCalls: showToolCallsEnabled(cfg),
6930
7287
  browser: builtInBrowserEnabled(cfg),
6931
7288
  },
@@ -7116,6 +7473,26 @@ function json(res, status, body) {
7116
7473
  res.writeHead(status, { "content-type": "application/json" });
7117
7474
  res.end(data);
7118
7475
  }
7476
+ /** A store refusal is a client error with a status of its own (400 path,
7477
+ * 409 conflict, 413 too large); a 409 also carries what is on disk now so
7478
+ * the editor can show the bot's version instead of guessing. Anything else
7479
+ * is a real failure and goes to the handler's catch-all. */
7480
+ function replyMemoryError(res, error) {
7481
+ if (!(error instanceof MemoryStoreError))
7482
+ throw error;
7483
+ if (error.code === "conflict") {
7484
+ return json(res, error.status, { error: error.message, code: error.code, currentHash: error.currentHash, current: error.current });
7485
+ }
7486
+ return json(res, error.status, { error: error.message, code: error.code });
7487
+ }
7488
+ /** The journal row as the panel shows it: the full prior text stays on
7489
+ * the server (a revert needs it there, the list does not), and the thread
7490
+ * id becomes the chat title people recognise. */
7491
+ function journalEntryForClient(botId, entry) {
7492
+ const { before: _before, ...visible } = entry;
7493
+ const threadTitle = entry.threadId ? store.taskByThread(botId, entry.threadId)?.title : undefined;
7494
+ return threadTitle ? { ...visible, threadTitle } : visible;
7495
+ }
7119
7496
  function readBody(req, limit = 1_000_000) {
7120
7497
  return new Promise((resolve, reject) => {
7121
7498
  let data = "";
@@ -7417,6 +7794,7 @@ const handleRequest = async (req, res) => {
7417
7794
  ...parsed.data,
7418
7795
  generation,
7419
7796
  createdBots: 0,
7797
+ openedThreads: 0,
7420
7798
  });
7421
7799
  return json(res, 201, { token });
7422
7800
  }
@@ -7482,10 +7860,23 @@ const handleRequest = async (req, res) => {
7482
7860
  throw Object.assign(new Error("the internal turn capability has expired"), { status: 401 });
7483
7861
  }
7484
7862
  };
7863
+ // Where an entry came from, as the person will read it in MEMORY.md:
7864
+ // the room or the thread title, never a bare id unless nothing else
7865
+ // names the conversation.
7866
+ const memorySource = () => memorySourceLabel({
7867
+ room: store.groupByThread(internalCapability.threadId),
7868
+ task: store.taskByThread(internalSender.id, internalCapability.threadId),
7869
+ threadId: internalCapability.threadId,
7870
+ });
7485
7871
  if (method === "POST" && path === "/api/internal/memory") {
7486
7872
  const body = await readInternalBody();
7487
- const result = updateMemory(internalSender.id, { action: body.action, text: body.text, oldText: body.oldText });
7488
- return json(res, result.ok ? 200 : result.code === "conflict" ? 409 : result.code === "too-large" ? 413 : 400, result);
7873
+ const result = updateMemory(internalSender.id, { action: body.action, text: body.text, oldText: body.oldText }, { source: memorySource() });
7874
+ return json(res, result.ok ? 200 : result.code === "conflict" ? 409 : result.code === "over-budget" ? 413 : 400, result);
7875
+ }
7876
+ if (method === "POST" && path === "/api/internal/memory/log") {
7877
+ const body = await readInternalBody();
7878
+ const result = appendMemoryLog(internalSender.id, body.text, { source: memorySource() });
7879
+ return json(res, result.ok ? 200 : 400, result);
7489
7880
  }
7490
7881
  if (method === "POST" && path === "/api/internal/browser/mcp") {
7491
7882
  const body = await readInternalBody();
@@ -7538,6 +7929,82 @@ const handleRequest = async (req, res) => {
7538
7929
  // to keep trying — but it is still NAMED, with the refusal it would
7539
7930
  // have met. Without that the bot can only say it is in no room at
7540
7931
  // all, while the person is looking at it in that very room.
7932
+ // list_threads: the caller's own threads, plus — on every peer it can
7933
+ // reach — only the threads the caller itself opened. A peer's other
7934
+ // threads are its own business (and the person's), so the scope is
7935
+ // "what you started", never "what that bot is doing". State is read
7936
+ // the way the sidebar reads it, so the bot and the person agree.
7937
+ if (method === "GET" && path === "/api/internal/threads") {
7938
+ const from = internalSender;
7939
+ const fromThreadId = internalCapability.threadId;
7940
+ if (!connectorThread(from.id, fromThreadId)) {
7941
+ return json(res, 403, { error: "source conversation does not belong to sender" });
7942
+ }
7943
+ const rows = [];
7944
+ const stateOf = (bot, task) => {
7945
+ if (task.activity === "waiting-on-you")
7946
+ return "waiting-on-you";
7947
+ if (threadBusy(bot.id, task.threadId))
7948
+ return "running";
7949
+ if (queuedThreadPosition(bot.id, task.threadId) !== null)
7950
+ return "queued";
7951
+ return "idle";
7952
+ };
7953
+ for (const task of store.tasks(from.id)) {
7954
+ rows.push({
7955
+ threadId: task.threadId, botId: from.id, botName: from.name, title: task.title,
7956
+ state: stateOf(from, task), unread: task.unread === true, openedAt: task.openedBy?.at ?? task.createdAt,
7957
+ delegationId: task.openedBy?.delegationId, own: true,
7958
+ });
7959
+ }
7960
+ for (const peer of reachablePeers(store.bots, from)) {
7961
+ for (const task of store.tasks(peer.id)) {
7962
+ if (task.openedBy?.botId !== from.id)
7963
+ continue;
7964
+ rows.push({
7965
+ threadId: task.threadId, botId: peer.id, botName: peer.name, title: task.title,
7966
+ state: stateOf(peer, task), unread: task.unread === true, openedAt: task.openedBy.at,
7967
+ delegationId: task.openedBy.delegationId, own: false,
7968
+ });
7969
+ }
7970
+ }
7971
+ rows.sort((a, b) => b.openedAt - a.openedAt);
7972
+ return json(res, 200, { threads: rows.slice(0, 100) });
7973
+ }
7974
+ // close_thread: a bot tidies a thread it opened (or one of its own)
7975
+ // once its result has been read. Closing is the sidebar's idle state
7976
+ // plus a chip saying who closed it — never a deletion, which stays a
7977
+ // person's confirmed action, and never while the thread is running.
7978
+ const closeMatch = method === "POST" ? path.match(/^\/api\/internal\/threads\/([\w-]+)\/close$/) : null;
7979
+ if (closeMatch) {
7980
+ const from = internalSender;
7981
+ const fromThreadId = internalCapability.threadId;
7982
+ if (!connectorThread(from.id, fromThreadId)) {
7983
+ return json(res, 403, { error: "source conversation does not belong to sender" });
7984
+ }
7985
+ const threadId = closeMatch[1];
7986
+ const owner = [from, ...reachablePeers(store.bots, from)].find((bot) => store.taskByThread(bot.id, threadId));
7987
+ const task = owner ? store.taskByThread(owner.id, threadId) : undefined;
7988
+ if (!owner || !task)
7989
+ return json(res, 404, { error: "no such thread — call list_threads for the ones you can see" });
7990
+ if (owner.id !== from.id && task.openedBy?.botId !== from.id) {
7991
+ return json(res, 403, { error: "that thread is not yours to close — only the bot that opened it, or its own bot, can" });
7992
+ }
7993
+ if (threadId === fromThreadId)
7994
+ return json(res, 400, { error: "you cannot close the thread you are speaking in — finish your turn instead" });
7995
+ if (threadBusy(owner.id, threadId)) {
7996
+ return json(res, 409, { error: `#${task.title} is still running — wait for it to finish (list_threads), or the person can stop it from the app` });
7997
+ }
7998
+ store.appendMessage(threadId, {
7999
+ role: "bot",
8000
+ kind: "activity",
8001
+ from: { botId: from.id, name: from.name, color: from.color },
8002
+ tool: { name: `Closed by @${from.name}`, ok: true },
8003
+ });
8004
+ if (task.unread)
8005
+ store.patchTask(owner.id, threadId, { unread: false });
8006
+ return json(res, 200, { closed: true, threadId, title: task.title, botName: owner.name });
8007
+ }
7541
8008
  if (method === "GET" && path === "/api/internal/rooms") {
7542
8009
  const from = internalSender;
7543
8010
  const fromThreadId = internalCapability.threadId;
@@ -7714,6 +8181,16 @@ const handleRequest = async (req, res) => {
7714
8181
  return json(res, 400, { error: "q is required" });
7715
8182
  const rawLimit = Number(url.searchParams.get("limit"));
7716
8183
  const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? Math.min(Math.trunc(rawLimit), 25) : 12;
8184
+ // Memory files ride along by default: the bot's own notes are as
8185
+ // much its notebook as its transcripts, and scoped the same way —
8186
+ // the caller's own bot, never another's.
8187
+ const scope = url.searchParams.get("scope") ?? "all";
8188
+ if (scope !== "all" && scope !== "conversations" && scope !== "memory") {
8189
+ return json(res, 400, { error: "scope must be all, conversations, or memory" });
8190
+ }
8191
+ const memoryHits = scope === "conversations" ? [] : searchMemoryFiles(from.id, q, limit);
8192
+ if (scope === "memory")
8193
+ return json(res, 200, { hits: [], memoryHits });
7717
8194
  const ownThreads = [...new Set([from.threadId, ...(from.tasks ?? []).map((task) => task.threadId)])];
7718
8195
  // A room is the only place a recall can be a disclosure: in a 1:1 the
7719
8196
  // user already owns every thread the bot can reach.
@@ -7727,7 +8204,7 @@ const handleRequest = async (req, res) => {
7727
8204
  if (inRoom) {
7728
8205
  discloseRecall(from, fromThreadId, hits.filter((hit) => hit.crossed).map((hit) => hit.threadId));
7729
8206
  }
7730
- return json(res, 200, { hits });
8207
+ return json(res, 200, { hits, memoryHits });
7731
8208
  }
7732
8209
  // session_read: the whole message behind a session_search hit. Same
7733
8210
  // own-bot scope — a message id from another bot's thread reads as
@@ -7761,8 +8238,8 @@ const handleRequest = async (req, res) => {
7761
8238
  });
7762
8239
  }
7763
8240
  if (method === "GET" && path === "/api/internal/skills") {
7764
- if (!skillRecorderEnabled(cfg))
7765
- return json(res, 403, { error: "learned skills are not enabled" });
8241
+ if (!skillAuthoringEnabled(cfg))
8242
+ return json(res, 403, { error: "skill authoring is not enabled in Settings" });
7766
8243
  if (!internalCapability.skillAuthoring) {
7767
8244
  return json(res, 403, { error: "skill authoring is not enabled for this turn" });
7768
8245
  }
@@ -7777,8 +8254,8 @@ const handleRequest = async (req, res) => {
7777
8254
  });
7778
8255
  }
7779
8256
  if (method === "POST" && path === "/api/internal/skills/stage") {
7780
- if (!skillRecorderEnabled(cfg))
7781
- return json(res, 403, { error: "learned skills are not enabled" });
8257
+ if (!skillAuthoringEnabled(cfg))
8258
+ return json(res, 403, { error: "skill authoring is not enabled in Settings" });
7782
8259
  if (!internalCapability.skillAuthoring) {
7783
8260
  return json(res, 403, { error: "skill authoring is not enabled for this turn" });
7784
8261
  }
@@ -8231,6 +8708,128 @@ const handleRequest = async (req, res) => {
8231
8708
  store.appendMessage(fromThreadId, chip);
8232
8709
  return json(res, 201, { ok: true, messageId: posted.id, roomName: room.name });
8233
8710
  }
8711
+ // start_thread: a bot opens a real thread — on itself for separate
8712
+ // work, or on a teammate as a handoff that should run on its own.
8713
+ // Never activates: a bot must not move what the person is looking at.
8714
+ if (method === "POST" && path === "/api/internal/threads") {
8715
+ const body = await readInternalBody();
8716
+ const from = internalSender;
8717
+ const fromThreadId = internalCapability.threadId;
8718
+ const owner = connectorThread(from.id, fromThreadId);
8719
+ if (!owner)
8720
+ return json(res, 403, { error: "source conversation does not belong to sender" });
8721
+ if (body.depth !== undefined &&
8722
+ (!Number.isInteger(body.depth) || body.depth < 0 || body.depth !== internalCapability.depth)) {
8723
+ return json(res, 403, { error: "the recursion depth does not match this turn" });
8724
+ }
8725
+ const title = String(body.title ?? "").trim();
8726
+ const message = String(body.message ?? "").trim();
8727
+ if (!title || !message)
8728
+ return json(res, 400, { error: "title and message are required" });
8729
+ if (title.length > 80)
8730
+ return json(res, 400, { error: "title must be at most 80 characters" });
8731
+ // the title is quoted into chips and the sidebar, one line each
8732
+ if (!fitsOnOneLine(title))
8733
+ return json(res, 400, { error: "title must fit on one line" });
8734
+ if (internalCapability.openedThreads >= MAX_THREADS_OPENED_PER_TURN) {
8735
+ return json(res, 429, { error: `you can open at most ${MAX_THREADS_OPENED_PER_TURN} threads in one turn` });
8736
+ }
8737
+ const toBotId = typeof body.toBotId === "string" && body.toBotId.trim() ? body.toBotId.trim() : from.id;
8738
+ const target = store.bot(toBotId);
8739
+ if (!target)
8740
+ return json(res, 404, { error: "no such bot" });
8741
+ // A folder is the target's own organisation; a name is what the
8742
+ // model has, an id is what the sidebar has, so accept either.
8743
+ const folder = typeof body.folder === "string" ? body.folder.trim() : "";
8744
+ let projectId;
8745
+ if (folder) {
8746
+ const project = (target.projects ?? []).find((candidate) => candidate.id === folder || candidate.name.trim().toLowerCase() === folder.toLowerCase());
8747
+ if (!project) {
8748
+ const names = (target.projects ?? []).map((candidate) => candidate.name).join(", ");
8749
+ return json(res, 400, { error: `@${target.name} has no folder named "${folder}"${names ? ` — the folders are: ${names}` : " — it has no folders; leave folder out"}` });
8750
+ }
8751
+ projectId = project.id;
8752
+ }
8753
+ const sourceTitle = owner.group ? owner.group.name : (store.taskByThread(from.id, fromThreadId)?.title ?? "");
8754
+ if (target.id === from.id) {
8755
+ const task = store.createTask(from.id, title, false, projectId, { botId: from.id, name: from.name, at: Date.now() });
8756
+ if (!task)
8757
+ return json(res, 500, { error: "couldn't create that thread" });
8758
+ internalCapability.openedThreads += 1;
8759
+ const chip = {
8760
+ role: "bot",
8761
+ kind: "activity",
8762
+ tool: { name: `Opened thread #${task.title}`, ok: true },
8763
+ threadRef: { botId: from.id, threadId: task.threadId, title: task.title },
8764
+ };
8765
+ if (owner.group)
8766
+ chip.from = { botId: from.id, name: from.name, color: from.color };
8767
+ store.appendMessage(fromThreadId, chip);
8768
+ // The first line is the bot's own words. Say so where the model
8769
+ // reads it, so a later turn in that thread never mistakes the
8770
+ // request for the person's.
8771
+ const opening = `[Thread you opened yourself${sourceTitle ? ` from #${sourceTitle}` : ""}. The request below is your own words, not the person's: do the work here and end with a clear result they can read.]\n\n${message}`;
8772
+ const outcome = await startOrQueueOpenedThread(from.id, task.threadId, opening, isUnattended(from.id, fromThreadId));
8773
+ return json(res, 201, {
8774
+ threadId: task.threadId,
8775
+ title: task.title,
8776
+ botId: from.id,
8777
+ botName: from.name,
8778
+ self: true,
8779
+ limit: maxConcurrentBotThreads(cfg),
8780
+ ...outcome,
8781
+ });
8782
+ }
8783
+ // A peer thread is a handoff into a fresh thread: every gate the
8784
+ // classic handoff has applies unchanged, here at queue time and
8785
+ // again in the drain at dispatch time.
8786
+ const depth = internalCapability.depth;
8787
+ if (depth >= MAX_COMMS_DEPTH) {
8788
+ return json(res, 200, { error: "thread chains are limited to one hop — open the thread on yourself, or do this one here" });
8789
+ }
8790
+ if (sectionKey(from.section) !== sectionKey(target.section)) {
8791
+ return json(res, 403, { error: "that bot belongs to a different section" });
8792
+ }
8793
+ if (!peerAllowed(from, target.id)) {
8794
+ return json(res, 403, { error: "that bot is not on this bot's allowed peers — call list_bots for the ones you can reach" });
8795
+ }
8796
+ const task = store.createTask(target.id, title, false, projectId, { botId: from.id, name: from.name, at: Date.now() });
8797
+ if (!task)
8798
+ return json(res, 500, { error: "couldn't create that thread" });
8799
+ const queued = queueDelegation(commsBus, from, { toBotId: target.id, message, depth, targetThreadId: task.threadId }, MAX_COMMS_DEPTH, fromThreadId);
8800
+ if (queued.result !== "ok" || !queued.id) {
8801
+ // nothing will ever run there: take the row back before the person
8802
+ // sees a thread that goes nowhere
8803
+ store.deleteTask(target.id, task.threadId);
8804
+ const said = {
8805
+ self: "a bot cannot hand a thread to itself this way — leave bot_id out",
8806
+ too_deep: "thread chains are limited to one hop — open the thread on yourself, or do this one here",
8807
+ no_target: "no such bot",
8808
+ too_many: "too many handoffs queued on this turn — finish your turn and open the rest next time",
8809
+ };
8810
+ return json(res, 200, { error: said[queued.result === "ok" ? "no_target" : queued.result] });
8811
+ }
8812
+ store.setTaskOpenedBy(target.id, task.threadId, { botId: from.id, name: from.name, delegationId: queued.id, at: task.openedBy?.at ?? Date.now() });
8813
+ internalCapability.openedThreads += 1;
8814
+ // An honest forecast, not a promise: the handoff starts when this
8815
+ // turn ends, and by then the target's slots are taken by whatever is
8816
+ // running there plus the threads this turn already opened ahead.
8817
+ const limit = maxConcurrentBotThreads(cfg);
8818
+ const running = store.tasks(target.id).filter((candidate) => threadBusy(target.id, candidate.threadId)).length;
8819
+ const ahead = pendingDelegationSnapshot().filter((pending) => pending.toBotId === target.id && pending.targetThreadId !== undefined && pending.targetThreadId !== task.threadId).length;
8820
+ const position = running + ahead + 1;
8821
+ return json(res, 201, {
8822
+ threadId: task.threadId,
8823
+ title: task.title,
8824
+ botId: target.id,
8825
+ botName: target.name,
8826
+ self: false,
8827
+ delegationId: queued.id,
8828
+ approvalRequired: Boolean(from.approvePeerComms),
8829
+ limit,
8830
+ ...(position > limit ? { state: "queued", position: position - limit } : { state: "pending" }),
8831
+ });
8832
+ }
8234
8833
  if (method === "POST" && path === "/api/internal/create-bot") {
8235
8834
  const body = await readInternalBody();
8236
8835
  const chief = internalSender;
@@ -8293,6 +8892,86 @@ const handleRequest = async (req, res) => {
8293
8892
  model: safeBot.modelSelection.model,
8294
8893
  });
8295
8894
  }
8895
+ if (method === "POST" && (path === "/api/internal/create-room" || path === "/api/internal/manage-room")) {
8896
+ const body = await readInternalBody();
8897
+ const chief = store.bot(internalCapability.botId);
8898
+ if (chief.hidden || !chief.chiefOfStaff || !connectorThread(chief.id, internalCapability.threadId)) {
8899
+ return json(res, 403, { error: "only an active section Chief of Staff can manage rooms" });
8900
+ }
8901
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
8902
+ return json(res, 400, { error: "room request must be a JSON object" });
8903
+ }
8904
+ // ponytail: no new room-administration approval flow. Fail closed for
8905
+ // chiefs whose peer changes need review; add a proposal card if needed.
8906
+ if (chief.approvePeerComms) {
8907
+ return json(res, 403, { error: "peer approval is required; ask the user to make this room change" });
8908
+ }
8909
+ // Section labels are a permission boundary, not bot-owned organization.
8910
+ // A Chief may not recruit excluded peers or acquire a foreign transcript.
8911
+ const allowedIds = new Set([chief.id, ...reachablePeers(store.bots, chief).map((bot) => bot.id)]);
8912
+ const allowedRoster = (ids) => ids.includes(chief.id) && ids.every((id) => allowedIds.has(id));
8913
+ if (body.section !== undefined && (typeof body.section !== "string" || sectionKey(body.section) !== sectionKey(chief.section))) {
8914
+ return json(res, 403, { error: "rooms must stay in your own section; ask the user to move them" });
8915
+ }
8916
+ if (path === "/api/internal/create-room") {
8917
+ if ((internalCapability.createdRooms ?? 0) >= 4) {
8918
+ return json(res, 429, { error: "you can create at most 4 rooms in one turn" });
8919
+ }
8920
+ const parsed = z.object({
8921
+ fromBotId: z.string().optional(), fromThreadId: z.string().optional(),
8922
+ name: z.string().trim().min(1).max(100), memberIds: z.array(z.string()).min(1).max(100),
8923
+ section: z.string().optional(), bulletin: z.string().max(12_000).optional(),
8924
+ }).strict().safeParse(body);
8925
+ if (!parsed.success)
8926
+ return json(res, 400, { error: "provide a room name, memberIds, and optional bulletin (at most 12000 characters)" });
8927
+ const memberIds = [...new Set([chief.id, ...parsed.data.memberIds])];
8928
+ if (!allowedRoster(memberIds)) {
8929
+ return json(res, 403, { error: "include yourself and only active, allowed peers from your section" });
8930
+ }
8931
+ const group = createChannel({
8932
+ name: redactSecretsInText(parsed.data.name), memberIds, section: chief.section,
8933
+ setup: { bulletin: redactSecretsInText(parsed.data.bulletin ?? ""), defaultResponder: { kind: "member", botId: chief.id } },
8934
+ });
8935
+ internalCapability.createdRooms = (internalCapability.createdRooms ?? 0) + 1;
8936
+ return json(res, 201, { id: group.id, name: group.name, section: group.section || "General", memberIds: group.memberIds, memberCount: group.memberIds.length });
8937
+ }
8938
+ const parsed = z.object({
8939
+ fromBotId: z.string().optional(), fromThreadId: z.string().optional(),
8940
+ roomId: z.string(), action: z.enum(["add_members", "remove_members", "set_members", "rename", "set_bulletin"]),
8941
+ memberIds: z.array(z.string()).min(1).max(100).optional(),
8942
+ name: z.string().trim().min(1).max(100).optional(), bulletin: z.string().max(12_000).optional(),
8943
+ }).strict().safeParse(body);
8944
+ if (!parsed.success)
8945
+ return json(res, 400, { error: "provide roomId and a supported room action; section moves are user-only" });
8946
+ const room = store.group(parsed.data.roomId);
8947
+ if (!room || room.dm || sectionKey(room.section) !== sectionKey(chief.section) || !allowedRoster(room.memberIds)) {
8948
+ return json(res, 403, { error: "you can only manage rooms you belong to with allowed peers in your own section" });
8949
+ }
8950
+ const { action, memberIds, name, bulletin } = parsed.data;
8951
+ const patch = {};
8952
+ if (action === "rename") {
8953
+ if (name === undefined)
8954
+ return json(res, 400, { error: "rename requires a name" });
8955
+ patch.name = redactSecretsInText(name);
8956
+ }
8957
+ else if (action === "set_bulletin") {
8958
+ if (bulletin === undefined)
8959
+ return json(res, 400, { error: "set_bulletin requires bulletin text; use an empty string to clear it" });
8960
+ patch.bulletin = redactSecretsInText(bulletin);
8961
+ }
8962
+ else {
8963
+ if (!memberIds || memberIds.some((id) => !allowedIds.has(id))) {
8964
+ return json(res, 403, { error: "memberIds must name only yourself or active, allowed peers in your section" });
8965
+ }
8966
+ const next = action === "add_members" ? [...new Set([...room.memberIds, ...memberIds])]
8967
+ : action === "remove_members" ? room.memberIds.filter((id) => !memberIds.includes(id)) : memberIds;
8968
+ if (!allowedRoster(next))
8969
+ return json(res, 403, { error: "keep yourself in the room; only the user can remove its managing Chief" });
8970
+ patch.memberIds = next;
8971
+ }
8972
+ const updated = updateChannel(room.id, patch);
8973
+ return json(res, 200, { ok: true, memberIds: updated.memberIds, memberCount: updated.memberIds.length, message: `Updated room “${updated.name}”.` });
8974
+ }
8296
8975
  if (method === "POST" && path === "/api/internal/request-credential") {
8297
8976
  const body = await readInternalBody();
8298
8977
  const from = internalSender;
@@ -9129,47 +9808,7 @@ const handleRequest = async (req, res) => {
9129
9808
  }
9130
9809
  // ── channels (persisted internally as groups) ───────────────────────
9131
9810
  if (method === "POST" && path === "/api/groups") {
9132
- const body = await readBody(req);
9133
- if (!body || typeof body !== "object" || Array.isArray(body)) {
9134
- return json(res, 400, { error: "channel must be a JSON object" });
9135
- }
9136
- const roster = checkedMemberIds(body.memberIds);
9137
- if (!roster.ok)
9138
- return json(res, 400, { error: roster.error });
9139
- const { memberIds } = roster;
9140
- if (body.name !== undefined && typeof body.name !== "string") {
9141
- return json(res, 400, { error: "channel name must be a string" });
9142
- }
9143
- const name = body.name?.trim() || `${store.bot(memberIds[0]).name} & co.`;
9144
- if (name.length > 100)
9145
- return json(res, 400, { error: "channel name must be at most 100 characters" });
9146
- let section;
9147
- if (body.section !== undefined && body.section !== null) {
9148
- if (typeof body.section !== "string")
9149
- return json(res, 400, { error: "context must be a string" });
9150
- section = body.section.trim() || undefined;
9151
- if (section && section.length > 60) {
9152
- return json(res, 400, { error: "context must be at most 60 characters" });
9153
- }
9154
- }
9155
- let setup;
9156
- if (body.setup !== undefined) {
9157
- if (!body.setup || typeof body.setup !== "object" || Array.isArray(body.setup)) {
9158
- return json(res, 400, { error: "setup must be an object" });
9159
- }
9160
- const requested = body.setup;
9161
- if (typeof requested.bulletin !== "string") {
9162
- return json(res, 400, { error: "setup.bulletin must be a string" });
9163
- }
9164
- if (requested.bulletin.length > 12_000) {
9165
- return json(res, 400, { error: "setup.bulletin must be at most 12000 characters" });
9166
- }
9167
- const responder = checkedGroupResponder(requested.defaultResponder, memberIds);
9168
- if (!responder)
9169
- return json(res, 400, { error: "invalid setup.defaultResponder" });
9170
- setup = { bulletin: requested.bulletin, defaultResponder: responder, completed: true };
9171
- }
9172
- const group = store.createGroup(name, memberIds, false, section, setup);
9811
+ const group = createChannel(await readBody(req));
9173
9812
  return json(res, 201, { group: { ...publicGroupState(group), messages: [] } });
9174
9813
  }
9175
9814
  if (method === "POST" && path === "/api/teams/export") {
@@ -9188,12 +9827,17 @@ const handleRequest = async (req, res) => {
9188
9827
  return json(res, 200, createTeamBackup(store, routines.listRoutines(), name));
9189
9828
  }
9190
9829
  if (body.format === "package") {
9830
+ const selectedBots = store.bots.filter((bot) => !bot.hidden);
9831
+ const skillNames = checkedExportSkillNames(body.skillIds, selectedBots);
9832
+ if (!skillNames.ok)
9833
+ return json(res, 400, { error: skillNames.error });
9191
9834
  const document = createBotPackageExport({
9192
9835
  name,
9193
9836
  authorName: profileName,
9194
- bots: store.bots,
9837
+ bots: selectedBots,
9195
9838
  groups: store.groups,
9196
9839
  routines: routines.listRoutines(),
9840
+ skillsByBot: collectExportSkills(selectedBots, skillNames.names),
9197
9841
  });
9198
9842
  return json(res, 200, {
9199
9843
  name: document.package.name,
@@ -9338,8 +9982,8 @@ const handleRequest = async (req, res) => {
9338
9982
  const pkg = packageDocument?.package;
9339
9983
  const importName = pkg?.name ?? manifest.team.name;
9340
9984
  const sourceMembers = pkg
9341
- ? pkg.agents.map((agent) => ({ member: packageAgentAsMember(agent), playbookKeys: agent.playbooks ?? [] }))
9342
- : manifest.team.members.map((member) => ({ member, playbookKeys: [] }));
9985
+ ? pkg.agents.map((agent) => ({ member: packageAgentAsMember(agent), playbookKeys: agent.playbooks ?? [], skillNames: agent.skills ?? [] }))
9986
+ : manifest.team.members.map((member) => ({ member, playbookKeys: [], skillNames: [] }));
9343
9987
  const importedBots = [];
9344
9988
  const createdGroups = [];
9345
9989
  const createdRoutineIds = [];
@@ -9362,6 +10006,7 @@ const handleRequest = async (req, res) => {
9362
10006
  }
9363
10007
  }
9364
10008
  const playbookByKey = new Map((pkg?.playbooks ?? []).map((playbook) => [playbook.key, playbook]));
10009
+ const packageSkillByName = new Map((pkg?.skills?.entries ?? []).map((skill) => [skill.name, skill]));
9365
10010
  for (const source of sourceMembers) {
9366
10011
  const member = source.member;
9367
10012
  // importedMemberProfile is the authority boundary: persona fields
@@ -9395,6 +10040,17 @@ const handleRequest = async (req, res) => {
9395
10040
  }
9396
10041
  : {}),
9397
10042
  });
10043
+ for (const skillName of source.skillNames) {
10044
+ const skill = packageSkillByName.get(skillName);
10045
+ if (!skill)
10046
+ throw new Error(`Package skill "${skillName}" is unavailable`);
10047
+ const installed = installSkill(created.id, skill.source ?? `package:${pkg.id}`, [
10048
+ { path: "SKILL.md", content: skill.instructions },
10049
+ ]);
10050
+ if ("error" in installed) {
10051
+ throw new Error(`Package skill "${skillName}" could not be imported: ${installed.error}`);
10052
+ }
10053
+ }
9398
10054
  memberIds.set(member.key, created.id);
9399
10055
  }
9400
10056
  // A package is an explicit structure import: its rooms are created
@@ -9517,11 +10173,6 @@ const handleRequest = async (req, res) => {
9517
10173
  return json(res, 200, { group: publicGroupState(updated) });
9518
10174
  }
9519
10175
  // ── channel tasks: separate conversations for the same team ────────
9520
- const channelTaskBlocked = (group) => groupIsWorking(group) ||
9521
- store.groupTasks(group.id).some((task) => store.messagesFor(task.threadId).some((message) => message.kind === "options" &&
9522
- message.card?.requestId &&
9523
- !message.card.answered &&
9524
- !message.card.dismissed));
9525
10176
  // A scheduled goal starts in a detached task. Let the user open the
9526
10177
  // exact task that owns the live operation (or a durable approval card)
9527
10178
  // so they can observe or unblock it; switching to an unrelated task is
@@ -9637,113 +10288,7 @@ const handleRequest = async (req, res) => {
9637
10288
  if (field)
9638
10289
  return json(res, 403, { error: `forbidden: this session may rename or mark a room, not change "${field}" (needs the admin scope)` });
9639
10290
  }
9640
- if (!body || typeof body !== "object" || Array.isArray(body)) {
9641
- return json(res, 400, { error: "body must be a JSON object" });
9642
- }
9643
- const existing = store.group(m[1]);
9644
- if (!existing)
9645
- return json(res, 404, { error: "no such room" });
9646
- if (body.memberIds !== undefined && phoneSecretSubmissions.hasGroup(existing.id)) {
9647
- return json(res, 409, { error: "this channel is securely saving a credential — try again when it finishes" });
9648
- }
9649
- if (channelTaskBlocked(existing) &&
9650
- (body.memberIds !== undefined || body.defaultResponder !== undefined || body.bulletin !== undefined)) {
9651
- return json(res, 409, { error: "this channel is working or waiting on you — finish that turn first" });
9652
- }
9653
- const patch = {};
9654
- if (body.name !== undefined) {
9655
- if (typeof body.name !== "string")
9656
- return json(res, 400, { error: "room name must be a string" });
9657
- const name = body.name.trim();
9658
- if (!name)
9659
- return json(res, 400, { error: "room name must not be empty" });
9660
- if (name.length > 100)
9661
- return json(res, 400, { error: "room name must be at most 100 characters" });
9662
- patch.name = name;
9663
- }
9664
- if (body.bulletin !== undefined) {
9665
- if (typeof body.bulletin !== "string")
9666
- return json(res, 400, { error: "bulletin must be a string" });
9667
- if (body.bulletin.length > 12_000) {
9668
- return json(res, 400, { error: "bulletin must be at most 12000 characters" });
9669
- }
9670
- patch.bulletin = body.bulletin;
9671
- }
9672
- if (body.unread !== undefined) {
9673
- if (typeof body.unread !== "boolean")
9674
- return json(res, 400, { error: "unread must be true or false" });
9675
- patch.unread = body.unread;
9676
- }
9677
- if (body.memberIds !== undefined) {
9678
- // A DM is the pair it was opened for; only real rooms have a roster.
9679
- if (existing.dm)
9680
- return json(res, 400, { error: "direct-message channels cannot change members" });
9681
- const roster = checkedMemberIds(body.memberIds);
9682
- if (!roster.ok)
9683
- return json(res, 400, { error: roster.error.replace("channel", "room") });
9684
- const removedGoalLead = routines.listRoutines().some((routine) => routine.enabled &&
9685
- routine.target === "room-goal" &&
9686
- routine.groupId === existing.id &&
9687
- !roster.memberIds.includes(routine.botId)) || routines.listRuns().some((run) => run.target === "room-goal" &&
9688
- run.groupId === existing.id &&
9689
- ["queued", "running", "waiting"].includes(run.status) &&
9690
- !roster.memberIds.includes(run.botId));
9691
- if (removedGoalLead) {
9692
- return json(res, 409, {
9693
- error: "pause or reassign this room's team-goal routine before removing its lead",
9694
- });
9695
- }
9696
- patch.memberIds = roster.memberIds;
9697
- }
9698
- if (body.defaultResponder !== undefined) {
9699
- const memberIds = patch.memberIds ?? existing.memberIds;
9700
- const responder = checkedGroupResponder(body.defaultResponder, memberIds);
9701
- if (!responder)
9702
- return json(res, 400, { error: "invalid default responder" });
9703
- patch.defaultResponder = responder;
9704
- }
9705
- if (body.cwd !== undefined) {
9706
- if (existing.dm)
9707
- return json(res, 400, { error: "direct-message channels cannot have a working folder" });
9708
- if (existing.pinnedCwd !== undefined) {
9709
- return json(res, 409, { error: "the room's working folder is fixed after its first turn" });
9710
- }
9711
- const checked = validateBotCwd(body.cwd);
9712
- if (!checked.ok)
9713
- return json(res, 400, { error: checked.error });
9714
- patch.cwd = checked.cwd ?? undefined;
9715
- }
9716
- // one pinned message per room; null/"" clears. The id is not
9717
- // validated against the transcript here — a pin whose message was
9718
- // edited away or deleted simply resolves to nothing in the UI.
9719
- if (body.pinnedMessageId !== undefined) {
9720
- if (body.pinnedMessageId === null || body.pinnedMessageId === "")
9721
- patch.pinnedMessageId = undefined;
9722
- else if (typeof body.pinnedMessageId === "string" && /^[\w-]+$/.test(body.pinnedMessageId)) {
9723
- patch.pinnedMessageId = body.pinnedMessageId;
9724
- }
9725
- else
9726
- return json(res, 400, { error: "pinnedMessageId must be a message id" });
9727
- }
9728
- // same contract as a bot's sidebar section: null/"" clears, 60 chars max
9729
- if (body.section !== undefined) {
9730
- if (body.section === null)
9731
- patch.section = undefined;
9732
- else if (typeof body.section !== "string")
9733
- return json(res, 400, { error: "section must be a string" });
9734
- else {
9735
- const trimmed = body.section.trim();
9736
- if (!trimmed)
9737
- patch.section = undefined;
9738
- else if (trimmed.length > 60)
9739
- return json(res, 400, { error: "section must be at most 60 characters" });
9740
- else
9741
- patch.section = trimmed;
9742
- }
9743
- }
9744
- const group = store.patchGroup(m[1], patch);
9745
- if (!group)
9746
- return json(res, 404, { error: "no such room" });
10291
+ const group = updateChannel(m[1], body);
9747
10292
  return json(res, 200, { group: publicGroupState(group) });
9748
10293
  }
9749
10294
  m = path.match(/^\/api\/groups\/([\w-]+)\/read$/);
@@ -11021,31 +11566,130 @@ const handleRequest = async (req, res) => {
11021
11566
  broadcast({ kind: "bot", bot: visible });
11022
11567
  return json(res, 200, { bot: visible });
11023
11568
  }
11024
- // ── bot memory: MEMORY.md + memory/ topic files ─────────────────────
11025
- // The files already belong to the user (plain markdown in the bot's
11026
- // workspace); these routes only make them visible without a trip to
11027
- // the filesystem. Reads never create the workspace — a bot that has
11028
- // not run yet simply has nothing to show.
11569
+ // ── bot memory: MEMORY.md + memory/ topic and log files ──────────────
11570
+ // The files already belong to the person (plain markdown in the bot's
11571
+ // workspace). server/memory-store.ts decides which paths can be reached
11572
+ // and refuses a save whose expectedHash no longer matches the file;
11573
+ // server/memory-journal.ts records every change made here so it can be
11574
+ // read back and reverted. Reads never create the workspace — a bot that
11575
+ // has not run yet simply has nothing to show. Admin scope by default
11576
+ // (request-auth.ts), like the profile routes above.
11029
11577
  m = path.match(/^\/api\/bots\/([\w-]+)\/memory$/);
11030
11578
  if (m && method === "GET") {
11031
11579
  if (!store.bot(m[1]))
11032
11580
  return json(res, 404, { error: "no such bot" });
11033
- return json(res, 200, { ...readMemoryFile(m[1]), topics: listMemoryTopics(m[1]) });
11581
+ try {
11582
+ const overview = memoryOverview(m[1]);
11583
+ // `text` and `truncated` ride along one release for clients of the
11584
+ // old whole-file shape; the panel reads the file through /memory/file
11585
+ return json(res, 200, { ...overview, text: readMemoryDoc(m[1], MEMORY_INDEX).text, truncated: overview.index.truncated });
11586
+ }
11587
+ catch (error) {
11588
+ return replyMemoryError(res, error);
11589
+ }
11034
11590
  }
11035
11591
  if (m && method === "PUT") {
11592
+ // The pre-panel whole-file write, kept one release: no hash check, so
11593
+ // it can still overwrite a note the bot just wrote — journaled as the
11594
+ // person's so at least the journal can undo it.
11036
11595
  if (!store.bot(m[1]))
11037
11596
  return json(res, 404, { error: "no such bot" });
11038
11597
  const parsed = z.object({ text: z.string() }).safeParse(await readBody(req));
11039
11598
  if (!parsed.success)
11040
11599
  return json(res, 400, { error: "text must be a string" });
11041
- if (Buffer.byteLength(parsed.data.text, "utf8") > MEMORY_FILE_MAX_BYTES) {
11042
- return json(res, 400, {
11043
- error: `memory is capped at ${MEMORY_FILE_MAX_BYTES / 1024}KB — move longer notes into memory/<topic>.md files`,
11600
+ try {
11601
+ const { doc } = journalMemoryWrite(m[1], MEMORY_INDEX, parsed.data.text, { actor: "person", via: "api" });
11602
+ return json(res, 200, { ok: true, hash: doc.hash, truncated: memoryCapacity(doc.text).truncated });
11603
+ }
11604
+ catch (error) {
11605
+ // the old route answered 400 for an oversized body; keep that for
11606
+ // its callers while the new route says 413
11607
+ if (error instanceof MemoryStoreError && error.code === "too-large")
11608
+ return json(res, 400, { error: error.message });
11609
+ return replyMemoryError(res, error);
11610
+ }
11611
+ }
11612
+ m = path.match(/^\/api\/bots\/([\w-]+)\/memory\/file$/);
11613
+ if (m && method === "GET") {
11614
+ if (!store.bot(m[1]))
11615
+ return json(res, 404, { error: "no such bot" });
11616
+ try {
11617
+ return json(res, 200, readMemoryDoc(m[1], url.searchParams.get("path") ?? MEMORY_INDEX));
11618
+ }
11619
+ catch (error) {
11620
+ return replyMemoryError(res, error);
11621
+ }
11622
+ }
11623
+ if (m && method === "PUT") {
11624
+ if (!store.bot(m[1]))
11625
+ return json(res, 404, { error: "no such bot" });
11626
+ const parsed = z
11627
+ .object({ path: z.string().default(MEMORY_INDEX), text: z.string(), expectedHash: z.string().optional() })
11628
+ .safeParse(await readBody(req));
11629
+ if (!parsed.success)
11630
+ return json(res, 400, { error: "text must be a string; path and expectedHash are optional strings" });
11631
+ try {
11632
+ const { doc, entry } = journalMemoryWrite(m[1], parsed.data.path, parsed.data.text, {
11633
+ actor: "person",
11634
+ via: "ui",
11635
+ expectedHash: parsed.data.expectedHash,
11044
11636
  });
11637
+ return json(res, 200, { ok: true, ...doc, entry: entry ? journalEntryForClient(m[1], entry) : null, overview: memoryOverview(m[1]) });
11638
+ }
11639
+ catch (error) {
11640
+ return replyMemoryError(res, error);
11641
+ }
11642
+ }
11643
+ if (m && method === "DELETE") {
11644
+ if (!store.bot(m[1]))
11645
+ return json(res, 404, { error: "no such bot" });
11646
+ const file = url.searchParams.get("path") ?? "";
11647
+ try {
11648
+ const entry = journalMemoryDelete(m[1], file, { actor: "person", via: "ui" });
11649
+ return json(res, 200, { ok: true, path: file, entry: entry ? journalEntryForClient(m[1], entry) : null, overview: memoryOverview(m[1]) });
11650
+ }
11651
+ catch (error) {
11652
+ return replyMemoryError(res, error);
11045
11653
  }
11046
- writeMemoryFile(m[1], parsed.data.text);
11047
- // truncated echoes back so the editor can warn about the load budget
11048
- return json(res, 200, { ok: true, truncated: readMemoryFile(m[1]).truncated });
11654
+ }
11655
+ m = path.match(/^\/api\/bots\/([\w-]+)\/memory\/journal$/);
11656
+ if (m && method === "GET") {
11657
+ if (!store.bot(m[1]))
11658
+ return json(res, 404, { error: "no such bot" });
11659
+ // the row a save queued a moment ago may not have reached disk yet
11660
+ await flushMemoryJournal(m[1]);
11661
+ const limit = Math.min(200, Math.max(1, Number(url.searchParams.get("limit") ?? 50) || 50));
11662
+ const botId = m[1];
11663
+ return json(res, 200, { entries: readMemoryJournal(botId, limit).map((entry) => journalEntryForClient(botId, entry)) });
11664
+ }
11665
+ m = path.match(/^\/api\/bots\/([\w-]+)\/memory\/journal\/([\w-]+)\/revert$/);
11666
+ if (m && method === "POST") {
11667
+ if (!store.bot(m[1]))
11668
+ return json(res, 404, { error: "no such bot" });
11669
+ await flushMemoryJournal(m[1]);
11670
+ const result = revertMemoryChange(m[1], m[2]);
11671
+ if (!result.ok)
11672
+ return json(res, result.status, { error: result.error });
11673
+ return json(res, 200, { ok: true, ...result.doc, entry: result.entry ? journalEntryForClient(m[1], result.entry) : null, overview: memoryOverview(m[1]) });
11674
+ }
11675
+ m = path.match(/^\/api\/bots\/([\w-]+)\/memory\/open$/);
11676
+ if (m && method === "POST") {
11677
+ if (!store.bot(m[1]))
11678
+ return json(res, 404, { error: "no such bot" });
11679
+ const parsed = z.object({ target: z.enum(["obsidian", "folder"]) }).safeParse(await readBody(req));
11680
+ if (!parsed.success)
11681
+ return json(res, 400, { error: "target must be obsidian or folder" });
11682
+ // The folder is on this machine's disk; opening it only makes sense
11683
+ // from this machine. A paired phone or a remote browser gets the path
11684
+ // to open by hand instead.
11685
+ const workspacePath = memoryOverview(m[1]).workspacePath;
11686
+ if (auth.kind !== "loopback") {
11687
+ return json(res, 403, { error: `This only works on the computer running OpenMausBot. The memory folder there is ${workspacePath}`, workspacePath });
11688
+ }
11689
+ const opened = await openMemoryLocation(m[1], parsed.data.target);
11690
+ if (!opened.ok)
11691
+ return json(res, 500, { error: opened.error, workspacePath: opened.workspacePath });
11692
+ return json(res, 200, opened);
11049
11693
  }
11050
11694
  m = path.match(/^\/api\/bots\/([\w-]+)\/memory\/topics\/([^/]+)$/);
11051
11695
  if (m && method === "GET") {
@@ -12035,7 +12679,7 @@ const handleRequest = async (req, res) => {
12035
12679
  }
12036
12680
  if (action === "install") {
12037
12681
  if (!(await registry.installRuntime(instanceId)))
12038
- return json(res, 404, { error: "managed installation is unavailable" });
12682
+ return json(res, 404, { error: "Installing this engine from Settings is not available on this server. Use the install command on the machine running OpenMausBot." });
12039
12683
  return json(res, 200, { instances: await describeInstances() });
12040
12684
  }
12041
12685
  if (action === "auth/start") {
@@ -13242,6 +13886,7 @@ const gracefulShutdown = createGracefulShutdown({
13242
13886
  await browserRuntime.closeAll();
13243
13887
  },
13244
13888
  () => flushAllProfileHistory(),
13889
+ () => flushAllMemoryJournals(),
13245
13890
  ],
13246
13891
  // Cleanup jobs run concurrently. Release only after they settle (or reach
13247
13892
  // the shutdown deadline), immediately before the process exits, so no new