openmausbot 0.1.75 → 0.1.77
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{index-BTlyRwLl.js → index-DeK-z9ld.js} +1 -1
- package/dist/assets/index-XdHJ-9CF.css +1 -0
- package/dist/assets/index-lhqE1umn.js +305 -0
- package/dist/index.html +2 -2
- package/dist-server/companion/src/routes.js +6 -2
- package/dist-server/container-mcp.js +5 -1
- package/dist-server/drivers/agents-proxy.js +184 -11
- package/dist-server/enterprise/server/index.js +387 -29
- package/dist-server/index.js +3786 -1135
- package/dist-server/mcp-gate.js +28 -18
- package/dist-server/openmausbot.js +927 -642
- package/dist-server/pair-cli.js +927 -642
- package/dist-server/server/agent-tool-policy.js +2 -0
- package/dist-server/server/atomic.js +37 -1
- package/dist-server/server/bot-overview.js +5 -1
- package/dist-server/server/browser-live.js +3 -0
- package/dist-server/server/chief-of-staff.js +17 -12
- package/dist-server/server/config.js +16 -0
- package/dist-server/server/delegations.js +177 -63
- package/dist-server/server/drivers/acp/core.js +3 -1
- package/dist-server/server/drivers/acp/hermes.js +21 -3
- package/dist-server/server/drivers/agents-proxy.js +169 -10
- package/dist-server/server/drivers/boxagent.js +1 -1
- package/dist-server/server/drivers/claude.js +34 -16
- package/dist-server/server/drivers/codex.js +3 -1
- package/dist-server/server/drivers/openai-chat.js +90 -67
- package/dist-server/server/drivers/openai-compat.js +9 -1
- package/dist-server/server/drivers/pi.js +22 -3
- package/dist-server/server/enterprise.js +38 -0
- package/dist-server/server/env-path.js +18 -17
- package/dist-server/server/environment.js +3 -0
- package/dist-server/server/fleet-agent.js +57 -13
- package/dist-server/server/fleet-cli.js +248 -31
- package/dist-server/server/fleet.js +88 -14
- package/dist-server/server/hosted-contract.js +16 -0
- package/dist-server/server/index.js +1777 -406
- package/dist-server/server/message-db.js +21 -0
- package/dist-server/server/message-file.js +4 -0
- package/dist-server/server/peer-roster.js +48 -9
- package/dist-server/server/procs.js +19 -3
- package/dist-server/server/provider-key-check.js +18 -1
- package/dist-server/server/request-auth.js +14 -5
- package/dist-server/server/room-handoffs.js +237 -0
- package/dist-server/server/section-context.js +64 -14
- package/dist-server/server/sessions.js +115 -8
- package/dist-server/server/shared-computer-control.js +24 -0
- package/dist-server/server/shared-computers.js +131 -0
- package/dist-server/server/steer-queue.js +2 -0
- package/dist-server/server/store.js +267 -15
- package/dist-server/server/surface.js +16 -5
- package/dist-server/server/team-backup.js +17 -1
- package/dist-server/server/team-computers.js +108 -0
- package/dist-server/server/team-setup-requests.js +252 -0
- package/dist-server/server/tool-summary.js +53 -1
- package/dist-server/server/turn-dispatch-guard.js +31 -3
- package/dist-server/server/workspace-backup.js +1 -0
- package/dist-server/shared/approval-mode.js +11 -1
- package/dist-server/shared/team-backup.js +1 -0
- package/dist-server/shared/team-computer.js +1 -0
- package/dist-server/shared/team-setup.js +1 -0
- package/dist-server/vps-container-mcp.js +5 -1
- package/enterprise/README.md +21 -3
- package/enterprise/server/index.js +387 -29
- package/package.json +1 -1
- package/dist/assets/index-CeMbvsgx.js +0 -305
- 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;
|
|
@@ -369,15 +378,27 @@ export class Store {
|
|
|
369
378
|
// bots.json write. Revoke it before schedulers, listeners, or HTTP can
|
|
370
379
|
// start any new work.
|
|
371
380
|
if (b.approvalGrant !== undefined) {
|
|
372
|
-
b.
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
381
|
+
const threadOnly = b.approvalGrant.threadOnly === true;
|
|
382
|
+
if (threadOnly) {
|
|
383
|
+
// A crash may land between saving the target and clearing its
|
|
384
|
+
// journal. Revoke that target only, never unrelated threads.
|
|
385
|
+
const target = b.tasks?.find(task => task.threadId === b.approvalGrant?.threadId);
|
|
386
|
+
if (target) {
|
|
387
|
+
target.approvalMode = "ask";
|
|
388
|
+
target.autoApprove = false;
|
|
379
389
|
}
|
|
380
390
|
}
|
|
391
|
+
if (!threadOnly) {
|
|
392
|
+
b.approvalMode = "ask";
|
|
393
|
+
b.autoApprove = false;
|
|
394
|
+
for (const task of b.tasks ?? []) {
|
|
395
|
+
if (task.approvalMode === "full" || task.approvalMode === "custom") {
|
|
396
|
+
task.approvalMode = "ask";
|
|
397
|
+
task.autoApprove = false;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
delete b.approvalGrant;
|
|
381
402
|
botsMigrated = true;
|
|
382
403
|
}
|
|
383
404
|
const avatar = botAvatarProfile(b);
|
|
@@ -403,6 +424,7 @@ export class Store {
|
|
|
403
424
|
continue;
|
|
404
425
|
}
|
|
405
426
|
b.chiefOfStaff = false;
|
|
427
|
+
delete b.managedSections;
|
|
406
428
|
botsMigrated = true;
|
|
407
429
|
}
|
|
408
430
|
// Peer grants originally used mutable display names (ask_bot:@Helper).
|
|
@@ -547,16 +569,59 @@ export class Store {
|
|
|
547
569
|
if (existsSync(legacyFile))
|
|
548
570
|
mdb.readThread(threadId, legacyFile);
|
|
549
571
|
}
|
|
572
|
+
this.registeringInitialSections = false;
|
|
550
573
|
}
|
|
551
574
|
saveBots(bots = this.bots) {
|
|
575
|
+
this.rememberSections([...this.bots, ...bots].map((bot) => bot.section));
|
|
552
576
|
writeFileAtomic(BOTS_FILE, JSON.stringify(bots.map(({ busy: _busy, activity: _activity, ...bot }) => ({
|
|
553
577
|
...bot,
|
|
554
578
|
tasks: bot.tasks?.map(({ busy: _taskBusy, activity: _taskActivity, ...task }) => task),
|
|
555
579
|
})), null, 2));
|
|
556
580
|
}
|
|
557
581
|
saveGroups() {
|
|
582
|
+
this.rememberSections(this.groups.map((group) => group.section));
|
|
558
583
|
writeFileAtomic(GROUPS_FILE, JSON.stringify(this.groups.map(({ busyBotId: _busyBotId, ...g }) => g), null, 2));
|
|
559
584
|
}
|
|
585
|
+
get sections() { return readSections(); }
|
|
586
|
+
rememberSections(names) {
|
|
587
|
+
try {
|
|
588
|
+
if (ensureSections(names))
|
|
589
|
+
this.emit({ type: "sections" });
|
|
590
|
+
}
|
|
591
|
+
catch (error) {
|
|
592
|
+
if (!this.registeringInitialSections)
|
|
593
|
+
throw error;
|
|
594
|
+
console.warn(`[teams] Startup could not register team names; saved teams and shared instructions were left unchanged: ${error.message}`);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
/** Empty-only changes cannot merge teams or silently change anybody's access. */
|
|
598
|
+
changeEmptySection(name, nextName) {
|
|
599
|
+
if (!this.sections.includes(name))
|
|
600
|
+
return "No such team";
|
|
601
|
+
if ([...this.bots, ...this.groups].some((record) => sectionKey(record.section) === name)) {
|
|
602
|
+
return "Move all bots (including archived bots) and group chats out of this team first";
|
|
603
|
+
}
|
|
604
|
+
if (nextName !== null && nextName !== name && this.sections.includes(nextName)) {
|
|
605
|
+
return "A team with that name already exists";
|
|
606
|
+
}
|
|
607
|
+
if (nextName === name)
|
|
608
|
+
return undefined;
|
|
609
|
+
const revoked = this.bots.filter((bot) => bot.managedSections?.some((section) => sectionKey(section) === name));
|
|
610
|
+
if (revoked.length) {
|
|
611
|
+
const grants = new Map(revoked.map((bot) => [bot.id, bot.managedSections.filter((section) => sectionKey(section) !== name)]));
|
|
612
|
+
// Revoke durably before freeing the name. If the registry write then
|
|
613
|
+
// fails, authority stays narrowed; recreating a name can never revive
|
|
614
|
+
// its old grants. Update existing objects so in-flight checks see it.
|
|
615
|
+
this.saveBots(this.bots.map((bot) => grants.has(bot.id) ? { ...bot, managedSections: grants.get(bot.id) } : bot));
|
|
616
|
+
for (const bot of revoked)
|
|
617
|
+
bot.managedSections = grants.get(bot.id);
|
|
618
|
+
for (const bot of revoked)
|
|
619
|
+
this.emit({ type: "bot", botId: bot.id });
|
|
620
|
+
}
|
|
621
|
+
changeEmptySection(name, nextName);
|
|
622
|
+
this.emit({ type: "sections" });
|
|
623
|
+
return undefined;
|
|
624
|
+
}
|
|
560
625
|
// ── groups ────────────────────────────────────────────────────────────
|
|
561
626
|
/** Subscribe to every write. Listeners run after the write and after
|
|
562
627
|
* save; a throwing listener never breaks the write. */
|
|
@@ -581,6 +646,7 @@ export class Store {
|
|
|
581
646
|
return this.groups.find((group) => group.threadId === threadId || group.tasks?.some((task) => task.threadId === threadId));
|
|
582
647
|
}
|
|
583
648
|
createGroup(name, memberIds, dm = false, section, setup) {
|
|
649
|
+
this.rememberSections([section]);
|
|
584
650
|
const threadId = newId();
|
|
585
651
|
const createdAt = Date.now();
|
|
586
652
|
const group = {
|
|
@@ -616,6 +682,9 @@ export class Store {
|
|
|
616
682
|
const group = this.group(id);
|
|
617
683
|
if (!group)
|
|
618
684
|
return null;
|
|
685
|
+
if (Object.prototype.hasOwnProperty.call(patch, "section")) {
|
|
686
|
+
this.rememberSections([patch.section]);
|
|
687
|
+
}
|
|
619
688
|
Object.assign(group, patch);
|
|
620
689
|
if (!group.dm && Object.prototype.hasOwnProperty.call(patch, "pinnedMessageId")) {
|
|
621
690
|
const active = this.activeGroupTask(group.id);
|
|
@@ -782,12 +851,20 @@ export class Store {
|
|
|
782
851
|
return this.patchMessage(threadId, messageId, { reactions: next.length ? next : undefined });
|
|
783
852
|
}
|
|
784
853
|
thread(threadId) {
|
|
785
|
-
|
|
854
|
+
const t = this.threads.get(threadId);
|
|
786
855
|
if (t)
|
|
787
856
|
return t;
|
|
788
857
|
// SQLite is the source of truth; a thread with no rows imports its
|
|
789
858
|
// legacy messages-<threadId>.json once, inside readThread
|
|
790
|
-
|
|
859
|
+
return this.cacheThread(threadId, mdb.readThread(threadId, messagesFile(threadId)));
|
|
860
|
+
}
|
|
861
|
+
/** Finish hydrating a full set of thread rows into the cache: chain any
|
|
862
|
+
* legacy (pre-branching) rows' parentId in array order, default the
|
|
863
|
+
* active leaf to the newest message, and store it. Shared by a full load
|
|
864
|
+
* and by messagesTail() when its bounded read turns out to be the whole
|
|
865
|
+
* thread anyway. */
|
|
866
|
+
cacheThread(threadId, rows) {
|
|
867
|
+
const { messages, activeLeafId: storedLeaf } = rows;
|
|
791
868
|
let activeLeafId = storedLeaf;
|
|
792
869
|
// legacy rows carry no parentId — chain them in array order
|
|
793
870
|
let prev = null;
|
|
@@ -798,13 +875,44 @@ export class Store {
|
|
|
798
875
|
}
|
|
799
876
|
if (!activeLeafId)
|
|
800
877
|
activeLeafId = messages.at(-1)?.id ?? null;
|
|
801
|
-
t = { messages, activeLeafId };
|
|
878
|
+
const t = { messages, activeLeafId };
|
|
802
879
|
this.threads.set(threadId, t);
|
|
803
880
|
return t;
|
|
804
881
|
}
|
|
805
882
|
messagesFor(threadId) {
|
|
806
883
|
return this.thread(threadId).messages;
|
|
807
884
|
}
|
|
885
|
+
/** A bounded page of a thread's newest messages, for callers that only
|
|
886
|
+
* need a display page — the startup/reconnect hydrate and a fresh
|
|
887
|
+
* scrollback view. Reads just `limit` rows at the SQL boundary instead of
|
|
888
|
+
* the whole transcript, unless the thread is already cached from other
|
|
889
|
+
* work (then it's a plain in-memory slice, no extra SQL) or the bounded
|
|
890
|
+
* read comes back as the complete thread anyway (short thread, or a
|
|
891
|
+
* one-time legacy import) — that gets cached like any other full load so
|
|
892
|
+
* a later messagesFor() doesn't re-read it. Legacy rows that predate
|
|
893
|
+
* per-message parentId are only chained correctly on a full load, so a
|
|
894
|
+
* bounded page missing that context falls back to one rather than
|
|
895
|
+
* returning messages with a broken parent chain. */
|
|
896
|
+
messagesTail(threadId, limit) {
|
|
897
|
+
let state = this.threads.get(threadId);
|
|
898
|
+
if (!state) {
|
|
899
|
+
const tail = mdb.readThreadTail(threadId, messagesFile(threadId), limit);
|
|
900
|
+
const legacyRows = tail.hasMore !== undefined && tail.messages.some((m) => m.parentId === undefined);
|
|
901
|
+
if (tail.hasMore !== true || legacyRows) {
|
|
902
|
+
state = this.cacheThread(threadId, legacyRows ? mdb.readThread(threadId, messagesFile(threadId)) : tail);
|
|
903
|
+
}
|
|
904
|
+
else {
|
|
905
|
+
return {
|
|
906
|
+
messages: tail.messages,
|
|
907
|
+
hasMore: tail.hasMore,
|
|
908
|
+
activeLeafId: tail.activeLeafId ?? tail.messages.at(-1)?.id ?? null,
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
const { messages, activeLeafId } = state;
|
|
913
|
+
const start = Math.max(0, messages.length - limit);
|
|
914
|
+
return { messages: messages.slice(start), hasMore: start > 0, activeLeafId };
|
|
915
|
+
}
|
|
808
916
|
/** Used only with newly allocated import threads. No live actions are
|
|
809
917
|
* replayed: the importer supplies inert text and freshly remapped IDs. */
|
|
810
918
|
importTranscript(threadId, messages, activeLeafId) {
|
|
@@ -982,6 +1090,7 @@ export class Store {
|
|
|
982
1090
|
return this.bots.find((b) => b.threadId === threadId || b.tasks?.some((t) => t.threadId === threadId)) ?? null;
|
|
983
1091
|
}
|
|
984
1092
|
createBot(profile = {}, opts = {}) {
|
|
1093
|
+
this.rememberSections([profile.section]);
|
|
985
1094
|
const name = profile.name?.trim() || pickBotName(this.bots.map((b) => b.name));
|
|
986
1095
|
const section = sectionKey(profile.section);
|
|
987
1096
|
const bot = {
|
|
@@ -1037,11 +1146,103 @@ export class Store {
|
|
|
1037
1146
|
}
|
|
1038
1147
|
return bot;
|
|
1039
1148
|
}
|
|
1040
|
-
|
|
1149
|
+
/** All setup fields and the Chief's receipt commit before publishing any
|
|
1150
|
+
* mutation. Model defaults never rewrite saved thread selections. */
|
|
1151
|
+
applyTeamSetup(request) {
|
|
1152
|
+
const chief = this.bot(request.botId);
|
|
1153
|
+
if (!chief)
|
|
1154
|
+
throw new Error("The requesting Chief no longer exists");
|
|
1155
|
+
if (chief.lastTeamSetupReceipt?.requestId === request.requestId)
|
|
1156
|
+
return chief.lastTeamSetupReceipt.result;
|
|
1157
|
+
const managedSections = [...new Set([...(chief.managedSections ?? []), ...request.newTeams])];
|
|
1158
|
+
if (managedSections.length > 100 || managedSections.some((name) => name.trim() !== name || name.length > 60) ||
|
|
1159
|
+
request.newTeams.some((name) => !name) || (request.newTeams.length && !chief.chiefOfStaff))
|
|
1160
|
+
throw new Error("Invalid reviewed Chief team scope");
|
|
1161
|
+
const nextBots = [...this.bots];
|
|
1162
|
+
const changed = [];
|
|
1163
|
+
for (const operation of request.operations) {
|
|
1164
|
+
const at = nextBots.findIndex((bot) => bot.id === operation.botId);
|
|
1165
|
+
let next;
|
|
1166
|
+
if (operation.action === "create") {
|
|
1167
|
+
if (at >= 0 || !operation.threadId || !operation.fields.name || !operation.fields.modelSelection)
|
|
1168
|
+
throw new Error("Invalid new bot in team setup");
|
|
1169
|
+
const createdAt = Date.now();
|
|
1170
|
+
next = { id: operation.botId, threadId: operation.threadId, name: operation.fields.name,
|
|
1171
|
+
title: "", description: "", soul: "", notifications: true, color: COLORS[nextBots.length % COLORS.length], unread: false,
|
|
1172
|
+
modelSelection: operation.fields.modelSelection, resumeCursors: {}, createdAt, ...operation.fields,
|
|
1173
|
+
approvalMode: "ask", autoApprove: false, composio: false, approvePeerComms: false,
|
|
1174
|
+
tasks: [{ threadId: operation.threadId, title: UNTITLED_THREAD, createdAt, resumeCursors: {},
|
|
1175
|
+
modelSelection: structuredClone(operation.fields.modelSelection), approvalMode: "ask", autoApprove: false,
|
|
1176
|
+
unread: false, activity: "idle", busy: false }],
|
|
1177
|
+
};
|
|
1178
|
+
nextBots.unshift(next);
|
|
1179
|
+
}
|
|
1180
|
+
else {
|
|
1181
|
+
if (at < 0)
|
|
1182
|
+
throw new Error("A setup target no longer exists");
|
|
1183
|
+
const previous = nextBots[at];
|
|
1184
|
+
next = { ...previous, ...operation.fields };
|
|
1185
|
+
if (operation.fields.modelSelection)
|
|
1186
|
+
next.tasks = previous.tasks?.map((task) => ({
|
|
1187
|
+
...task,
|
|
1188
|
+
modelSelection: structuredClone(task.modelSelection ?? previous.modelSelection),
|
|
1189
|
+
approvalMode: approvalModeFor(this.projectBotForTask(previous.id, task.threadId)),
|
|
1190
|
+
autoApprove: task.autoApprove ?? previous.autoApprove,
|
|
1191
|
+
alwaysAllow: structuredClone(task.alwaysAllow ?? previous.alwaysAllow ?? []),
|
|
1192
|
+
}));
|
|
1193
|
+
nextBots[at] = next;
|
|
1194
|
+
}
|
|
1195
|
+
next.section = sectionKey(next.section) || undefined;
|
|
1196
|
+
if (operation.fields.soul !== undefined) {
|
|
1197
|
+
next.soulHash = soulHash(operation.fields.soul);
|
|
1198
|
+
next.soulDrift = false;
|
|
1199
|
+
}
|
|
1200
|
+
changed.push(next);
|
|
1201
|
+
}
|
|
1202
|
+
const result = { state: "applied", newTeams: request.newTeams, bots: changed.map((bot, index) => ({
|
|
1203
|
+
id: bot.id, name: bot.name, section: bot.section, modelSelection: structuredClone(bot.modelSelection),
|
|
1204
|
+
action: request.operations[index].action === "create" ? "created" : "updated",
|
|
1205
|
+
})) };
|
|
1206
|
+
const chiefAt = nextBots.findIndex((bot) => bot.id === chief.id);
|
|
1207
|
+
const nextChief = { ...nextBots[chiefAt], lastTeamSetupReceipt: { requestId: request.requestId, result } };
|
|
1208
|
+
// Only the newly-created teams explicitly named in the human review may
|
|
1209
|
+
// extend this Chief's reach. Existing teams require owner settings.
|
|
1210
|
+
if (request.newTeams.length) {
|
|
1211
|
+
nextChief.managedSections = managedSections;
|
|
1212
|
+
}
|
|
1213
|
+
nextBots[chiefAt] = nextChief;
|
|
1214
|
+
this.saveBots(nextBots);
|
|
1215
|
+
this.bots = nextBots;
|
|
1216
|
+
for (const bot of changed) {
|
|
1217
|
+
try {
|
|
1218
|
+
writeSoulMirror(bot.id, bot.soul ?? "");
|
|
1219
|
+
}
|
|
1220
|
+
catch (error) {
|
|
1221
|
+
console.warn(`[bot-folder] could not refresh reviewed setup mirror for ${bot.id}: ${error.message}`);
|
|
1222
|
+
}
|
|
1223
|
+
this.emit({ type: "bot", botId: bot.id });
|
|
1224
|
+
}
|
|
1225
|
+
this.emit({ type: "bot", botId: chief.id });
|
|
1226
|
+
return result;
|
|
1227
|
+
}
|
|
1228
|
+
deleteBot(id, setupRequest) {
|
|
1041
1229
|
const bot = this.bot(id);
|
|
1042
1230
|
if (!bot)
|
|
1043
1231
|
return false;
|
|
1044
|
-
|
|
1232
|
+
let nextBots = this.bots.filter((b) => b.id !== id);
|
|
1233
|
+
if (setupRequest) {
|
|
1234
|
+
const chief = this.bot(setupRequest.botId);
|
|
1235
|
+
if (!chief || chief.id === id || setupRequest.deletion?.botId !== id)
|
|
1236
|
+
throw new Error("The reviewed deletion no longer has a valid owner");
|
|
1237
|
+
const lastTeamSetupReceipt = { requestId: setupRequest.requestId, result: { state: "applied", newTeams: [], bots: [
|
|
1238
|
+
{ id: bot.id, name: bot.name, action: "deleted" },
|
|
1239
|
+
] } };
|
|
1240
|
+
nextBots = nextBots.map((candidate) => candidate.id === chief.id ? { ...candidate, lastTeamSetupReceipt } : candidate);
|
|
1241
|
+
}
|
|
1242
|
+
// Persist removal and the review receipt before deleting conversation or
|
|
1243
|
+
// workspace data. A failed save must leave the bot recoverable in place.
|
|
1244
|
+
this.saveBots(nextBots);
|
|
1245
|
+
this.bots = nextBots;
|
|
1045
1246
|
this.legacyActivities.delete(id);
|
|
1046
1247
|
// every task's transcript goes with the bot, not just the open one
|
|
1047
1248
|
for (const threadId of new Set([bot.threadId, ...(bot.tasks ?? []).map((t) => t.threadId)])) {
|
|
@@ -1064,7 +1265,6 @@ export class Store {
|
|
|
1064
1265
|
catch { }
|
|
1065
1266
|
// The bot folder (SOUL.md mirror) is the bot's too.
|
|
1066
1267
|
removeBotFolder(id);
|
|
1067
|
-
this.saveBots();
|
|
1068
1268
|
this.emit({ type: "bot.deleted", botId: id });
|
|
1069
1269
|
return true;
|
|
1070
1270
|
}
|
|
@@ -1164,6 +1364,7 @@ export class Store {
|
|
|
1164
1364
|
for (const botId of changedIds)
|
|
1165
1365
|
this.emit({ type: "bot", botId });
|
|
1166
1366
|
}
|
|
1367
|
+
this.rememberSections([targetSection]);
|
|
1167
1368
|
return { ok: true, bots: ids.map((id) => this.bot(id)) };
|
|
1168
1369
|
}
|
|
1169
1370
|
/** Legacy bot/room activity occupies its own slot; direct conversations
|
|
@@ -1221,6 +1422,7 @@ export class Store {
|
|
|
1221
1422
|
}
|
|
1222
1423
|
else {
|
|
1223
1424
|
bot.chiefOfStaff = false;
|
|
1425
|
+
delete bot.managedSections;
|
|
1224
1426
|
}
|
|
1225
1427
|
changed.push(bot);
|
|
1226
1428
|
}
|
|
@@ -1427,6 +1629,7 @@ export class Store {
|
|
|
1427
1629
|
return {
|
|
1428
1630
|
...bot,
|
|
1429
1631
|
threadId: task.threadId,
|
|
1632
|
+
approvalGrant: bot.approvalGrant?.threadOnly && bot.approvalGrant.threadId !== threadId ? undefined : bot.approvalGrant,
|
|
1430
1633
|
modelSelection: structuredClone(task.modelSelection ?? bot.modelSelection),
|
|
1431
1634
|
resumeCursors: structuredClone(task.resumeCursors),
|
|
1432
1635
|
approvalMode: task.approvalMode ?? (task.autoApprove === undefined ? bot.approvalMode : undefined),
|
|
@@ -1460,6 +1663,37 @@ export class Store {
|
|
|
1460
1663
|
this.emit({ type: "bot", botId });
|
|
1461
1664
|
return task;
|
|
1462
1665
|
}
|
|
1666
|
+
/** Model/provider changes are one configuration transaction: never publish
|
|
1667
|
+
* a new provider before its confirmed approval downgrade, or change the
|
|
1668
|
+
* default while leaving the selected thread behind after a write failure. */
|
|
1669
|
+
switchTaskModel(botId, threadId, selection, updateBotDefault, resetApprovalToAsk, taskPatch = {}) {
|
|
1670
|
+
const bot = this.bot(botId);
|
|
1671
|
+
const task = this.taskByThread(botId, threadId);
|
|
1672
|
+
if (!bot || !task)
|
|
1673
|
+
return null;
|
|
1674
|
+
const patch = { modelSelection: structuredClone(selection),
|
|
1675
|
+
...(resetApprovalToAsk ? { approvalMode: "ask", autoApprove: false, alwaysAllow: [] } : {}) };
|
|
1676
|
+
const nextTask = { ...task, ...taskPatch, ...patch,
|
|
1677
|
+
...(typeof taskPatch.title === "string" ? { title: taskPatch.title.trim().slice(0, 80) || UNTITLED_THREAD } : {}) };
|
|
1678
|
+
// Older threads may still inherit settings. Freeze their effective
|
|
1679
|
+
// values before updating the default so "other threads unchanged" also
|
|
1680
|
+
// holds for workspaces created before per-thread approval settings.
|
|
1681
|
+
const nextTasks = bot.tasks.map((candidate) => candidate === task ? nextTask : !updateBotDefault ? candidate : {
|
|
1682
|
+
...candidate,
|
|
1683
|
+
modelSelection: structuredClone(candidate.modelSelection ?? bot.modelSelection),
|
|
1684
|
+
approvalMode: approvalModeFor(this.projectBotForTask(botId, candidate.threadId)),
|
|
1685
|
+
autoApprove: candidate.autoApprove ?? bot.autoApprove,
|
|
1686
|
+
alwaysAllow: structuredClone(candidate.alwaysAllow ?? bot.alwaysAllow ?? []),
|
|
1687
|
+
});
|
|
1688
|
+
const next = { ...bot, ...(updateBotDefault ? patch : {}),
|
|
1689
|
+
tasks: nextTasks };
|
|
1690
|
+
this.saveBots(this.bots.map((candidate) => candidate === bot ? next : candidate));
|
|
1691
|
+
bot.tasks.forEach((candidate, index) => Object.assign(candidate, nextTasks[index]));
|
|
1692
|
+
if (updateBotDefault)
|
|
1693
|
+
Object.assign(bot, patch);
|
|
1694
|
+
this.emit({ type: "bot", botId });
|
|
1695
|
+
return task;
|
|
1696
|
+
}
|
|
1463
1697
|
mirrorActiveTask(bot, task) {
|
|
1464
1698
|
bot.threadId = task.threadId;
|
|
1465
1699
|
bot.resumeCursors = structuredClone(task.resumeCursors);
|
|
@@ -1511,6 +1745,24 @@ export class Store {
|
|
|
1511
1745
|
this.emit({ type: "bot", botId });
|
|
1512
1746
|
return task;
|
|
1513
1747
|
}
|
|
1748
|
+
/** Stamp or clear the closer record. `null` reopens: the next turn in a
|
|
1749
|
+
* closed thread calls this so the row comes back to the sidebar. Never
|
|
1750
|
+
* reachable from the HTTP task PATCH: closedBy is not a TASK_PATCH_FIELD. */
|
|
1751
|
+
setTaskClosedBy(botId, threadId, closedBy) {
|
|
1752
|
+
const bot = this.bot(botId);
|
|
1753
|
+
const task = this.taskByThread(botId, threadId);
|
|
1754
|
+
if (!bot || !task)
|
|
1755
|
+
return null;
|
|
1756
|
+
if (closedBy)
|
|
1757
|
+
task.closedBy = structuredClone(closedBy);
|
|
1758
|
+
else if (!task.closedBy)
|
|
1759
|
+
return task;
|
|
1760
|
+
else
|
|
1761
|
+
delete task.closedBy;
|
|
1762
|
+
this.saveBots();
|
|
1763
|
+
this.emit({ type: "bot", botId });
|
|
1764
|
+
return task;
|
|
1765
|
+
}
|
|
1514
1766
|
switchTask(botId, threadId) {
|
|
1515
1767
|
const bot = this.bot(botId);
|
|
1516
1768
|
const task = bot?.tasks?.find((t) => t.threadId === threadId);
|
|
@@ -1534,10 +1786,10 @@ export class Store {
|
|
|
1534
1786
|
this.emit({ type: "bot", botId });
|
|
1535
1787
|
}
|
|
1536
1788
|
/** Delete a task and its transcript, retaining generated project files.
|
|
1537
|
-
*
|
|
1789
|
+
* When no visible tasks remain, replace it with a fresh conversation. */
|
|
1538
1790
|
deleteTask(botId, threadId) {
|
|
1539
1791
|
const bot = this.bot(botId);
|
|
1540
|
-
if (!bot
|
|
1792
|
+
if (!bot?.tasks)
|
|
1541
1793
|
return null;
|
|
1542
1794
|
if (!bot.tasks.some((t) => t.threadId === threadId))
|
|
1543
1795
|
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:
|
|
24
|
-
* destination keeps whatever browser the bot
|
|
25
|
-
* model which to use), a browser destination
|
|
26
|
-
* follows the task's pin while the pinned
|
|
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([
|
|
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
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, lstatSync, readFileSync } from "node:fs";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { writeFileAtomic } from "./atomic.js";
|
|
5
|
+
export const teamComputerCreate = z.object({
|
|
6
|
+
name: z.string().trim().min(1).max(60).refine(value => [...value].every(character => character.charCodeAt(0) >= 32 && character.charCodeAt(0) !== 127)),
|
|
7
|
+
acknowledgeCost: z.literal(true),
|
|
8
|
+
// Clients may keep this id across a lost create response. It is not a Box id.
|
|
9
|
+
requestId: z.string().uuid(),
|
|
10
|
+
}).strict();
|
|
11
|
+
export const teamComputerAssignment = z.object({
|
|
12
|
+
section: z.string().trim().max(60).nullable(),
|
|
13
|
+
acknowledgeSharedAccess: z.literal(true),
|
|
14
|
+
}).strict();
|
|
15
|
+
const entrySchema = z.object({
|
|
16
|
+
id: z.string().uuid(), name: teamComputerCreate.shape.name,
|
|
17
|
+
section: z.string().trim().max(60).nullable(), createdAt: z.number().finite().nonnegative(),
|
|
18
|
+
problem: z.string().max(500).optional(),
|
|
19
|
+
}).strict();
|
|
20
|
+
const fileSchema = z.object({ version: z.literal(1), environmentId: z.string().uuid(), computers: z.array(entrySchema).max(100) }).strict();
|
|
21
|
+
export const teamComputerOwner = (id) => `computer_${id}`;
|
|
22
|
+
const failure = (message, status = 409) => Object.assign(new Error(message), { status });
|
|
23
|
+
/** One server writer owns the data directory. Persist identity before any
|
|
24
|
+
* provider call; failed/retried creates always retain the same Box journal key.
|
|
25
|
+
* Invalid or foreign restored state is never silently reset to an empty pool. */
|
|
26
|
+
export class TeamComputers {
|
|
27
|
+
entries = [];
|
|
28
|
+
problem;
|
|
29
|
+
file;
|
|
30
|
+
environmentId;
|
|
31
|
+
constructor(file, environmentId) {
|
|
32
|
+
this.file = file;
|
|
33
|
+
this.environmentId = environmentId;
|
|
34
|
+
try {
|
|
35
|
+
if (!existsSync(file))
|
|
36
|
+
return;
|
|
37
|
+
const stat = lstatSync(file);
|
|
38
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 100_000)
|
|
39
|
+
throw new Error("unsafe registry file");
|
|
40
|
+
const saved = fileSchema.parse(JSON.parse(readFileSync(file, "utf8")));
|
|
41
|
+
if (saved.environmentId !== environmentId)
|
|
42
|
+
throw new Error("registry belongs to another workspace; its computers were not transferred");
|
|
43
|
+
if (new Set(saved.computers.map(entry => entry.id)).size !== saved.computers.length)
|
|
44
|
+
throw new Error("duplicate computer identity");
|
|
45
|
+
const sections = saved.computers.flatMap(entry => entry.section === null ? [] : [entry.section]);
|
|
46
|
+
if (new Set(sections).size !== sections.length)
|
|
47
|
+
throw new Error("a team has more than one computer");
|
|
48
|
+
this.entries = saved.computers;
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
this.problem = `Team computer registry could not be loaded: ${error instanceof Error ? error.message : String(error)}`;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
list() {
|
|
55
|
+
if (this.problem)
|
|
56
|
+
throw failure(this.problem, 503);
|
|
57
|
+
return this.entries.map(entry => ({ ...entry }));
|
|
58
|
+
}
|
|
59
|
+
get(id) { return this.list().find(entry => entry.id === id); }
|
|
60
|
+
forSection(section) {
|
|
61
|
+
return this.list().find(entry => entry.section !== null && entry.section === (section?.trim() || ""));
|
|
62
|
+
}
|
|
63
|
+
forBot(bot) {
|
|
64
|
+
// Explicit destinations are permissions, not suggestions. Only Auto inherits.
|
|
65
|
+
return bot.computer === undefined && bot.cloudBackend !== "vps" ? this.forSection(bot.section) : undefined;
|
|
66
|
+
}
|
|
67
|
+
create(name, id = randomUUID()) {
|
|
68
|
+
const entries = this.list();
|
|
69
|
+
const existing = entries.find(entry => entry.id === id);
|
|
70
|
+
if (existing) {
|
|
71
|
+
if (existing.name !== name)
|
|
72
|
+
throw failure("This creation id already names a different computer");
|
|
73
|
+
return existing;
|
|
74
|
+
}
|
|
75
|
+
if (entries.length >= 100)
|
|
76
|
+
throw failure("This workspace has reached its team computer limit");
|
|
77
|
+
const entry = entrySchema.parse({ id, name, section: null, createdAt: Date.now() });
|
|
78
|
+
this.save([...entries, entry]);
|
|
79
|
+
return { ...entry };
|
|
80
|
+
}
|
|
81
|
+
assign(id, section) {
|
|
82
|
+
section = entrySchema.shape.section.parse(section);
|
|
83
|
+
const entry = this.get(id);
|
|
84
|
+
if (!entry)
|
|
85
|
+
throw failure("No such team computer", 404);
|
|
86
|
+
if (entry.section !== null && section !== null && section !== entry.section)
|
|
87
|
+
throw failure("Unassign this computer before moving it to another team");
|
|
88
|
+
if (section !== null && this.list().some(other => other.id !== id && other.section === section))
|
|
89
|
+
throw failure("This team already has a computer; unassign it first");
|
|
90
|
+
return this.patch(id, { section });
|
|
91
|
+
}
|
|
92
|
+
setProblem(id, problem) {
|
|
93
|
+
return this.patch(id, { problem: problem?.slice(0, 500) });
|
|
94
|
+
}
|
|
95
|
+
patch(id, patch) {
|
|
96
|
+
const entries = this.list();
|
|
97
|
+
const entry = entries.find(candidate => candidate.id === id);
|
|
98
|
+
if (!entry)
|
|
99
|
+
throw failure("No such team computer", 404);
|
|
100
|
+
const next = entrySchema.parse({ ...entry, ...patch });
|
|
101
|
+
this.save(entries.map(candidate => candidate.id === id ? next : candidate));
|
|
102
|
+
return { ...next };
|
|
103
|
+
}
|
|
104
|
+
save(entries) {
|
|
105
|
+
writeFileAtomic(this.file, JSON.stringify({ version: 1, environmentId: this.environmentId, computers: entries }), { mode: 0o600 });
|
|
106
|
+
this.entries = entries;
|
|
107
|
+
}
|
|
108
|
+
}
|