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
@@ -0,0 +1,252 @@
1
+ import { createHash } from "node:crypto";
2
+ import { z } from "zod";
3
+ import { newId } from "./contracts.js";
4
+ import { fitsOnOneLine, parseBotProfilePatch } from "./bot-profile.js";
5
+ import { profileSnapshot } from "./profile-revision.js";
6
+ import { redactSecretsInText } from "./redact.js";
7
+ const section = (value) => value?.trim() || "";
8
+ const teamName = z.string().trim().min(1).max(60).refine(fitsOnOneLine).refine((value) => redactSecretsInText(value) === value, "Team names cannot contain credentials");
9
+ const fieldsSchema = z.object({
10
+ name: z.string().optional(), title: z.string().optional(), description: z.string().optional(), soul: z.string().optional(),
11
+ section: z.string().trim().max(60).refine(fitsOnOneLine).refine((value) => redactSecretsInText(value) === value, "Team names cannot contain credentials").optional(),
12
+ modelSelection: z.object({ instanceId: z.string().trim().min(1), model: z.string().trim().min(1), effort: z.string().optional() }).strict().optional(),
13
+ }).strict();
14
+ const planSchema = z.object({
15
+ reason: z.string().trim().min(1).max(500),
16
+ newTeams: z.array(teamName).max(8).default([]),
17
+ operations: z.array(z.discriminatedUnion("action", [
18
+ z.object({ action: z.literal("create"), key: z.string().trim().min(1).max(80), fields: fieldsSchema }).strict(),
19
+ z.object({ action: z.literal("update"), botId: z.string().min(1).max(128), fields: fieldsSchema }).strict(),
20
+ ])).min(1).max(24),
21
+ }).strict();
22
+ export class TeamSetupError extends Error {
23
+ status;
24
+ constructor(message, status = 400) { super(message); this.status = status; }
25
+ }
26
+ /** Settings relevant to approval, including the previous durable receipt. */
27
+ export function teamSetupRevision(bot) {
28
+ return createHash("sha256").update(JSON.stringify({
29
+ ...profileSnapshot(bot), section: section(bot.section), modelSelection: bot.modelSelection,
30
+ hidden: Boolean(bot.hidden), chiefOfStaff: Boolean(bot.chiefOfStaff), peers: bot.peers,
31
+ managedSections: bot.managedSections,
32
+ approvalMode: bot.approvalMode, autoApprove: bot.autoApprove, approvalGrant: bot.approvalGrant,
33
+ receipt: bot.lastTeamSetupReceipt?.requestId,
34
+ })).digest("hex");
35
+ }
36
+ export class TeamSetupRequestService {
37
+ resolving = new Set();
38
+ options;
39
+ constructor(options) { this.options = options; }
40
+ chief(botId) {
41
+ const chief = this.options.store.bot(botId);
42
+ if (!chief || chief.hidden || !chief.chiefOfStaff)
43
+ throw new TeamSetupError("Only an active Chief of Staff can propose team setup", 403);
44
+ return chief;
45
+ }
46
+ fields(input, current) {
47
+ const { section: targetSection, modelSelection, ...profile } = input;
48
+ const safe = Object.fromEntries(Object.entries(profile).map(([key, value]) => [key, redactSecretsInText(value)]));
49
+ const parsed = parseBotProfilePatch(safe, true);
50
+ if (!parsed.ok)
51
+ throw new TeamSetupError(parsed.error);
52
+ const result = { ...parsed.patch };
53
+ if (targetSection !== undefined)
54
+ result.section = targetSection;
55
+ if (modelSelection) {
56
+ const selection = modelSelection;
57
+ const error = this.options.validateModel(selection, current);
58
+ if (error)
59
+ throw new TeamSetupError(error);
60
+ result.modelSelection = selection;
61
+ }
62
+ return result;
63
+ }
64
+ validate(request, confirming) {
65
+ const chief = this.chief(request.botId);
66
+ if (!this.options.ownsThread(request.botId, request.threadId))
67
+ throw new TeamSetupError("The requesting conversation no longer exists", 409);
68
+ if (confirming && !this.options.store.messagesFor(request.threadId).some((message) => message.card?.requestId === request.requestId && !message.card.answered && !message.card.dismissed))
69
+ throw new TeamSetupError("This setup card is no longer pending", 409);
70
+ if (confirming && teamSetupRevision(chief) !== request.requesterRevision)
71
+ throw new TeamSetupError("The Chief's settings changed. This setup was cancelled; review a new proposal.", 409);
72
+ const existingTeams = new Set(this.options.teams().map(section));
73
+ if (new Set([...(chief.managedSections ?? []), ...request.newTeams]).size > 100)
74
+ throw new TeamSetupError("A Chief may coordinate at most 100 additional teams", 409);
75
+ for (const name of request.newTeams)
76
+ if (existingTeams.has(name))
77
+ throw new TeamSetupError(`Team ${JSON.stringify(name)} now exists. Review a new proposal.`, 409);
78
+ const allowed = (name) => request.newTeams.includes(section(name)) || this.options.canAccessTeam(chief, name);
79
+ const targets = new Set();
80
+ const projected = this.options.store.bots.map((bot) => ({ id: bot.id, name: bot.name, section: section(bot.section), chiefOfStaff: bot.chiefOfStaff, hidden: bot.hidden }));
81
+ for (const operation of request.operations) {
82
+ if (targets.has(operation.botId))
83
+ throw new TeamSetupError("A bot appears twice in the prepared setup");
84
+ targets.add(operation.botId);
85
+ const target = this.options.store.bot(operation.botId);
86
+ if (operation.action === "create") {
87
+ if (target)
88
+ throw new TeamSetupError("A proposed bot already exists. Review a new proposal.", 409);
89
+ if (!operation.fields.name?.trim() || !operation.fields.title?.trim() || !operation.fields.soul?.trim() || !operation.fields.modelSelection)
90
+ throw new TeamSetupError("Each new bot needs a name, title, soul instructions, and exact model selection");
91
+ }
92
+ else {
93
+ if (!target || target.hidden || !this.options.canAccessTeam(chief, target.section) || (target.id !== chief.id && Array.isArray(chief.peers) && !chief.peers.includes(target.id)))
94
+ throw new TeamSetupError("A target bot is outside this Chief's authorized team and peer scope", 403);
95
+ if (teamSetupRevision(target) !== operation.expectedRevision)
96
+ throw new TeamSetupError(`@${target.name} changed. This setup was cancelled; review a new proposal.`, 409);
97
+ if (this.options.targetBusy(target.id) && (confirming || target.id !== chief.id))
98
+ throw new TeamSetupError(`Stop @${target.name}'s work before changing its setup`, 409);
99
+ }
100
+ const fields = this.fields(operation.fields, target ?? undefined);
101
+ const destination = fields.section ?? target?.section ?? chief.section;
102
+ if (!allowed(destination))
103
+ throw new TeamSetupError("The destination team is outside this Chief's authorized scope", 403);
104
+ if (!request.newTeams.includes(section(destination)) && !existingTeams.has(section(destination)))
105
+ throw new TeamSetupError("The destination team no longer exists", 409);
106
+ const next = { id: operation.botId, name: fields.name ?? target.name, section: section(destination), chiefOfStaff: target?.chiefOfStaff, hidden: false };
107
+ const at = projected.findIndex((bot) => bot.id === operation.botId);
108
+ if (at < 0)
109
+ projected.push(next);
110
+ else
111
+ projected[at] = next;
112
+ }
113
+ if (this.options.store.bots.length + request.operations.filter((op) => op.action === "create").length > this.options.maxBots)
114
+ throw new TeamSetupError("This setup exceeds the workspace bot limit", 409);
115
+ for (const name of request.newTeams)
116
+ if (!request.operations.some((op) => section(op.fields.section) === name))
117
+ throw new TeamSetupError(`New team ${JSON.stringify(name)} needs a specialist in this setup`);
118
+ for (const operation of request.operations) {
119
+ const candidate = projected.find((bot) => bot.id === operation.botId);
120
+ if (projected.some((bot) => bot.id !== candidate.id && !bot.hidden && bot.section === candidate.section && bot.name.trim().toLowerCase() === candidate.name.trim().toLowerCase()))
121
+ throw new TeamSetupError(`@${candidate.name} already exists in that team`, 409);
122
+ if (candidate.chiefOfStaff && projected.some((bot) => bot.id !== candidate.id && bot.chiefOfStaff && bot.section === candidate.section))
123
+ throw new TeamSetupError("That move would put two Chiefs in one team", 409);
124
+ }
125
+ if (request.deletion) {
126
+ const target = this.options.store.bot(request.deletion.botId);
127
+ if (!target || target.hidden || target.id === chief.id || !this.options.canAccessTeam(chief, target.section) || (Array.isArray(chief.peers) && !chief.peers.includes(target.id)))
128
+ throw new TeamSetupError("This deletion target is no longer an authorized teammate", 403);
129
+ if (teamSetupRevision(target) !== request.deletion.expectedRevision || this.options.targetBusy(target.id))
130
+ throw new TeamSetupError("The deletion target changed or is working. Review a new deletion proposal.", 409);
131
+ }
132
+ }
133
+ propose(args) {
134
+ const parsed = planSchema.safeParse(args.plan);
135
+ if (!parsed.success)
136
+ throw new TeamSetupError(parsed.error.issues[0]?.message ?? "Invalid team setup");
137
+ const chief = this.chief(args.botId);
138
+ const combined = new Map();
139
+ for (const operation of parsed.data.operations) {
140
+ const key = operation.action === "create" ? `create:${operation.key}` : `update:${operation.botId}`;
141
+ const previous = combined.get(key);
142
+ combined.set(key, { ...operation, fields: { ...previous?.fields, ...operation.fields } });
143
+ }
144
+ if (combined.size > 8)
145
+ throw new TeamSetupError("Review at most eight bots in one setup");
146
+ const operations = [...combined.values()].map((operation) => {
147
+ const target = operation.action === "update" ? this.options.store.bot(operation.botId) : undefined;
148
+ return { action: operation.action, botId: operation.action === "create" ? newId() : operation.botId,
149
+ ...(operation.action === "create" ? { threadId: newId() } : {}),
150
+ fields: this.fields({ ...operation.fields, section: operation.fields.section ?? target?.section ?? chief.section ?? "" }, target ?? undefined),
151
+ ...(target ? { expectedRevision: teamSetupRevision(target) } : {}),
152
+ };
153
+ });
154
+ const request = { version: 1, requestId: newId(), botId: args.botId, threadId: args.threadId,
155
+ reason: redactSecretsInText(parsed.data.reason), createdAt: Date.now(), requesterRevision: teamSetupRevision(chief),
156
+ newTeams: [...new Set(parsed.data.newTeams)], operations };
157
+ this.validate(request, false);
158
+ return this.card(request, args.from);
159
+ }
160
+ proposeDeletion(args) {
161
+ const chief = this.chief(args.botId);
162
+ const target = this.options.store.bot(args.targetBotId);
163
+ if (!target)
164
+ throw new TeamSetupError("That bot no longer exists", 404);
165
+ if (!args.reason.trim() || args.reason.length > 500)
166
+ throw new TeamSetupError("A deletion reason of at most 500 characters is required");
167
+ const request = { version: 1, requestId: newId(), botId: args.botId, threadId: args.threadId,
168
+ reason: redactSecretsInText(args.reason), createdAt: Date.now(), requesterRevision: teamSetupRevision(chief), newTeams: [], operations: [],
169
+ deletion: { botId: target.id, name: target.name, expectedRevision: teamSetupRevision(target) } };
170
+ this.validate(request, false);
171
+ return this.card(request, args.from);
172
+ }
173
+ card(request, from) {
174
+ const permission = this.options.canPersist(request.botId, request.threadId);
175
+ if (!permission.ok)
176
+ throw new TeamSetupError(permission.error, permission.status);
177
+ const chief = this.chief(request.botId);
178
+ const lines = [`Why: ${request.reason}`];
179
+ if (request.deletion)
180
+ lines.push(`Delete @${request.deletion.name} (${request.deletion.botId}).`, "Permanently removes this bot, all its conversations, memory, instructions, and skills. Generated project files remain. Active work or owned computers can block deletion.");
181
+ if (request.newTeams.length)
182
+ lines.push(`Create teams: ${request.newTeams.map((name) => JSON.stringify(name)).join(", ")}.`, `Authorize @${chief.name} to coordinate and propose setup changes in these new teams.`);
183
+ for (const operation of request.operations) {
184
+ const current = this.options.store.bot(operation.botId);
185
+ lines.push(`\n${operation.action === "create" ? "Create" : "Update"} @${operation.fields.name ?? current?.name} (${operation.action === "create" ? "new bot" : operation.botId})`);
186
+ for (const [key, value] of Object.entries(operation.fields)) {
187
+ const before = current ? (key === "section" ? current.section || "General" : current[key]) : undefined;
188
+ lines.push(`${key === "modelSelection" ? "Default engine/model" : key}: ${current ? `${JSON.stringify(before ?? "")} → ` : ""}${key === "section" ? JSON.stringify(value || "General") : JSON.stringify(value)}`);
189
+ }
190
+ }
191
+ if (!request.deletion)
192
+ lines.push("\nDefault models apply to groups and new threads. Every existing thread keeps its current model and permissions.", "New bots start in Ask with connected apps disabled. Existing execution permissions are unchanged.");
193
+ lines.push("After this decision the Chief continues once with the result.");
194
+ const title = request.deletion ? `Delete @${request.deletion.name}?` : `Apply setup for ${request.operations.length} ${request.operations.length === 1 ? "bot" : "bots"}?`;
195
+ const detail = lines.join("\n");
196
+ const message = this.options.store.appendMessage(request.threadId, { role: "bot", kind: "options", ...(from ? { from } : {}), card: {
197
+ title, subtitle: detail, options: [request.deletion ? "Delete bot" : "Apply setup", "Cancel"], requestId: request.requestId,
198
+ tool: request.deletion ? "delete_bot" : "set_up_team", teamSetupRequest: request,
199
+ } });
200
+ return { requestId: request.requestId, messageId: message.id, title, summary: title, detail };
201
+ }
202
+ async resolve(args) {
203
+ const message = this.options.store.messagesFor(args.threadId).find((item) => item.card?.requestId === args.requestId && item.card.teamSetupRequest);
204
+ const card = message?.card, request = card?.teamSetupRequest;
205
+ if (!message || !card || !request)
206
+ return null;
207
+ if (request.botId !== args.botId || request.threadId !== args.threadId || request.requestId !== args.requestId)
208
+ throw new TeamSetupError("This setup belongs to another conversation", 403);
209
+ if (args.behavior !== "allow" && args.behavior !== "deny")
210
+ throw new TeamSetupError("Confirm or cancel this setup", 400);
211
+ // Stop/dismiss can close a card without a setup decision. It must never
212
+ // resurrect that review or wake the stopped conversation.
213
+ if (card.answered || card.dismissed) {
214
+ const result = request.result ?? { state: "cancelled", bots: [], newTeams: [], error: "This setup card was closed" };
215
+ return { result, request: { ...request, result }, messageId: message.id, duplicate: true };
216
+ }
217
+ if (this.resolving.has(request.requestId))
218
+ throw new TeamSetupError("This setup is already being applied", 409);
219
+ this.resolving.add(request.requestId);
220
+ try {
221
+ const receipt = this.options.store.bot(request.botId)?.lastTeamSetupReceipt;
222
+ let result;
223
+ if (receipt?.requestId === request.requestId)
224
+ result = receipt.result;
225
+ else if (args.behavior === "deny")
226
+ result = { state: "denied", bots: [], newTeams: [] };
227
+ else {
228
+ try {
229
+ this.validate(request, true);
230
+ if (request.deletion) {
231
+ await this.options.deleteBot(request.deletion.botId, () => this.validate(request, true), request);
232
+ result = { state: "applied", bots: [{ id: request.deletion.botId, name: request.deletion.name, action: "deleted" }], newTeams: [] };
233
+ }
234
+ else
235
+ result = this.options.store.applyTeamSetup(request);
236
+ }
237
+ catch (error) {
238
+ const saved = this.options.store.bot(request.botId)?.lastTeamSetupReceipt;
239
+ const detail = redactSecretsInText(error instanceof Error ? error.message : String(error));
240
+ result = saved?.requestId === request.requestId
241
+ ? { ...saved.result, error: `The changes were saved, but cleanup needs attention: ${detail}` }
242
+ : { state: error instanceof TeamSetupError ? "cancelled" : "failed", bots: [], newTeams: [], error: detail };
243
+ }
244
+ }
245
+ this.options.store.patchMessage(args.threadId, message.id, { card: { ...card, answered: result.state === "applied" ? "allow" : "deny", held: result.error, teamSetupRequest: { ...request, result } } });
246
+ return { result, request: { ...request, result }, messageId: message.id, duplicate: false };
247
+ }
248
+ finally {
249
+ this.resolving.delete(request.requestId);
250
+ }
251
+ }
252
+ }
@@ -44,8 +44,11 @@ export class RetiredTurnRegistry {
44
44
  export class ProviderTurnGenerationRegistry {
45
45
  #owners = new Map();
46
46
  #completed;
47
+ #earlyCompletions = new Map();
48
+ #limit;
47
49
  constructor(limit = 4_096) {
48
50
  this.#completed = new RetiredTurnRegistry(limit);
51
+ this.#limit = limit;
49
52
  }
50
53
  bind(threadId, generation, turnId) {
51
54
  if (this.#completed.has(turnId))
@@ -53,23 +56,48 @@ export class ProviderTurnGenerationRegistry {
53
56
  this.#owners.set(turnId, { threadId, generation });
54
57
  return true;
55
58
  }
56
- complete(threadId, turnId) {
57
- this.#completed.retire(turnId);
59
+ complete(threadId, turnId, outcome) {
60
+ if (this.#completed.has(turnId))
61
+ return null;
58
62
  const owner = this.#owners.get(turnId);
59
- if (!owner || owner.threadId !== threadId)
63
+ if (owner && owner.threadId !== threadId)
60
64
  return null;
65
+ this.#completed.retire(turnId);
66
+ if (!owner) {
67
+ // Only a later ACK for this exact provider id may consume the result.
68
+ // Never attribute an unbound event to the thread's current generation.
69
+ if (outcome !== undefined) {
70
+ this.#earlyCompletions.set(turnId, { threadId, outcome });
71
+ while (this.#earlyCompletions.size > this.#limit) {
72
+ this.#earlyCompletions.delete(this.#earlyCompletions.keys().next().value);
73
+ }
74
+ }
75
+ return null;
76
+ }
61
77
  this.#owners.delete(turnId);
62
78
  return owner;
63
79
  }
80
+ takeEarlyCompletion(threadId, turnId) {
81
+ const receipt = this.#earlyCompletions.get(turnId);
82
+ if (receipt?.threadId !== threadId)
83
+ return undefined;
84
+ this.#earlyCompletions.delete(turnId);
85
+ return receipt.outcome;
86
+ }
64
87
  deleteGeneration(threadId, generation) {
88
+ const removed = [];
65
89
  for (const [turnId, owner] of this.#owners) {
66
90
  if (owner.threadId === threadId && owner.generation === generation) {
67
91
  this.#owners.delete(turnId);
92
+ this.#completed.retire(turnId);
93
+ removed.push(turnId);
68
94
  }
69
95
  }
96
+ return removed;
70
97
  }
71
98
  clear() {
72
99
  this.#owners.clear();
100
+ this.#earlyCompletions.clear();
73
101
  }
74
102
  }
75
103
  /** Thread gate for the narrow interval after Stop wins but before an async
@@ -16,6 +16,13 @@ export function supportsApprovalMode(driverKind, mode) {
16
16
  return true;
17
17
  return ["codex", "claudeAgent", "antigravityAgent", "cursorAgent", "grokAgent", "opencodeGo"].includes(driverKind ?? "");
18
18
  }
19
+ /** A Full/Custom grant belongs to one provider's tool semantics. Other
20
+ * modes carry across only when the destination actually implements them.
21
+ * Adapted from tahodev's provider-switch guard in PR #1120. */
22
+ export function modelSwitchNeedsAsk(mode, fromDriver, toDriver) {
23
+ return !supportsApprovalMode(toDriver, mode) ||
24
+ ((mode === "full" || mode === "custom") && fromDriver !== toDriver);
25
+ }
19
26
  export function hasNativeAutoReview(driverKind) {
20
27
  return ["codex", "claudeAgent", "cursorAgent", "grokAgent"].includes(driverKind ?? "");
21
28
  }
@@ -21,6 +21,7 @@ const task = z.object({
21
21
  title: z.string().max(2_000),
22
22
  createdAt: timestamp,
23
23
  openedBy: z.object({ botId: key, name, at: timestamp }).optional(),
24
+ closedBy: z.object({ botId: key, name, at: timestamp }).optional(),
24
25
  activeLeafId: key.nullable(),
25
26
  messages: z.array(message).max(100_000),
26
27
  });
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openmausbot",
3
- "version": "0.1.75",
3
+ "version": "0.1.76",
4
4
  "description": "Run the OpenMausBot server anywhere and pair your devices to it",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",