openmausbot 0.1.75 → 0.1.76

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 (39) hide show
  1. package/dist/assets/index-BtT047dk.css +1 -0
  2. package/dist/assets/{index-BTlyRwLl.js → index-C6blGSq5.js} +1 -1
  3. package/dist/assets/index-fKXFpB_J.js +305 -0
  4. package/dist/index.html +2 -2
  5. package/dist-server/companion/src/routes.js +6 -2
  6. package/dist-server/drivers/agents-proxy.js +133 -11
  7. package/dist-server/index.js +2602 -869
  8. package/dist-server/mcp-gate.js +28 -18
  9. package/dist-server/openmausbot.js +202 -120
  10. package/dist-server/pair-cli.js +202 -120
  11. package/dist-server/server/agent-tool-policy.js +1 -0
  12. package/dist-server/server/atomic.js +37 -1
  13. package/dist-server/server/bot-overview.js +5 -1
  14. package/dist-server/server/chief-of-staff.js +17 -12
  15. package/dist-server/server/delegations.js +177 -63
  16. package/dist-server/server/drivers/acp/hermes.js +21 -3
  17. package/dist-server/server/drivers/agents-proxy.js +113 -10
  18. package/dist-server/server/drivers/claude.js +31 -14
  19. package/dist-server/server/drivers/openai-chat.js +90 -67
  20. package/dist-server/server/drivers/openai-compat.js +9 -1
  21. package/dist-server/server/drivers/pi.js +18 -3
  22. package/dist-server/server/env-path.js +18 -17
  23. package/dist-server/server/index.js +1112 -312
  24. package/dist-server/server/message-db.js +21 -0
  25. package/dist-server/server/peer-roster.js +48 -9
  26. package/dist-server/server/procs.js +19 -3
  27. package/dist-server/server/room-handoffs.js +237 -0
  28. package/dist-server/server/section-context.js +64 -14
  29. package/dist-server/server/store.js +247 -8
  30. package/dist-server/server/surface.js +16 -5
  31. package/dist-server/server/team-backup.js +17 -1
  32. package/dist-server/server/team-setup-requests.js +252 -0
  33. package/dist-server/server/turn-dispatch-guard.js +31 -3
  34. package/dist-server/shared/approval-mode.js +7 -0
  35. package/dist-server/shared/team-backup.js +1 -0
  36. package/dist-server/shared/team-setup.js +1 -0
  37. package/package.json +1 -1
  38. package/dist/assets/index-CeMbvsgx.js +0 -305
  39. package/dist/assets/index-DH7Zax6L.css +0 -1
@@ -6,6 +6,7 @@ import { createHash } from "node:crypto";
6
6
  import { existsSync, readFileSync, mkdirSync, rmSync, unlinkSync } from "node:fs";
7
7
  import { join } from "node:path";
8
8
  import { writeFileAtomic } from "./atomic.js";
9
+ import { ensureSections, readSections, changeEmptySection } from "./section-context.js";
9
10
  import { removeBotFolder, soulFile, soulHash, writeSoulMirror } from "./bot-folder.js";
10
11
  import { peerAllowKey } from "./peer-approval-key.js";
11
12
  import { DATA_DIR, loadBrowserProfileIdAliases } from "./config.js";
@@ -294,6 +295,8 @@ export class Store {
294
295
  threads = new Map();
295
296
  defaultSelection;
296
297
  listeners = new Set();
298
+ /** A broken team registry must not prevent loading independent chat data. */
299
+ registeringInitialSections = true;
297
300
  /** Room turns and old callers have their own activity slot. Clearing
298
301
  * that slot must not clear a concurrently running independent task. */
299
302
  legacyActivities = new Map();
@@ -312,6 +315,7 @@ export class Store {
312
315
  catch {
313
316
  this.groups = [];
314
317
  }
318
+ this.rememberSections([...this.bots, ...this.groups].map((record) => record.section));
315
319
  // busy never survives a restart — no turn does either. Rooms saved
316
320
  // before default responders existed adopt their first member as lead.
317
321
  let botsMigrated = false;
@@ -359,6 +363,11 @@ export class Store {
359
363
  delete b.autoStartVps;
360
364
  botsMigrated = true;
361
365
  }
366
+ if (b.managedSections !== undefined && (!b.chiefOfStaff || !Array.isArray(b.managedSections) ||
367
+ b.managedSections.length > 100 || b.managedSections.some(section => typeof section !== "string" || section.length > 60))) {
368
+ delete b.managedSections;
369
+ botsMigrated = true;
370
+ }
362
371
  if (b.approvalMode !== undefined && !isApprovalMode(b.approvalMode)) {
363
372
  delete b.approvalMode;
364
373
  botsMigrated = true;
@@ -403,6 +412,7 @@ export class Store {
403
412
  continue;
404
413
  }
405
414
  b.chiefOfStaff = false;
415
+ delete b.managedSections;
406
416
  botsMigrated = true;
407
417
  }
408
418
  // Peer grants originally used mutable display names (ask_bot:@Helper).
@@ -547,16 +557,59 @@ export class Store {
547
557
  if (existsSync(legacyFile))
548
558
  mdb.readThread(threadId, legacyFile);
549
559
  }
560
+ this.registeringInitialSections = false;
550
561
  }
551
562
  saveBots(bots = this.bots) {
563
+ this.rememberSections([...this.bots, ...bots].map((bot) => bot.section));
552
564
  writeFileAtomic(BOTS_FILE, JSON.stringify(bots.map(({ busy: _busy, activity: _activity, ...bot }) => ({
553
565
  ...bot,
554
566
  tasks: bot.tasks?.map(({ busy: _taskBusy, activity: _taskActivity, ...task }) => task),
555
567
  })), null, 2));
556
568
  }
557
569
  saveGroups() {
570
+ this.rememberSections(this.groups.map((group) => group.section));
558
571
  writeFileAtomic(GROUPS_FILE, JSON.stringify(this.groups.map(({ busyBotId: _busyBotId, ...g }) => g), null, 2));
559
572
  }
573
+ get sections() { return readSections(); }
574
+ rememberSections(names) {
575
+ try {
576
+ if (ensureSections(names))
577
+ this.emit({ type: "sections" });
578
+ }
579
+ catch (error) {
580
+ if (!this.registeringInitialSections)
581
+ throw error;
582
+ console.warn(`[teams] Startup could not register team names; saved teams and shared instructions were left unchanged: ${error.message}`);
583
+ }
584
+ }
585
+ /** Empty-only changes cannot merge teams or silently change anybody's access. */
586
+ changeEmptySection(name, nextName) {
587
+ if (!this.sections.includes(name))
588
+ return "No such team";
589
+ if ([...this.bots, ...this.groups].some((record) => sectionKey(record.section) === name)) {
590
+ return "Move all bots (including archived bots) and group chats out of this team first";
591
+ }
592
+ if (nextName !== null && nextName !== name && this.sections.includes(nextName)) {
593
+ return "A team with that name already exists";
594
+ }
595
+ if (nextName === name)
596
+ return undefined;
597
+ const revoked = this.bots.filter((bot) => bot.managedSections?.some((section) => sectionKey(section) === name));
598
+ if (revoked.length) {
599
+ const grants = new Map(revoked.map((bot) => [bot.id, bot.managedSections.filter((section) => sectionKey(section) !== name)]));
600
+ // Revoke durably before freeing the name. If the registry write then
601
+ // fails, authority stays narrowed; recreating a name can never revive
602
+ // its old grants. Update existing objects so in-flight checks see it.
603
+ this.saveBots(this.bots.map((bot) => grants.has(bot.id) ? { ...bot, managedSections: grants.get(bot.id) } : bot));
604
+ for (const bot of revoked)
605
+ bot.managedSections = grants.get(bot.id);
606
+ for (const bot of revoked)
607
+ this.emit({ type: "bot", botId: bot.id });
608
+ }
609
+ changeEmptySection(name, nextName);
610
+ this.emit({ type: "sections" });
611
+ return undefined;
612
+ }
560
613
  // ── groups ────────────────────────────────────────────────────────────
561
614
  /** Subscribe to every write. Listeners run after the write and after
562
615
  * save; a throwing listener never breaks the write. */
@@ -581,6 +634,7 @@ export class Store {
581
634
  return this.groups.find((group) => group.threadId === threadId || group.tasks?.some((task) => task.threadId === threadId));
582
635
  }
583
636
  createGroup(name, memberIds, dm = false, section, setup) {
637
+ this.rememberSections([section]);
584
638
  const threadId = newId();
585
639
  const createdAt = Date.now();
586
640
  const group = {
@@ -616,6 +670,9 @@ export class Store {
616
670
  const group = this.group(id);
617
671
  if (!group)
618
672
  return null;
673
+ if (Object.prototype.hasOwnProperty.call(patch, "section")) {
674
+ this.rememberSections([patch.section]);
675
+ }
619
676
  Object.assign(group, patch);
620
677
  if (!group.dm && Object.prototype.hasOwnProperty.call(patch, "pinnedMessageId")) {
621
678
  const active = this.activeGroupTask(group.id);
@@ -782,12 +839,20 @@ export class Store {
782
839
  return this.patchMessage(threadId, messageId, { reactions: next.length ? next : undefined });
783
840
  }
784
841
  thread(threadId) {
785
- let t = this.threads.get(threadId);
842
+ const t = this.threads.get(threadId);
786
843
  if (t)
787
844
  return t;
788
845
  // SQLite is the source of truth; a thread with no rows imports its
789
846
  // legacy messages-<threadId>.json once, inside readThread
790
- const { messages, activeLeafId: storedLeaf } = mdb.readThread(threadId, messagesFile(threadId));
847
+ return this.cacheThread(threadId, mdb.readThread(threadId, messagesFile(threadId)));
848
+ }
849
+ /** Finish hydrating a full set of thread rows into the cache: chain any
850
+ * legacy (pre-branching) rows' parentId in array order, default the
851
+ * active leaf to the newest message, and store it. Shared by a full load
852
+ * and by messagesTail() when its bounded read turns out to be the whole
853
+ * thread anyway. */
854
+ cacheThread(threadId, rows) {
855
+ const { messages, activeLeafId: storedLeaf } = rows;
791
856
  let activeLeafId = storedLeaf;
792
857
  // legacy rows carry no parentId — chain them in array order
793
858
  let prev = null;
@@ -798,13 +863,44 @@ export class Store {
798
863
  }
799
864
  if (!activeLeafId)
800
865
  activeLeafId = messages.at(-1)?.id ?? null;
801
- t = { messages, activeLeafId };
866
+ const t = { messages, activeLeafId };
802
867
  this.threads.set(threadId, t);
803
868
  return t;
804
869
  }
805
870
  messagesFor(threadId) {
806
871
  return this.thread(threadId).messages;
807
872
  }
873
+ /** A bounded page of a thread's newest messages, for callers that only
874
+ * need a display page — the startup/reconnect hydrate and a fresh
875
+ * scrollback view. Reads just `limit` rows at the SQL boundary instead of
876
+ * the whole transcript, unless the thread is already cached from other
877
+ * work (then it's a plain in-memory slice, no extra SQL) or the bounded
878
+ * read comes back as the complete thread anyway (short thread, or a
879
+ * one-time legacy import) — that gets cached like any other full load so
880
+ * a later messagesFor() doesn't re-read it. Legacy rows that predate
881
+ * per-message parentId are only chained correctly on a full load, so a
882
+ * bounded page missing that context falls back to one rather than
883
+ * returning messages with a broken parent chain. */
884
+ messagesTail(threadId, limit) {
885
+ let state = this.threads.get(threadId);
886
+ if (!state) {
887
+ const tail = mdb.readThreadTail(threadId, messagesFile(threadId), limit);
888
+ const legacyRows = tail.hasMore !== undefined && tail.messages.some((m) => m.parentId === undefined);
889
+ if (tail.hasMore !== true || legacyRows) {
890
+ state = this.cacheThread(threadId, legacyRows ? mdb.readThread(threadId, messagesFile(threadId)) : tail);
891
+ }
892
+ else {
893
+ return {
894
+ messages: tail.messages,
895
+ hasMore: tail.hasMore,
896
+ activeLeafId: tail.activeLeafId ?? tail.messages.at(-1)?.id ?? null,
897
+ };
898
+ }
899
+ }
900
+ const { messages, activeLeafId } = state;
901
+ const start = Math.max(0, messages.length - limit);
902
+ return { messages: messages.slice(start), hasMore: start > 0, activeLeafId };
903
+ }
808
904
  /** Used only with newly allocated import threads. No live actions are
809
905
  * replayed: the importer supplies inert text and freshly remapped IDs. */
810
906
  importTranscript(threadId, messages, activeLeafId) {
@@ -982,6 +1078,7 @@ export class Store {
982
1078
  return this.bots.find((b) => b.threadId === threadId || b.tasks?.some((t) => t.threadId === threadId)) ?? null;
983
1079
  }
984
1080
  createBot(profile = {}, opts = {}) {
1081
+ this.rememberSections([profile.section]);
985
1082
  const name = profile.name?.trim() || pickBotName(this.bots.map((b) => b.name));
986
1083
  const section = sectionKey(profile.section);
987
1084
  const bot = {
@@ -1037,11 +1134,103 @@ export class Store {
1037
1134
  }
1038
1135
  return bot;
1039
1136
  }
1040
- deleteBot(id) {
1137
+ /** All setup fields and the Chief's receipt commit before publishing any
1138
+ * mutation. Model defaults never rewrite saved thread selections. */
1139
+ applyTeamSetup(request) {
1140
+ const chief = this.bot(request.botId);
1141
+ if (!chief)
1142
+ throw new Error("The requesting Chief no longer exists");
1143
+ if (chief.lastTeamSetupReceipt?.requestId === request.requestId)
1144
+ return chief.lastTeamSetupReceipt.result;
1145
+ const managedSections = [...new Set([...(chief.managedSections ?? []), ...request.newTeams])];
1146
+ if (managedSections.length > 100 || managedSections.some((name) => name.trim() !== name || name.length > 60) ||
1147
+ request.newTeams.some((name) => !name) || (request.newTeams.length && !chief.chiefOfStaff))
1148
+ throw new Error("Invalid reviewed Chief team scope");
1149
+ const nextBots = [...this.bots];
1150
+ const changed = [];
1151
+ for (const operation of request.operations) {
1152
+ const at = nextBots.findIndex((bot) => bot.id === operation.botId);
1153
+ let next;
1154
+ if (operation.action === "create") {
1155
+ if (at >= 0 || !operation.threadId || !operation.fields.name || !operation.fields.modelSelection)
1156
+ throw new Error("Invalid new bot in team setup");
1157
+ const createdAt = Date.now();
1158
+ next = { id: operation.botId, threadId: operation.threadId, name: operation.fields.name,
1159
+ title: "", description: "", soul: "", notifications: true, color: COLORS[nextBots.length % COLORS.length], unread: false,
1160
+ modelSelection: operation.fields.modelSelection, resumeCursors: {}, createdAt, ...operation.fields,
1161
+ approvalMode: "ask", autoApprove: false, composio: false, approvePeerComms: false,
1162
+ tasks: [{ threadId: operation.threadId, title: UNTITLED_THREAD, createdAt, resumeCursors: {},
1163
+ modelSelection: structuredClone(operation.fields.modelSelection), approvalMode: "ask", autoApprove: false,
1164
+ unread: false, activity: "idle", busy: false }],
1165
+ };
1166
+ nextBots.unshift(next);
1167
+ }
1168
+ else {
1169
+ if (at < 0)
1170
+ throw new Error("A setup target no longer exists");
1171
+ const previous = nextBots[at];
1172
+ next = { ...previous, ...operation.fields };
1173
+ if (operation.fields.modelSelection)
1174
+ next.tasks = previous.tasks?.map((task) => ({
1175
+ ...task,
1176
+ modelSelection: structuredClone(task.modelSelection ?? previous.modelSelection),
1177
+ approvalMode: approvalModeFor(this.projectBotForTask(previous.id, task.threadId)),
1178
+ autoApprove: task.autoApprove ?? previous.autoApprove,
1179
+ alwaysAllow: structuredClone(task.alwaysAllow ?? previous.alwaysAllow ?? []),
1180
+ }));
1181
+ nextBots[at] = next;
1182
+ }
1183
+ next.section = sectionKey(next.section) || undefined;
1184
+ if (operation.fields.soul !== undefined) {
1185
+ next.soulHash = soulHash(operation.fields.soul);
1186
+ next.soulDrift = false;
1187
+ }
1188
+ changed.push(next);
1189
+ }
1190
+ const result = { state: "applied", newTeams: request.newTeams, bots: changed.map((bot, index) => ({
1191
+ id: bot.id, name: bot.name, section: bot.section, modelSelection: structuredClone(bot.modelSelection),
1192
+ action: request.operations[index].action === "create" ? "created" : "updated",
1193
+ })) };
1194
+ const chiefAt = nextBots.findIndex((bot) => bot.id === chief.id);
1195
+ const nextChief = { ...nextBots[chiefAt], lastTeamSetupReceipt: { requestId: request.requestId, result } };
1196
+ // Only the newly-created teams explicitly named in the human review may
1197
+ // extend this Chief's reach. Existing teams require owner settings.
1198
+ if (request.newTeams.length) {
1199
+ nextChief.managedSections = managedSections;
1200
+ }
1201
+ nextBots[chiefAt] = nextChief;
1202
+ this.saveBots(nextBots);
1203
+ this.bots = nextBots;
1204
+ for (const bot of changed) {
1205
+ try {
1206
+ writeSoulMirror(bot.id, bot.soul ?? "");
1207
+ }
1208
+ catch (error) {
1209
+ console.warn(`[bot-folder] could not refresh reviewed setup mirror for ${bot.id}: ${error.message}`);
1210
+ }
1211
+ this.emit({ type: "bot", botId: bot.id });
1212
+ }
1213
+ this.emit({ type: "bot", botId: chief.id });
1214
+ return result;
1215
+ }
1216
+ deleteBot(id, setupRequest) {
1041
1217
  const bot = this.bot(id);
1042
1218
  if (!bot)
1043
1219
  return false;
1044
- this.bots = this.bots.filter((b) => b.id !== id);
1220
+ let nextBots = this.bots.filter((b) => b.id !== id);
1221
+ if (setupRequest) {
1222
+ const chief = this.bot(setupRequest.botId);
1223
+ if (!chief || chief.id === id || setupRequest.deletion?.botId !== id)
1224
+ throw new Error("The reviewed deletion no longer has a valid owner");
1225
+ const lastTeamSetupReceipt = { requestId: setupRequest.requestId, result: { state: "applied", newTeams: [], bots: [
1226
+ { id: bot.id, name: bot.name, action: "deleted" },
1227
+ ] } };
1228
+ nextBots = nextBots.map((candidate) => candidate.id === chief.id ? { ...candidate, lastTeamSetupReceipt } : candidate);
1229
+ }
1230
+ // Persist removal and the review receipt before deleting conversation or
1231
+ // workspace data. A failed save must leave the bot recoverable in place.
1232
+ this.saveBots(nextBots);
1233
+ this.bots = nextBots;
1045
1234
  this.legacyActivities.delete(id);
1046
1235
  // every task's transcript goes with the bot, not just the open one
1047
1236
  for (const threadId of new Set([bot.threadId, ...(bot.tasks ?? []).map((t) => t.threadId)])) {
@@ -1064,7 +1253,6 @@ export class Store {
1064
1253
  catch { }
1065
1254
  // The bot folder (SOUL.md mirror) is the bot's too.
1066
1255
  removeBotFolder(id);
1067
- this.saveBots();
1068
1256
  this.emit({ type: "bot.deleted", botId: id });
1069
1257
  return true;
1070
1258
  }
@@ -1164,6 +1352,7 @@ export class Store {
1164
1352
  for (const botId of changedIds)
1165
1353
  this.emit({ type: "bot", botId });
1166
1354
  }
1355
+ this.rememberSections([targetSection]);
1167
1356
  return { ok: true, bots: ids.map((id) => this.bot(id)) };
1168
1357
  }
1169
1358
  /** Legacy bot/room activity occupies its own slot; direct conversations
@@ -1221,6 +1410,7 @@ export class Store {
1221
1410
  }
1222
1411
  else {
1223
1412
  bot.chiefOfStaff = false;
1413
+ delete bot.managedSections;
1224
1414
  }
1225
1415
  changed.push(bot);
1226
1416
  }
@@ -1460,6 +1650,37 @@ export class Store {
1460
1650
  this.emit({ type: "bot", botId });
1461
1651
  return task;
1462
1652
  }
1653
+ /** Model/provider changes are one configuration transaction: never publish
1654
+ * a new provider before its confirmed approval downgrade, or change the
1655
+ * default while leaving the selected thread behind after a write failure. */
1656
+ switchTaskModel(botId, threadId, selection, updateBotDefault, resetApprovalToAsk, taskPatch = {}) {
1657
+ const bot = this.bot(botId);
1658
+ const task = this.taskByThread(botId, threadId);
1659
+ if (!bot || !task)
1660
+ return null;
1661
+ const patch = { modelSelection: structuredClone(selection),
1662
+ ...(resetApprovalToAsk ? { approvalMode: "ask", autoApprove: false, alwaysAllow: [] } : {}) };
1663
+ const nextTask = { ...task, ...taskPatch, ...patch,
1664
+ ...(typeof taskPatch.title === "string" ? { title: taskPatch.title.trim().slice(0, 80) || UNTITLED_THREAD } : {}) };
1665
+ // Older threads may still inherit settings. Freeze their effective
1666
+ // values before updating the default so "other threads unchanged" also
1667
+ // holds for workspaces created before per-thread approval settings.
1668
+ const nextTasks = bot.tasks.map((candidate) => candidate === task ? nextTask : !updateBotDefault ? candidate : {
1669
+ ...candidate,
1670
+ modelSelection: structuredClone(candidate.modelSelection ?? bot.modelSelection),
1671
+ approvalMode: approvalModeFor(this.projectBotForTask(botId, candidate.threadId)),
1672
+ autoApprove: candidate.autoApprove ?? bot.autoApprove,
1673
+ alwaysAllow: structuredClone(candidate.alwaysAllow ?? bot.alwaysAllow ?? []),
1674
+ });
1675
+ const next = { ...bot, ...(updateBotDefault ? patch : {}),
1676
+ tasks: nextTasks };
1677
+ this.saveBots(this.bots.map((candidate) => candidate === bot ? next : candidate));
1678
+ bot.tasks.forEach((candidate, index) => Object.assign(candidate, nextTasks[index]));
1679
+ if (updateBotDefault)
1680
+ Object.assign(bot, patch);
1681
+ this.emit({ type: "bot", botId });
1682
+ return task;
1683
+ }
1463
1684
  mirrorActiveTask(bot, task) {
1464
1685
  bot.threadId = task.threadId;
1465
1686
  bot.resumeCursors = structuredClone(task.resumeCursors);
@@ -1511,6 +1732,24 @@ export class Store {
1511
1732
  this.emit({ type: "bot", botId });
1512
1733
  return task;
1513
1734
  }
1735
+ /** Stamp or clear the closer record. `null` reopens: the next turn in a
1736
+ * closed thread calls this so the row comes back to the sidebar. Never
1737
+ * reachable from the HTTP task PATCH: closedBy is not a TASK_PATCH_FIELD. */
1738
+ setTaskClosedBy(botId, threadId, closedBy) {
1739
+ const bot = this.bot(botId);
1740
+ const task = this.taskByThread(botId, threadId);
1741
+ if (!bot || !task)
1742
+ return null;
1743
+ if (closedBy)
1744
+ task.closedBy = structuredClone(closedBy);
1745
+ else if (!task.closedBy)
1746
+ return task;
1747
+ else
1748
+ delete task.closedBy;
1749
+ this.saveBots();
1750
+ this.emit({ type: "bot", botId });
1751
+ return task;
1752
+ }
1514
1753
  switchTask(botId, threadId) {
1515
1754
  const bot = this.bot(botId);
1516
1755
  const task = bot?.tasks?.find((t) => t.threadId === threadId);
@@ -1534,10 +1773,10 @@ export class Store {
1534
1773
  this.emit({ type: "bot", botId });
1535
1774
  }
1536
1775
  /** Delete a task and its transcript, retaining generated project files.
1537
- * A bot always keeps one. */
1776
+ * When no visible tasks remain, replace it with a fresh conversation. */
1538
1777
  deleteTask(botId, threadId) {
1539
1778
  const bot = this.bot(botId);
1540
- if (!bot || !bot.tasks || bot.tasks.length < 2)
1779
+ if (!bot?.tasks)
1541
1780
  return null;
1542
1781
  if (!bot.tasks.some((t) => t.threadId === threadId))
1543
1782
  return null;
@@ -19,14 +19,25 @@ export function surfaceOfComputerKind(kind) {
19
19
  return kind;
20
20
  }
21
21
  const NO_BROWSER_NOTE = " This bot is set to work in the built-in browser, but the built-in browser is switched off in App Settings, so you have no browser and no computer this turn — say so instead of guessing.";
22
+ const OFF_NOTE = " This bot's \"Works on\" setting is Off, so no computer and no built-in browser are mounted this turn: you cannot open a page, click, or type on any screen. If the user asks for something that needs one, tell them Works on is Off in this bot's settings — never claim you are opening a browser you do not have.";
22
23
  /** Decide what a turn mounts from the bot's destination, the task's pin
23
- * and what is actually reachable. Explicit choices are strict: a computer
24
- * destination keeps whatever browser the bot has (the prompt tells the
25
- * model which to use), a browser destination mounts only the browser. Auto
26
- * follows the task's pin while the pinned place still exists, and otherwise
27
- * re-resolves the way it always has. */
24
+ * and what is actually reachable. Explicit choices are strict: Off mounts
25
+ * neither surface, a computer destination keeps whatever browser the bot
26
+ * has (the prompt tells the model which to use), a browser destination
27
+ * mounts only the browser. Auto follows the task's pin while the pinned
28
+ * place still exists, and otherwise re-resolves the way it always has. */
28
29
  export function resolveSurface(input) {
29
30
  const { destination, browserOn } = input;
31
+ // Off is the whole answer: no computer and no browser. It used to withhold
32
+ // only the computer, which left a bot set to Off holding the built-in
33
+ // browser — the one surface the setting most obviously reads as forbidding,
34
+ // and the one people then watched it reach for. The overview ("Can't use a
35
+ // computer.") and the settings prompt preview already described Off this
36
+ // way; the dispatch was the odd one out. A bot that should keep the browser
37
+ // and nothing else has its own destination: Browser.
38
+ if (destination === "off") {
39
+ return { computer: "off", browser: false, pinned: null, clearPin: false, note: OFF_NOTE };
40
+ }
30
41
  if (destination === "browser") {
31
42
  return browserOn
32
43
  ? { computer: "off", browser: true, pinned: null, clearPin: false, note: "" }
@@ -66,6 +66,9 @@ export function createTeamBackup(store, routines, name) {
66
66
  openedBy: "openedBy" in task && task.openedBy
67
67
  ? { botId: task.openedBy.botId, name: task.openedBy.name, at: task.openedBy.at }
68
68
  : undefined,
69
+ closedBy: "closedBy" in task && task.closedBy
70
+ ? { botId: task.closedBy.botId, name: task.closedBy.name, at: task.closedBy.at }
71
+ : undefined,
69
72
  activeLeafId: store.activeLeaf(task.threadId),
70
73
  messages: store.messagesFor(task.threadId).map((message) => ({
71
74
  id: message.id, role: message.role, text: messageText(message), at: message.at,
@@ -130,7 +133,10 @@ export function importTeamBackup(store, routines, input, selection) {
130
133
  const groupIds = new Map();
131
134
  const takenNames = new Set(store.bots.map((bot) => bot.name.trim().toLowerCase()));
132
135
  const takenGroups = new Set(store.groups.map((group) => group.name.trim().toLowerCase()));
133
- const takenSections = new Set([...store.bots, ...store.groups].map((record) => record.section?.trim().toLowerCase() ?? ""));
136
+ const takenSections = new Set([
137
+ ...store.sections.map((section) => section.toLowerCase()),
138
+ ...[...store.bots, ...store.groups].map((record) => record.section?.trim().toLowerCase() ?? ""),
139
+ ]);
134
140
  const sections = new Map();
135
141
  const sectionFor = (section) => {
136
142
  const key = section?.trim() ?? "";
@@ -182,6 +188,12 @@ export function importTeamBackup(store, routines, input, selection) {
182
188
  const opener = task.openedBy && botIds.get(task.openedBy.botId);
183
189
  if (task.openedBy && opener)
184
190
  record.openedBy = { botId: opener, name: task.openedBy.name, at: task.openedBy.at };
191
+ // A closed thread stays closed after import — the pile the person
192
+ // tidied does not come back as a pile — with the closer remapped
193
+ // the same way, or absent when it was a stranger.
194
+ const closer = task.closedBy && botIds.get(task.closedBy.botId);
195
+ if (task.closedBy && closer)
196
+ record.closedBy = { botId: closer, name: task.closedBy.name, at: task.closedBy.at };
185
197
  return record;
186
198
  });
187
199
  // Own the task IDs before writing their transcripts, so rollback also
@@ -228,6 +240,10 @@ export function importTeamBackup(store, routines, input, selection) {
228
240
  for (const bot of store.bots)
229
241
  if (!existingBotIds.has(bot.id))
230
242
  store.deleteBot(bot.id);
243
+ for (const section of sections.values()) {
244
+ if (store.sections.includes(section))
245
+ store.changeEmptySection(section, null);
246
+ }
231
247
  throw error;
232
248
  }
233
249
  }