openmausbot 0.1.56 → 0.1.57

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 (43) hide show
  1. package/dist/assets/index-B433sv8K.css +1 -0
  2. package/dist/assets/index-DSh8AIUy.js +264 -0
  3. package/dist/assets/{index-Dq2j4Ikr.js → index-jWqt5dmY.js} +1 -1
  4. package/dist/index.html +2 -2
  5. package/dist-server/companion/src/routes.js +6 -1
  6. package/dist-server/connector-proxy.js +31 -12
  7. package/dist-server/container-mcp.js +49 -31
  8. package/dist-server/drivers/agents-proxy.js +56 -2
  9. package/dist-server/index.js +1769 -338
  10. package/dist-server/server/auto-approve.js +24 -0
  11. package/dist-server/server/bot-folder.js +102 -0
  12. package/dist-server/server/bot-overview.js +206 -0
  13. package/dist-server/server/bot-package.js +5 -0
  14. package/dist-server/server/bot-profile.js +7 -0
  15. package/dist-server/server/connector-proxy.js +37 -16
  16. package/dist-server/server/container-computer.js +9 -2
  17. package/dist-server/server/drivers/agents-proxy.js +63 -2
  18. package/dist-server/server/drivers/antigravity-acp.js +61 -13
  19. package/dist-server/server/drivers/antigravity-runtime.js +47 -3
  20. package/dist-server/server/drivers/claude.js +7 -1
  21. package/dist-server/server/index.js +589 -103
  22. package/dist-server/server/mcp-bridge.js +73 -44
  23. package/dist-server/server/package-export.js +1 -0
  24. package/dist-server/server/profile-requests.js +332 -0
  25. package/dist-server/server/profile-revision.js +15 -0
  26. package/dist-server/server/profile-versions.js +154 -0
  27. package/dist-server/server/routine-requests.js +34 -1
  28. package/dist-server/server/routines.js +70 -2
  29. package/dist-server/server/setup-mode.js +63 -0
  30. package/dist-server/server/skill-fetch.js +67 -11
  31. package/dist-server/server/skills.js +1 -1
  32. package/dist-server/server/store.js +87 -7
  33. package/dist-server/server/system-prompt.js +49 -0
  34. package/dist-server/server/team-backup.js +2 -2
  35. package/dist-server/server/team-manifest.js +7 -0
  36. package/dist-server/shared/bot-profile.js +5 -1
  37. package/dist-server/shared/line-diff.js +42 -0
  38. package/dist-server/shared/profile-request.js +9 -0
  39. package/dist-server/shared/team-backup.js +4 -0
  40. package/dist-server/vps-container-mcp.js +49 -31
  41. package/package.json +1 -1
  42. package/dist/assets/index-CJBrtDbC.css +0 -1
  43. package/dist/assets/index-D0xt96rv.js +0 -263
@@ -184,3 +184,27 @@ export function autoVerdict(bot, tool, summary, context) {
184
184
  export function autoDecision(bot, tool, summary, context) {
185
185
  return autoVerdict(bot, tool, summary, context).approve;
186
186
  }
187
+ /** The note a card shows above its buttons, explaining why the bot stopped
188
+ * rather than answering for itself.
189
+ *
190
+ * The unattended case is the one users misread. A turn a webhook or another
191
+ * bot started never runs Auto at all — approvalModeForTurn downgrades it to
192
+ * Ask before the provider spawns — so "this action needs you" would name the
193
+ * wrong cause and imply the next action might pass. It will not: with a fleet
194
+ * delegating between bots, every card looks like this until someone answers.
195
+ * Say that plainly, and name the mode that keeps running. */
196
+ export function approvalHeldReason(context) {
197
+ if (context.source === "native-approval")
198
+ return "The provider requires your approval for this action.";
199
+ if (!context.permission)
200
+ return undefined;
201
+ if (context.requiresExplicitApproval) {
202
+ return "This changes the provider sandbox, so only Full access can approve it automatically.";
203
+ }
204
+ if (context.mode !== "auto")
205
+ return undefined;
206
+ if (!context.unattended)
207
+ return "This action needs you, so Approve for me stopped to ask.";
208
+ const hint = context.fullAccessAvailable ? " Full access keeps working unattended." : "";
209
+ return `A webhook or another bot started this turn, so Approve for me is paused and every action asks.${hint}`;
210
+ }
@@ -0,0 +1,102 @@
1
+ // The bot folder: ~/.openmausbot/bots/<botId>/, owned by the server.
2
+ //
3
+ // SOUL.md is a MIRROR of BotRecord.soul, never the source of truth. The
4
+ // prompt is built from the record; the file exists so a person can read
5
+ // and edit the bot in their editor and so a bot can export as a folder.
6
+ // The folder sits outside the bot-writable workspace on purpose: a bot
7
+ // that reads untrusted content (a Discord channel, a webhook payload)
8
+ // must not be able to persist injected text into its own persona. A
9
+ // mirror that no longer matches the record is surfaced to the user as
10
+ // drift, with its text, and is never applied on its own.
11
+ import { createHash } from "node:crypto";
12
+ import { closeSync, mkdirSync, openSync, readSync, rmSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { writeFileAtomic } from "./atomic.js";
15
+ import { DATA_DIR } from "./config.js";
16
+ import { BOT_PROFILE_LIMITS } from "../shared/bot-profile.js";
17
+ export const BOTS_DIR = join(DATA_DIR, "bots");
18
+ export const SOUL_FILE = "SOUL.md";
19
+ export function botFolder(botId) {
20
+ return join(BOTS_DIR, botId);
21
+ }
22
+ export function soulFile(botId) {
23
+ return join(botFolder(botId), SOUL_FILE);
24
+ }
25
+ export function soulHash(soul) {
26
+ return createHash("sha256").update(soul, "utf8").digest("hex");
27
+ }
28
+ /** Rewrite the mirror from the canonical text. Always writes, even for an
29
+ * empty soul, so the folder exists for the user to find. Private modes:
30
+ * standing instructions can describe a person's work in detail. */
31
+ export function writeSoulMirror(botId, soul) {
32
+ mkdirSync(botFolder(botId), { recursive: true, mode: 0o700 });
33
+ writeFileAtomic(soulFile(botId), soul, { mode: 0o600 });
34
+ }
35
+ /** Compare the mirror with the record's hash. A missing mirror is not
36
+ * drift — it is re-created from the record. A differing one is reported
37
+ * together with its text so the user can apply or discard it. Editor reads
38
+ * surface filesystem failures instead of mistaking them for a clean mirror. */
39
+ export function readSoulDrift(botId, soul, hash) {
40
+ let fileText;
41
+ try {
42
+ const fd = openSync(soulFile(botId), "r");
43
+ try {
44
+ // External edits are unbounded. Read at most the budget plus one byte,
45
+ // including if the file grows while the editor has it open.
46
+ const bytes = Buffer.alloc(BOT_PROFILE_LIMITS.soul + 1);
47
+ let length = 0;
48
+ while (length < bytes.length) {
49
+ const count = readSync(fd, bytes, length, bytes.length - length, null);
50
+ if (!count)
51
+ break;
52
+ length += count;
53
+ }
54
+ if (length > BOT_PROFILE_LIMITS.soul) {
55
+ throw Object.assign(new Error("SOUL.md exceeds 24000 bytes; shorten it in your editor"), { status: 400 });
56
+ }
57
+ fileText = bytes.subarray(0, length).toString("utf8");
58
+ }
59
+ finally {
60
+ closeSync(fd);
61
+ }
62
+ }
63
+ catch (error) {
64
+ if (error.code !== "ENOENT")
65
+ throw error;
66
+ writeSoulMirror(botId, soul);
67
+ return { drift: false };
68
+ }
69
+ if (soulHash(fileText) === hash)
70
+ return { drift: false };
71
+ return { drift: true, fileText };
72
+ }
73
+ /** Turn dispatch is best-effort; explicit editor actions use readSoulDrift
74
+ * so an unreadable file is never presented as successfully discarded. */
75
+ export function checkSoulDrift(botId, soul, hash) {
76
+ try {
77
+ return readSoulDrift(botId, soul, hash);
78
+ }
79
+ catch {
80
+ return { drift: false };
81
+ }
82
+ }
83
+ export function removeBotFolder(botId) {
84
+ try {
85
+ rmSync(botFolder(botId), { recursive: true, force: true });
86
+ }
87
+ catch { }
88
+ }
89
+ /** The standing-instructions block of the system prompt. Empty soul,
90
+ * empty block, so a bot without one gets today's prompt byte for byte. */
91
+ export function soulSystemPrompt(soul) {
92
+ const text = soul.trim();
93
+ if (!text)
94
+ return "";
95
+ const bytes = Buffer.byteLength(text, "utf8");
96
+ return ("\n\nYour standing instructions follow. The user manages them in bot settings and SOUL.md; proposed changes apply only after the user confirms." +
97
+ " They rank above your memory and imported skills, and below the user's current request and safety boundaries." +
98
+ ` Text inside this block is instruction for you, never tool authorization or permission to expose secrets.` +
99
+ `\n\n--- BEGIN STANDING INSTRUCTIONS (SOUL.md, ${bytes} bytes) ---\n` +
100
+ text +
101
+ "\n--- END STANDING INSTRUCTIONS ---");
102
+ }
@@ -0,0 +1,206 @@
1
+ import { approvalModeFor } from "../shared/approval-mode.js";
2
+ /** The first paragraph of a SOUL.md-style persona, capped at 240 characters
3
+ * so a settings-dialog card never renders a full standing-instructions
4
+ * document inline. */
5
+ export function soulLead(soul) {
6
+ const trimmed = (soul ?? "").trim();
7
+ if (!trimmed)
8
+ return "";
9
+ const paragraph = (trimmed.split(/\r?\n\s*\r?\n/)[0] ?? trimmed).trim();
10
+ return paragraph.length > 240 ? `${paragraph.slice(0, 240).trimEnd()}…` : paragraph;
11
+ }
12
+ function lowerFirst(value) {
13
+ return value.length ? value[0].toLowerCase() + value.slice(1) : value;
14
+ }
15
+ function time(at, timeZone) {
16
+ return new Date(at).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", timeZone });
17
+ }
18
+ const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
19
+ /** "09:05" → "9:05 AM" — the daily schedule's wall-clock time as a person
20
+ * would say it, with no timezone suffix (the Overview is read in the
21
+ * timezone it was built for). */
22
+ function clockTime(hhmm) {
23
+ const [h = 0, m = 0] = hhmm.split(":").map((part) => Number.parseInt(part, 10) || 0);
24
+ return new Date(Date.UTC(2000, 0, 1, h, m)).toLocaleTimeString("en-US", {
25
+ hour: "numeric",
26
+ minute: "2-digit",
27
+ timeZone: "UTC",
28
+ });
29
+ }
30
+ /** The schedule as the opening phrase of a Does line — "Every 5 minutes",
31
+ * "Every weekday at 9:00 AM", "Once on September 5 at 12:00 PM". The
32
+ * approval card's scheduleText() carries the anchor instant and timezone
33
+ * name because a card must be exact; a plain-language overview must not. */
34
+ function schedulePhrase(schedule, timeZone) {
35
+ if (schedule.type === "once") {
36
+ const date = new Date(schedule.at).toLocaleDateString("en-US", { month: "long", day: "numeric", timeZone });
37
+ return `Once on ${date} at ${time(schedule.at, timeZone)}`;
38
+ }
39
+ if (schedule.type === "interval") {
40
+ const minutes = schedule.everyMinutes;
41
+ if (minutes < 60)
42
+ return `Every ${minutes} minute${minutes === 1 ? "" : "s"}`;
43
+ if (minutes === 60)
44
+ return "Every hour";
45
+ if (minutes % 60 === 0)
46
+ return `Every ${minutes / 60} hours`;
47
+ return `Every ${Math.floor(minutes / 60)} hour${Math.floor(minutes / 60) === 1 ? "" : "s"} ${minutes % 60} minutes`;
48
+ }
49
+ const days = schedule.weekdays;
50
+ const at = ` at ${clockTime(schedule.time)}`;
51
+ if (days.length === 7)
52
+ return `Every day${at}`;
53
+ if (days.join(",") === "1,2,3,4,5")
54
+ return `Every weekday${at}`;
55
+ if (days.length === 1)
56
+ return `Weekly on ${DAY_NAMES[days[0]]}${at}`;
57
+ return `${days.map((day) => DAY_NAMES[day]).join(", ")}${at}`;
58
+ }
59
+ /** The most recent run for a routine, by (finishedAt ?? startedAt ??
60
+ * scheduledFor) — not merely the first match in `runs`, since callers may
61
+ * hand this an unsorted or multi-routine list. */
62
+ function latestRunFor(runs, routineId) {
63
+ let best;
64
+ let bestAt = -Infinity;
65
+ for (const run of runs) {
66
+ if (run.routineId !== routineId)
67
+ continue;
68
+ const at = run.finishedAt ?? run.startedAt ?? run.scheduledFor;
69
+ if (at > bestAt) {
70
+ bestAt = at;
71
+ best = run;
72
+ }
73
+ }
74
+ return best;
75
+ }
76
+ function doesLines(facts) {
77
+ const lines = [];
78
+ for (const routine of facts.routines) {
79
+ if (!routine.enabled) {
80
+ lines.push(`Paused: ${routine.name}.`);
81
+ continue;
82
+ }
83
+ const nextRun = routine.nextRunAt != null ? ` Next run ${time(routine.nextRunAt, facts.timeZone)}.` : "";
84
+ const latest = latestRunFor(facts.runs, routine.id);
85
+ const at = latest ? latest.finishedAt ?? latest.startedAt ?? latest.scheduledFor : undefined;
86
+ const lastRun = latest ? ` Last run ${latest.status} at ${time(at, facts.timeZone)}.` : "";
87
+ lines.push(`${schedulePhrase(routine.schedule, facts.timeZone)}: ${routine.name}.${nextRun}${lastRun}`);
88
+ }
89
+ for (const skill of facts.skills) {
90
+ if (!skill.enabled)
91
+ continue;
92
+ lines.push(`Knows how to ${lowerFirst(skill.description || skill.name)}.`);
93
+ }
94
+ for (const webhook of facts.webhooks) {
95
+ if (!webhook.enabled)
96
+ continue;
97
+ lines.push(`Listens for “${webhook.name}” webhooks.`);
98
+ }
99
+ return lines;
100
+ }
101
+ /** The connected-apps facts for the route: reads the inventory only when a
102
+ * connector is configured and the credential store was readable, and treats
103
+ * a failing read (connector down, token rejected) as "unverified" rather
104
+ * than letting it fail the whole Overview. `read` is injected so the
105
+ * fallback is unit-testable without a live connector. */
106
+ export async function connectedAppsFacts(configured, availability, read) {
107
+ let authoritative = availability !== "unreadable";
108
+ let services = [];
109
+ if (configured && authoritative) {
110
+ try {
111
+ services = Object.entries(await read())
112
+ .filter(([, state]) => state.connected)
113
+ .map(([slug]) => slug);
114
+ }
115
+ catch {
116
+ authoritative = false;
117
+ }
118
+ }
119
+ return { configured, authoritative, services };
120
+ }
121
+ /** Whether this bot could use connected apps at all: apps on for the bot,
122
+ * a connector configured, and an engine that mounts the Composio MCP. */
123
+ function couldUseApps(facts) {
124
+ return facts.bot.composio !== false && facts.connectedApps.configured && Boolean(facts.engine?.composioMcp);
125
+ }
126
+ function computerReach(computer) {
127
+ switch (computer) {
128
+ case "cloud":
129
+ return "Computer preference: cloud computer.";
130
+ case "vm":
131
+ return "Computer preference: Local VM.";
132
+ case "local":
133
+ return "Computer preference: this computer.";
134
+ case "browser":
135
+ return "Computer preference: browser only.";
136
+ case "off":
137
+ return null;
138
+ default:
139
+ return "Computer preference: Auto; availability is checked when a task starts.";
140
+ }
141
+ }
142
+ function reachesLines(facts) {
143
+ const lines = [];
144
+ const computer = computerReach(facts.bot.computer);
145
+ if (computer)
146
+ lines.push(computer);
147
+ lines.push(facts.bot.cwd ? `Works in ${facts.bot.cwd}.` : "Works in its private workspace.");
148
+ const apps = facts.connectedApps;
149
+ // Only a bot that could actually use apps gets an apps line here. When it
150
+ // could, the inventory decides: verified and non-empty → list them;
151
+ // unverified → say so rather than guess either way.
152
+ if (couldUseApps(facts) && apps.authoritative && apps.services.length > 0) {
153
+ const count = apps.services.length;
154
+ lines.push(`Can use ${count} connected app${count === 1 ? "" : "s"}: ${apps.services.join(", ")}.`);
155
+ }
156
+ else if (couldUseApps(facts) && !apps.authoritative) {
157
+ lines.push("Connected apps could not be checked.");
158
+ }
159
+ if (facts.browserEnabled && facts.engine?.browserMcp && facts.bot.browser !== false && facts.bot.computer !== "off")
160
+ lines.push("Has the built-in browser.");
161
+ if (facts.engine?.agentsMcp && facts.sectionPeers > 0 && facts.bot.peers?.length !== 0) {
162
+ lines.push(`Can talk to ${facts.sectionPeers} other bot${facts.sectionPeers === 1 ? "" : "s"} in its section.`);
163
+ }
164
+ if (facts.bot.chiefOfStaff)
165
+ lines.push("Coordinates its section as Chief of Staff.");
166
+ return lines;
167
+ }
168
+ function wontLines(facts) {
169
+ const lines = [];
170
+ const mode = approvalModeFor(facts.bot);
171
+ if (mode === "ask")
172
+ lines.push("Command approvals use Ask mode; saved permissions and provider rules still apply.");
173
+ if (mode === "custom")
174
+ lines.push("Command approvals follow the provider's custom configuration.");
175
+ if (facts.bot.peers?.length === 0)
176
+ lines.push("Cannot initiate contact with other bots.");
177
+ else if (facts.bot.approvePeerComms)
178
+ lines.push("Asks before contacting other bots.");
179
+ // "Has no connected apps." is definite when apps are off for this bot,
180
+ // not configured, or unsupported by its engine — no inventory needed. Only
181
+ // the "configured but nothing connected" case rests on the inventory, so
182
+ // only that case requires it to be authoritative.
183
+ if (!couldUseApps(facts) || (facts.connectedApps.authoritative && facts.connectedApps.services.length === 0)) {
184
+ lines.push("Has no connected apps.");
185
+ }
186
+ if (facts.bot.computer === "off")
187
+ lines.push("Can't use a computer.");
188
+ if (!facts.routines.some((routine) => routine.enabled))
189
+ lines.push("Won't act on a schedule.");
190
+ lines.push("Profile proposal cards require your approval.");
191
+ return lines;
192
+ }
193
+ export function buildBotOverview(facts) {
194
+ return {
195
+ who: {
196
+ name: facts.bot.name,
197
+ title: facts.bot.title,
198
+ blurb: facts.bot.description,
199
+ soulLead: soulLead(facts.bot.soul),
200
+ },
201
+ does: doesLines(facts),
202
+ reaches: reachesLines(facts),
203
+ wont: wontLines(facts),
204
+ recent: facts.recent,
205
+ };
206
+ }
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
3
3
  import { schemaIssue } from "./schema.js";
4
+ import { BOT_PROFILE_LIMITS } from "../shared/bot-profile.js";
4
5
  export const BOT_PACKAGE_FORMAT = "openmaus.package";
5
6
  export const BOT_PACKAGE_VERSION = 1;
6
7
  export const BOTMRR_MARKDOWN_VERSION = 1;
@@ -57,6 +58,9 @@ const packageSchema = z.object({
57
58
  name: requiredText(100),
58
59
  title: optionalText(200),
59
60
  description: optionalText(4_000),
61
+ soul: z.string().refine((value) => Buffer.byteLength(value, "utf8") <= BOT_PROFILE_LIMITS.soul, {
62
+ error: "standing instructions must be at most 24000 bytes",
63
+ }).optional(),
60
64
  appearance: z.object({
61
65
  color: z.enum(COLORS, { error: "is not supported" }),
62
66
  mascotExpression: optionalText(80),
@@ -259,6 +263,7 @@ export function packageAgentAsMember(agent) {
259
263
  name: agent.name,
260
264
  title: agent.title ?? "",
261
265
  description: agent.description ?? "",
266
+ ...(agent.soul !== undefined ? { soul: agent.soul } : {}),
262
267
  appearance: {
263
268
  color: agent.appearance.color,
264
269
  ...(agent.appearance.mascotExpression ? { mascotExpression: agent.appearance.mascotExpression } : {}),
@@ -21,6 +21,7 @@ export const BOT_PROFILE_PATCH_FIELDS = [
21
21
  "name",
22
22
  "title",
23
23
  "description",
24
+ "soul",
24
25
  "notifications",
25
26
  "avatarUrl",
26
27
  "avatarCrop",
@@ -44,6 +45,12 @@ const profilePatchSchema = z.object({
44
45
  .string({ error: "description must be a string" })
45
46
  .max(BOT_PROFILE_LIMITS.description, { error: "description must be at most 4000 characters" })
46
47
  .optional(),
48
+ soul: z
49
+ .string({ error: "soul must be a string" })
50
+ .refine((value) => Buffer.byteLength(value, "utf8") <= BOT_PROFILE_LIMITS.soul, {
51
+ error: "standing instructions must be at most 24000 bytes",
52
+ })
53
+ .optional(),
47
54
  notifications: z.boolean({ error: "notifications must be true or false" }).optional(),
48
55
  avatarUrl: z
49
56
  .union([botAvatarUrlSchema, z.literal(""), z.null()], {
@@ -118,22 +118,42 @@ function connectorAdds(args) {
118
118
  const toolkits = args.toolkits;
119
119
  if (!Array.isArray(toolkits))
120
120
  return [];
121
- return [...new Set(toolkits.flatMap((item) => {
122
- if (typeof item === "string")
123
- return [item.toLowerCase()];
124
- if (!item || typeof item !== "object" || Array.isArray(item))
125
- return [];
126
- const row = item;
127
- const slug = typeof row.toolkit === "string" ? row.toolkit : row.name;
128
- const action = String(row.action ?? "add").toLowerCase();
129
- return typeof slug === "string" && ["add", "connect", "initiate"].includes(action) ? [slug.toLowerCase()] : [];
130
- }))];
121
+ const seen = new Set();
122
+ const requests = [];
123
+ for (const item of toolkits) {
124
+ if (typeof item === "string") {
125
+ const slug = item.trim().toLowerCase();
126
+ const key = JSON.stringify([slug, ""]);
127
+ if (!seen.has(key)) {
128
+ seen.add(key);
129
+ requests.push({ slug });
130
+ }
131
+ continue;
132
+ }
133
+ if (!item || typeof item !== "object" || Array.isArray(item))
134
+ continue;
135
+ const row = item;
136
+ const action = String(row.action ?? "add").toLowerCase();
137
+ if (!["add", "connect", "initiate"].includes(action))
138
+ continue;
139
+ const slug = typeof row.toolkit === "string" ? row.toolkit : row.name;
140
+ if (typeof slug !== "string")
141
+ continue;
142
+ const normalized = slug.trim().toLowerCase();
143
+ const alias = (typeof row.alias === "string" ? row.alias : typeof row.account === "string" ? row.account : "").trim();
144
+ const key = JSON.stringify([normalized, alias.toLowerCase()]);
145
+ if (seen.has(key))
146
+ continue;
147
+ seen.add(key);
148
+ requests.push({ slug: normalized, ...(alias ? { alias } : {}) });
149
+ }
150
+ return requests;
131
151
  }
132
- async function showConnectorCards(slugs) {
152
+ async function showConnectorCards(items) {
133
153
  const response = await fetch(`${HARNESS}/api/internal/connectors/request`, {
134
154
  method: "POST",
135
155
  headers: { "content-type": "application/json", authorization: `Bearer ${TOKEN}` },
136
- body: JSON.stringify({ botId: BOT_ID, threadId: THREAD_ID, slugs, resumeKey: randomUUID() }),
156
+ body: JSON.stringify({ botId: BOT_ID, threadId: THREAD_ID, items, resumeKey: randomUUID() }),
137
157
  signal: AbortSignal.timeout(30_000),
138
158
  });
139
159
  if (!response.ok) {
@@ -176,10 +196,11 @@ async function handle(message) {
176
196
  if (method === "tools/call") {
177
197
  const params = (message.params ?? {});
178
198
  const name = String(params.name ?? "");
179
- const slugs = /MANAGE_CONNECTIONS$/i.test(name) ? connectorAdds(params.arguments) : [];
180
- if (slugs.length) {
181
- await showConnectorCards(slugs);
182
- send(textResult(id, `OpenMausBot showed the user a secure connection card for ${slugs.join(", ")}. End this turn now. The app will continue the task automatically after the connection finishes.`));
199
+ const requests = /MANAGE_CONNECTIONS$/i.test(name) ? connectorAdds(params.arguments) : [];
200
+ if (requests.length) {
201
+ await showConnectorCards(requests);
202
+ const labels = requests.map((r) => (r.alias ? `${r.slug} (${r.alias})` : r.slug)).join(", ");
203
+ send(textResult(id, `OpenMausBot showed the user a secure connection card for ${labels}. End this turn now. The app will continue the task automatically after the connection finishes.`));
183
204
  return;
184
205
  }
185
206
  if (/WAIT_FOR_CONNECTIONS$/i.test(name)) {
@@ -728,7 +728,10 @@ export function wholeScreenshot(bytes) {
728
728
  mime: "image/jpeg",
729
729
  };
730
730
  }
731
- export async function containerComputerScreenshot(runner = sh, platform = process.platform, target = SHARED_LOCAL_VM_TARGET) {
731
+ /** The raw frame, in the shape the live screen poller broadcasts to every
732
+ * client (server/index.ts). The web panel wants a data URL instead, so
733
+ * containerComputerScreenshot below wraps this one. */
734
+ export async function containerComputerFrame(runner = sh, platform = process.platform, target = SHARED_LOCAL_VM_TARGET) {
732
735
  const cacheable = runner === sh && platform === process.platform;
733
736
  const now = Date.now();
734
737
  const cached = screenshotStatusCache.get(target.key);
@@ -759,7 +762,7 @@ export async function containerComputerScreenshot(runner = sh, platform = proces
759
762
  if (!checked.ok) {
760
763
  throw Object.assign(new Error("Cua Driver returned an incomplete screenshot"), { status: 502 });
761
764
  }
762
- return `data:${checked.mime};base64,${data}`;
765
+ return { png: data, format: checked.mime === "image/jpeg" ? "jpeg" : "png" };
763
766
  }
764
767
  catch (error) {
765
768
  if (cacheable)
@@ -767,6 +770,10 @@ export async function containerComputerScreenshot(runner = sh, platform = proces
767
770
  throw error;
768
771
  }
769
772
  }
773
+ export async function containerComputerScreenshot(runner = sh, platform = process.platform, target = SHARED_LOCAL_VM_TARGET) {
774
+ const { png, format } = await containerComputerFrame(runner, platform, target);
775
+ return `data:image/${format};base64,${png}`;
776
+ }
770
777
  const screenshotStatusCache = new Map();
771
778
  const containerMcpPath = SPAWNED_PROXIES.containerMcp;
772
779
  export function containerComputerMcp(runtime, control, target = SHARED_LOCAL_VM_TARGET) {
@@ -19,6 +19,7 @@
19
19
  // list_routines() → inspect this bot's scheduled work
20
20
  // propose_routine(...) → show a confirmation card for a new routine
21
21
  // propose_routine_action(...) → show a confirmation card for a routine change
22
+ // propose_profile(...) → show a confirmation card for a profile change
22
23
  //
23
24
  // Speaks raw JSON-RPC 2.0 over stdio (no MCP SDK — house style, matches
24
25
  // computer-proxy / permission-proxy). All state comes from env, injected by
@@ -212,6 +213,10 @@ const ROUTINE_FIELDS_SCHEMA = {
212
213
  type: "boolean",
213
214
  description: "Only for updates: set true to remove an existing safety limit. Do not combine with timeout_minutes.",
214
215
  },
216
+ continuity: {
217
+ type: "boolean",
218
+ description: "Opt in to using the latest completed run's bounded report as historical context. Defaults to false; set false in an update to start fresh again. Shown on the confirmation card.",
219
+ },
215
220
  };
216
221
  const TOOLS = [
217
222
  {
@@ -390,6 +395,31 @@ const TOOLS = [
390
395
  required: ["routine_id", "action"],
391
396
  },
392
397
  },
398
+ {
399
+ name: "propose_profile",
400
+ description: "Propose changes to your own name, title, description, standing instructions (SOUL.md), or working folder (cwd). This only creates a confirmation card; nothing changes until the user approves it. After calling it, end the turn and do not claim the change is applied. Keep SOUL.md short — who you are and the rules you never break; put step-by-step procedure into a skill instead. A Chief of Staff may pass for_bot_id (from list_bots) to propose a change for another bot in its section.",
401
+ inputSchema: {
402
+ type: "object",
403
+ additionalProperties: false,
404
+ properties: {
405
+ name: { type: "string", maxLength: 100, description: "New display name." },
406
+ title: { type: "string", maxLength: 200, description: "New role or title." },
407
+ description: { type: "string", maxLength: 4000, description: "New one-line blurb shown in rosters." },
408
+ soul: { type: "string", description: "Full replacement text for SOUL.md, at most 24000 bytes." },
409
+ cwd: {
410
+ type: "string",
411
+ maxLength: 1024,
412
+ description: "Absolute path of the folder your tools read and write in (for example /Users/me/Projects/site). It must already exist. An empty string means your private workspace.",
413
+ },
414
+ reason: { type: "string", minLength: 1, maxLength: 500, description: "One sentence the user will see explaining why." },
415
+ for_bot_id: {
416
+ type: "string",
417
+ description: "Chief of Staff only: the id of another bot in your section whose profile this changes. Omit to change your own.",
418
+ },
419
+ },
420
+ required: ["reason"],
421
+ },
422
+ },
393
423
  {
394
424
  name: "skills_list",
395
425
  description: "List this bot's imported skills (enabled and disabled) and any staged skill writes waiting for the user to confirm. Use this before skill_manage to avoid duplicate names. Listing does not enable anything.",
@@ -475,12 +505,14 @@ function routineFields(args) {
475
505
  fields.timeoutMinutes = null;
476
506
  else if (typeof args.timeout_minutes === "number")
477
507
  fields.timeoutMinutes = args.timeout_minutes;
508
+ if (typeof args.continuity === "boolean")
509
+ fields.continuity = args.continuity;
478
510
  return { fields };
479
511
  }
480
- function confirmationResult(r, fallback) {
512
+ function confirmationResult(r, fallback, noun = "routine") {
481
513
  const summary = typeof r.summary === "string" && r.summary.trim() ? `\n\n${r.summary.trim()}` : "";
482
514
  return {
483
- text: `A confirmation card is now visible to the user for ${fallback}.${summary}\n\nThis change has not been applied yet. End this turn and wait for the user to confirm or deny the card; do not claim the routine was created or changed before confirmation.`,
515
+ text: `A confirmation card is now visible to the user for ${fallback}.${summary}\n\nThis change has not been applied yet. End this turn and wait for the user to confirm or deny the card; do not claim the ${noun} was created or changed before confirmation.`,
484
516
  };
485
517
  }
486
518
  /** Who said a recalled line, as the header of a hit or a read. A user-role
@@ -770,6 +802,35 @@ async function callTool(name, args) {
770
802
  });
771
803
  return confirmationResult(r, `${action.replace("_", " ")} on routine ${routineId}`);
772
804
  }
805
+ if (name === "propose_profile") {
806
+ const changes = {};
807
+ if (typeof args.name === "string")
808
+ changes.name = args.name.trim();
809
+ if (typeof args.title === "string")
810
+ changes.title = args.title.trim();
811
+ if (typeof args.description === "string")
812
+ changes.description = args.description.trim();
813
+ if (typeof args.soul === "string")
814
+ changes.soul = args.soul;
815
+ if (typeof args.cwd === "string")
816
+ changes.cwd = args.cwd.trim();
817
+ if (!Object.keys(changes).length) {
818
+ return { text: "propose_profile needs at least one of name, title, description, soul, or cwd.", isError: true };
819
+ }
820
+ const forBotId = String(args.for_bot_id ?? "").trim();
821
+ const r = await api("/api/internal/profile-requests", {
822
+ method: "POST",
823
+ body: JSON.stringify({
824
+ fromBotId: BOT_ID,
825
+ fromThreadId: THREAD_ID,
826
+ changes,
827
+ reason: args.reason,
828
+ // JSON.stringify drops the key entirely when no target was named
829
+ forBotId: forBotId || undefined,
830
+ }),
831
+ });
832
+ return confirmationResult(r, "the profile change", "profile");
833
+ }
773
834
  if (name === "session_search") {
774
835
  const q = String(args.query ?? "").trim();
775
836
  if (!q)