openmausbot 0.1.74 → 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 (51) hide show
  1. package/dist/assets/index-BtT047dk.css +1 -0
  2. package/dist/assets/{index-CwoQdM0z.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 +3379 -1431
  8. package/dist-server/mcp-gate.js +28 -18
  9. package/dist-server/openmausbot.js +557 -325
  10. package/dist-server/pair-cli.js +557 -325
  11. package/dist-server/permission-proxy.js +119 -14
  12. package/dist-server/server/agent-tool-policy.js +1 -0
  13. package/dist-server/server/atomic.js +37 -1
  14. package/dist-server/server/auto-approve.js +15 -0
  15. package/dist-server/server/bot-overview.js +5 -1
  16. package/dist-server/server/chief-of-staff.js +17 -12
  17. package/dist-server/server/delegations.js +177 -63
  18. package/dist-server/server/drivers/acp/core.js +2 -1
  19. package/dist-server/server/drivers/acp/grok.js +12 -1
  20. package/dist-server/server/drivers/acp/hermes.js +21 -3
  21. package/dist-server/server/drivers/agents-proxy.js +113 -10
  22. package/dist-server/server/drivers/claude.js +91 -17
  23. package/dist-server/server/drivers/codex.js +15 -4
  24. package/dist-server/server/drivers/openai-chat.js +90 -67
  25. package/dist-server/server/drivers/openai-compat.js +9 -1
  26. package/dist-server/server/drivers/pi.js +18 -3
  27. package/dist-server/server/drivers/retry.js +4 -0
  28. package/dist-server/server/env-path.js +18 -17
  29. package/dist-server/server/index.js +1172 -302
  30. package/dist-server/server/message-db.js +21 -0
  31. package/dist-server/server/peer-roster.js +48 -9
  32. package/dist-server/server/permission-proxy.js +90 -15
  33. package/dist-server/server/procs.js +19 -3
  34. package/dist-server/server/room-handoffs.js +237 -0
  35. package/dist-server/server/section-context.js +64 -14
  36. package/dist-server/server/setup-mode.js +6 -7
  37. package/dist-server/server/store.js +266 -8
  38. package/dist-server/server/surface.js +16 -5
  39. package/dist-server/server/team-backup.js +17 -1
  40. package/dist-server/server/team-setup-requests.js +252 -0
  41. package/dist-server/server/thread-events.js +10 -1
  42. package/dist-server/server/turn-dispatch-guard.js +31 -3
  43. package/dist-server/server/workspace.js +13 -0
  44. package/dist-server/shared/approval-mode.js +7 -0
  45. package/dist-server/shared/ask-question.js +172 -0
  46. package/dist-server/shared/provider-safety.js +6 -0
  47. package/dist-server/shared/team-backup.js +1 -0
  48. package/dist-server/shared/team-setup.js +1 -0
  49. package/package.json +1 -1
  50. package/dist/assets/index-BRLHsQ5g.js +0 -298
  51. package/dist/assets/index-ByLQ7a3F.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
+ }
@@ -133,6 +133,14 @@ const stringOrMissing = (value) => value === undefined || typeof value === "stri
133
133
  const stringOrNullOrMissing = (value) => value === undefined || value === null || typeof value === "string";
134
134
  const numberOrNullOrMissing = (value) => value === undefined || value === null || typeof value === "number";
135
135
  const stringsOrMissing = (value) => value === undefined || (Array.isArray(value) && value.every((item) => typeof item === "string"));
136
+ /** A replayed structured ask. Only the shape the card actually reads is
137
+ * required; the rest is optional and simply absent on an older event. */
138
+ const askQuestionsOrMissing = (value) => value === undefined ||
139
+ (Array.isArray(value) &&
140
+ value.every((question) => isRecord(question) &&
141
+ typeof question.question === "string" &&
142
+ Array.isArray(question.options) &&
143
+ question.options.every((option) => isRecord(option) && typeof option.label === "string")));
136
144
  function isRuntimeEvent(value) {
137
145
  if (!isRecord(value) ||
138
146
  typeof value.eventId !== "string" ||
@@ -179,7 +187,8 @@ function isRuntimeEvent(value) {
179
187
  return ((value.requestType === "permission" || value.requestType === "question") &&
180
188
  typeof value.tool === "string" &&
181
189
  typeof value.summary === "string" &&
182
- stringsOrMissing(value.choices));
190
+ stringsOrMissing(value.choices) &&
191
+ askQuestionsOrMissing(value.questions));
183
192
  case "request.resolved":
184
193
  return ((value.behavior === "allow" || value.behavior === "deny" || value.behavior === "answer") &&
185
194
  (value.source === "user" ||
@@ -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
@@ -52,6 +52,19 @@ export function ensureWorkspace(botId) {
52
52
  export function workspaceDir(botId) {
53
53
  return join(WORKSPACES_DIR, botId);
54
54
  }
55
+ /** File locations, not file contents or wider tool permissions. Threads keep
56
+ * independent working directories; the same bot can find its earlier output
57
+ * without assuming that a file absent from the current directory was lost. */
58
+ export function workspaceLocationsPrompt(botId, cwd, botCwd) {
59
+ return "\n\nFile locations for this bot (absolute paths): " + JSON.stringify({
60
+ currentWorkingFolder: cwd ?? "Provider default; inspect the working directory before using relative paths",
61
+ sharedBotFolder: workspaceDir(botId),
62
+ otherThreadFiles: join(TASK_WORKSPACES_DIR, botId),
63
+ ...(botCwd ? { configuredProjectFolder: botCwd } : {}),
64
+ }) + ". Different conversations can have different working folders. For an existing file, use the exact path from the conversation; if missing here, check this bot's listed folders before saying it is gone or recreating it." +
65
+ " Follow an explicitly requested destination. Otherwise put new task output in the current working folder and report its absolute path so another thread or room can use it." +
66
+ " Do not move old files, edit another active thread's work, or read another bot's private folders without authorization. These paths do not grant additional access.";
67
+ }
55
68
  /** Lines as a person counts them: a file that ends in a newline has no
56
69
  * extra empty line after it. Every budget check and every "N lines"
57
70
  * message uses this one count. */
@@ -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
  }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Structured questions raised by a provider's own "ask the human" tool —
3
+ * Claude Code's built-in `AskUserQuestion`.
4
+ *
5
+ * That tool reaches us as a PERMISSION ask (the CLI routes it through
6
+ * --permission-prompt-tool like any other tool use), which is exactly the
7
+ * wrong shape: a person cannot answer "which model should this bot use?"
8
+ * with Deny / Always allow / Allow once. So the ask is re-read here into the
9
+ * questions the model actually posed, the card renders them as choices, and
10
+ * the answer goes back as text.
11
+ *
12
+ * The payload is bot-authored, so every field is treated as untrusted: the
13
+ * shape is validated rather than cast, and the counts and lengths are capped
14
+ * so a runaway (or hostile) tool call cannot produce an unreadable card.
15
+ */
16
+ /** The provider tool whose input this module understands. */
17
+ export const ASK_USER_QUESTION_TOOL = "AskUserQuestion";
18
+ /** Caps. Claude Code's own limits are smaller (1-4 questions, 2-4 options);
19
+ * these leave room for a provider that widens them without letting a card
20
+ * grow without bound. */
21
+ export const MAX_QUESTIONS = 6;
22
+ export const MAX_OPTIONS = 12;
23
+ const MAX_QUESTION_TEXT = 400;
24
+ const MAX_LABEL = 120;
25
+ const MAX_DESCRIPTION = 400;
26
+ /** One free-text answer. Long enough for a sentence or two of context. */
27
+ export const MAX_CUSTOM_ANSWER = 2000;
28
+ function text(value, limit) {
29
+ if (typeof value !== "string")
30
+ return undefined;
31
+ const trimmed = value.trim();
32
+ return trimmed ? trimmed.slice(0, limit) : undefined;
33
+ }
34
+ function isRecord(value) {
35
+ return typeof value === "object" && value !== null && !Array.isArray(value);
36
+ }
37
+ function parseOption(value) {
38
+ // A bare string is not the documented shape, but it is the obvious
39
+ // degradation and costs one line to accept.
40
+ if (typeof value === "string") {
41
+ const label = text(value, MAX_LABEL);
42
+ return label ? { label } : null;
43
+ }
44
+ if (!isRecord(value))
45
+ return null;
46
+ const label = text(value.label, MAX_LABEL);
47
+ if (!label)
48
+ return null;
49
+ const description = text(value.description, MAX_DESCRIPTION);
50
+ return description ? { label, description } : { label };
51
+ }
52
+ function parseQuestion(value) {
53
+ if (!isRecord(value))
54
+ return null;
55
+ const question = text(value.question, MAX_QUESTION_TEXT);
56
+ if (!question)
57
+ return null;
58
+ const options = [];
59
+ const seen = new Set();
60
+ for (const raw of Array.isArray(value.options) ? value.options : []) {
61
+ const option = parseOption(raw);
62
+ // Duplicate labels are the one thing a radio group cannot survive: the
63
+ // answer text could no longer say which row was picked.
64
+ if (!option || seen.has(option.label))
65
+ continue;
66
+ seen.add(option.label);
67
+ options.push(option);
68
+ if (options.length === MAX_OPTIONS)
69
+ break;
70
+ }
71
+ // A question with nothing to choose from is still answerable — the card
72
+ // always offers free text — so an empty option list is kept, not dropped.
73
+ const header = text(value.header, MAX_LABEL);
74
+ return {
75
+ question,
76
+ ...(header ? { header } : {}),
77
+ ...(value.multiSelect === true ? { multiSelect: true } : {}),
78
+ options,
79
+ };
80
+ }
81
+ /** The questions inside an AskUserQuestion tool input, or null when the
82
+ * payload is not one (a malformed call falls back to the ordinary card). */
83
+ export function parseAskQuestions(input) {
84
+ if (!isRecord(input) || !Array.isArray(input.questions))
85
+ return null;
86
+ const questions = [];
87
+ for (const raw of input.questions) {
88
+ const question = parseQuestion(raw);
89
+ if (!question)
90
+ continue;
91
+ questions.push(question);
92
+ if (questions.length === MAX_QUESTIONS)
93
+ break;
94
+ }
95
+ return questions.length ? questions : null;
96
+ }
97
+ /** The one line the card subtitle and a spoken prompt show. */
98
+ export function askQuestionSummary(questions) {
99
+ const first = questions[0]?.question ?? "";
100
+ const rest = questions.length - 1;
101
+ return rest > 0 ? `${first} (+${rest} more question${rest > 1 ? "s" : ""})` : first;
102
+ }
103
+ /** Flat labels for clients that only know how to render a list of choices
104
+ * (the phone companions, and any older desktop build). Only a single
105
+ * question can be answered that way without losing which one was answered. */
106
+ export function questionChoices(questions) {
107
+ if (questions.length !== 1)
108
+ return undefined;
109
+ const only = questions[0];
110
+ if (only.multiSelect || only.options.length < 2)
111
+ return undefined;
112
+ return only.options.map((option) => option.label);
113
+ }
114
+ /** The lead-in on a formatted answer. It exists for the model — the answer
115
+ * is delivered on the deny channel, so it has to say what it is — and the
116
+ * card strips it back off when it shows the person what they sent. */
117
+ export const ANSWER_PREAMBLE = "The user answered your questions.";
118
+ /**
119
+ * What the model is told. It arrives as the tool's result, so it has to
120
+ * stand on its own: name each question, then what was picked for it.
121
+ */
122
+ export function formatQuestionAnswers(questions, answers) {
123
+ const blocks = [];
124
+ questions.forEach((question, index) => {
125
+ const picked = (answers[index] ?? []).map((value) => value.trim()).filter(Boolean);
126
+ if (!picked.length)
127
+ return;
128
+ blocks.push(`Q: ${question.question}\nA: ${picked.join(", ")}`);
129
+ });
130
+ if (!blocks.length)
131
+ return "";
132
+ return `${ANSWER_PREAMBLE}\n\n${blocks.join("\n\n")}`;
133
+ }
134
+ /** The same answer with the model-facing lead-in removed, for the settled
135
+ * card. Anything that does not carry the lead-in is shown as it is. */
136
+ export function answerWithoutPreamble(answer) {
137
+ return answer.startsWith(`${ANSWER_PREAMBLE}\n\n`) ? answer.slice(ANSWER_PREAMBLE.length + 2) : answer;
138
+ }
139
+ /**
140
+ * The answer text, read back as one value per question.
141
+ *
142
+ * `AskUserQuestion` is answered through its own `answers` field, keyed by the
143
+ * question's text — but the card sends ONE answer for the whole set, because
144
+ * a person answers the whole card at once. `formatQuestionAnswers` writes
145
+ * each question's text beside its answer for exactly this reason, so the map
146
+ * is recovered rather than guessed.
147
+ *
148
+ * A message that carries no blocks at all is the flat path: an older client,
149
+ * or a phone answering a single-question card with one of the option labels
150
+ * the harness also sends. With exactly one question there is no ambiguity
151
+ * about what it answers, so the whole message is that question's answer.
152
+ * With more than one there is, and nothing is filed.
153
+ */
154
+ export function questionAnswersByQuestion(message, questions) {
155
+ const answers = {};
156
+ const known = new Map(questions.map((entry) => [entry.question, entry.question]));
157
+ for (const block of message.split("\n\n")) {
158
+ const match = /^Q: ([\s\S]+?)\nA: ([\s\S]+)$/.exec(block.trim());
159
+ if (!match)
160
+ continue;
161
+ // Only a question this ask actually posed. An unrecognized block is
162
+ // dropped rather than filed under a key the tool never asked about.
163
+ const question = known.get(match[1].trim());
164
+ if (question)
165
+ answers[question] = match[2].trim();
166
+ }
167
+ if (Object.keys(answers).length)
168
+ return answers;
169
+ const only = questions.length === 1 ? questions[0] : undefined;
170
+ const flat = message.trim();
171
+ return only && flat ? { [only.question]: flat } : {};
172
+ }
@@ -0,0 +1,6 @@
1
+ /** Match provider error wording, not ordinary assistant discussions of safety. */
2
+ export function isProviderSafetyBlock(message) {
3
+ return /\bblocked by (?:our|the provider['’]s) safety systems\b|\bsafety monitoring\b.{0,100}\b(?:paused|ended|blocked)\b|\b(?:safety_check_failed|safety_policy_violation)\b/i.test(message);
4
+ }
5
+ export const PROVIDER_SAFETY_GUIDANCE = "The provider stopped this task. Full access controls tool approvals, not provider safety checks. Review the provider’s findings in Codex if available; OpenMausBot cannot override this block.";
6
+ export const PROVIDER_SAFETY_HELP_URL = "https://learn.chatgpt.com/docs/agent-approvals-security#safety-monitoring-and-paused-tasks";
@@ -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.74",
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",