openmausbot 0.1.55 → 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 (56) 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-D-u0LYsV.js → index-jWqt5dmY.js} +1 -1
  4. package/dist/index.html +2 -2
  5. package/dist-server/companion/src/routes.js +218 -0
  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 +2020 -361
  10. package/dist-server/openmausbot.js +45 -386
  11. package/dist-server/pair-cli.js +45 -386
  12. package/dist-server/server/auto-approve.js +48 -0
  13. package/dist-server/server/bot-folder.js +102 -0
  14. package/dist-server/server/bot-overview.js +206 -0
  15. package/dist-server/server/bot-package.js +5 -0
  16. package/dist-server/server/bot-profile.js +24 -0
  17. package/dist-server/server/channel-queue.js +1 -0
  18. package/dist-server/server/computer-control.js +15 -0
  19. package/dist-server/server/connector-proxy.js +37 -16
  20. package/dist-server/server/container-computer.js +9 -2
  21. package/dist-server/server/drivers/agents-proxy.js +63 -2
  22. package/dist-server/server/drivers/antigravity-acp.js +61 -13
  23. package/dist-server/server/drivers/antigravity-runtime.js +47 -3
  24. package/dist-server/server/drivers/claude.js +7 -1
  25. package/dist-server/server/exit.js +9 -0
  26. package/dist-server/server/index.js +707 -124
  27. package/dist-server/server/mcp-bridge.js +73 -44
  28. package/dist-server/server/openmausbot.js +3 -2
  29. package/dist-server/server/package-export.js +1 -0
  30. package/dist-server/server/pair-cli.js +3 -2
  31. package/dist-server/server/peer-approval.js +27 -0
  32. package/dist-server/server/peer-provenance.js +4 -1
  33. package/dist-server/server/peer-roster.js +15 -0
  34. package/dist-server/server/profile-requests.js +332 -0
  35. package/dist-server/server/profile-revision.js +15 -0
  36. package/dist-server/server/profile-versions.js +154 -0
  37. package/dist-server/server/request-auth.js +10 -0
  38. package/dist-server/server/routine-requests.js +34 -1
  39. package/dist-server/server/routines.js +70 -2
  40. package/dist-server/server/setup-mode.js +63 -0
  41. package/dist-server/server/skill-fetch.js +67 -11
  42. package/dist-server/server/skills.js +1 -1
  43. package/dist-server/server/store.js +87 -7
  44. package/dist-server/server/system-prompt.js +49 -0
  45. package/dist-server/server/team-backup.js +2 -2
  46. package/dist-server/server/team-manifest.js +7 -0
  47. package/dist-server/server/tunnel.js +1 -1
  48. package/dist-server/shared/bot-profile.js +5 -1
  49. package/dist-server/shared/line-diff.js +42 -0
  50. package/dist-server/shared/profile-request.js +9 -0
  51. package/dist-server/shared/team-backup.js +4 -0
  52. package/dist-server/tunnel-guardian.js +6 -19
  53. package/dist-server/vps-container-mcp.js +49 -31
  54. package/package.json +1 -1
  55. package/dist/assets/index-B9rLIY81.js +0 -263
  56. package/dist/assets/index-CJBrtDbC.css +0 -1
@@ -0,0 +1,218 @@
1
+ // What a paired device is allowed to ask for.
2
+ //
3
+ // The default is deny, and that direction is the whole point: the sidecar
4
+ // sits in front of an API it does not own and cannot see the future of. A
5
+ // route that appears in the harness later is closed to phones until someone
6
+ // decides otherwise, because the alternative is that every upstream release
7
+ // silently widens what a lost phone can reach.
8
+ //
9
+ // This file used to claim that and not do it — it listed refusals and let
10
+ // everything else under `/api/` through. In the time between writing it and
11
+ // noticing, upstream added webhook triggers, connected-app authorisation and
12
+ // routines, all of which a paired phone could drive: minting an
13
+ // internet-reachable trigger, rotating a signing secret out from under
14
+ // whatever was sending to it, disconnecting a Google account. None of that
15
+ // was a decision anyone made. It was the default.
16
+ //
17
+ // So the list below is the surface, derived from what the app actually
18
+ // calls. Adding a feature to the phone means adding its route here, on
19
+ // purpose, in a diff someone can read. That cost is the feature.
20
+ /** The one companion route that crosses into full interactive desktop
21
+ * control. Both the allowlist and capability gate consume this classifier so
22
+ * their security decisions cannot drift apart. */
23
+ export const CLOUD_DESKTOP_JOIN_ROUTE = {
24
+ method: "POST",
25
+ path: /^\/api\/bots\/[\w-]+\/computer\/join$/,
26
+ };
27
+ /** A POST whose response is file bytes. Keep this exact classifier shared
28
+ * with the proxy: a `.json` document must not enter the ordinary JSON
29
+ * scrub/re-serialise path and come back as different bytes. */
30
+ export const MESSAGE_FILE_ROUTE = {
31
+ method: "POST",
32
+ path: /^\/api\/threads\/[\w-]+\/messages\/[\w-]+\/file$/,
33
+ };
34
+ export const CLOUD_DESKTOP_CONTROL_ROUTE = {
35
+ method: "POST",
36
+ path: /^\/api\/bots\/[\w-]+\/computer\/(?:control|screenshot|viewer-close)$/,
37
+ };
38
+ export function isCloudDesktopJoin(method, path) {
39
+ return method === CLOUD_DESKTOP_JOIN_ROUTE.method && CLOUD_DESKTOP_JOIN_ROUTE.path.test(path);
40
+ }
41
+ export function isMessageFileDownload(method, path) {
42
+ return method === MESSAGE_FILE_ROUTE.method && MESSAGE_FILE_ROUTE.path.test(path);
43
+ }
44
+ export function isCloudDesktopAccess(method, path) {
45
+ return isCloudDesktopJoin(method, path)
46
+ || (method === CLOUD_DESKTOP_CONTROL_ROUTE.method && CLOUD_DESKTOP_CONTROL_ROUTE.path.test(path));
47
+ }
48
+ /** Every request the iOS app makes, and nothing else.
49
+ *
50
+ * Ids are `[\w-]+`, matching the harness's own route patterns. The paths
51
+ * arrive undecoded and are anchored at both ends, so an encoded traversal
52
+ * fails to match and is denied rather than forwarded — the failure mode of
53
+ * a strict pattern is a closed door, which is the one to have. */
54
+ const ALLOWED = [
55
+ // configured-or-not booleans. The write side is refused below: reading
56
+ // which providers are set up is not reading their keys.
57
+ { method: "GET", path: /^\/api\/config$/ },
58
+ { method: "GET", path: /^\/api\/events$/ },
59
+ { method: "GET", path: /^\/api\/instances$/ },
60
+ { method: "GET", path: /^\/api\/team-map$/ },
61
+ // Sidecar-owned, authenticated endpoint metadata. The proxy terminates it
62
+ // locally; it never becomes a newly exposed harness route.
63
+ { method: "GET", path: /^\/api\/companion\/endpoints$/ },
64
+ // the fleet, and making a bot
65
+ { method: "GET", path: /^\/api\/bots$/ },
66
+ { method: "POST", path: /^\/api\/bots$/ },
67
+ // One narrow, atomic organizer write. This can only file visible bots;
68
+ // unlike the desktop's broad PATCH it cannot alter execution policy.
69
+ { method: "POST", path: /^\/api\/sidebar-sections$/ },
70
+ { method: "POST", path: /^\/api\/bots\/[\w-]+\/messages$/ },
71
+ { method: "PATCH", path: /^\/api\/bots\/[\w-]+\/cards\/[\w-]+$/ },
72
+ { method: "POST", path: /^\/api\/bots\/[\w-]+\/respond$/ },
73
+ { method: "POST", path: /^\/api\/bots\/[\w-]+\/interrupt$/ },
74
+ { method: "DELETE", path: /^\/api\/bots\/[\w-]+\/queue\/[\w-]+$/ },
75
+ { method: "POST", path: /^\/api\/bots\/[\w-]+\/read$/ },
76
+ { method: "POST", path: /^\/api\/bots\/[\w-]+\/always-allow$/ },
77
+ { method: "POST", path: /^\/api\/bots\/[\w-]+\/messages\/[\w-]+\/edit$/ },
78
+ // A credential value crosses this one route only as an HPKE envelope. The
79
+ // server binds it to the authenticated device and exact pending card before
80
+ // Electron opens it into the OS-encrypted credential store.
81
+ { method: "POST", path: /^\/api\/bots\/[\w-]+\/secret-cards\/[\w-]+\/provide$/ },
82
+ { method: "POST", path: /^\/api\/bots\/[\w-]+\/active-branch$/ },
83
+ { method: "POST", path: /^\/api\/bots\/[\w-]+\/tasks$/ },
84
+ { method: "POST", path: /^\/api\/bots\/[\w-]+\/tasks\/[\w-]+$/ },
85
+ { method: "PATCH", path: /^\/api\/bots\/[\w-]+\/tasks\/[\w-]+$/ },
86
+ { method: "DELETE", path: /^\/api\/bots\/[\w-]+\/tasks\/[\w-]+$/ },
87
+ // Read-only summary: who the bot is, what it does and won't do, and its
88
+ // recent activity. No settings, no transcript — read on open and on
89
+ // pull-to-refresh.
90
+ { method: "GET", path: /^\/api\/bots\/[\w-]+\/overview$/ },
91
+ // Paired-safe profile subset. The harness route itself rejects fields
92
+ // outside identity, standing instructions (soul, byte-capped), avatar,
93
+ // notifications, and voice preferences.
94
+ { method: "PATCH", path: /^\/api\/bots\/[\w-]+\/profile$/ },
95
+ // Full model selection, but no other bot settings. The harness validates
96
+ // the live catalog and refuses changes while the bot is working.
97
+ { method: "PATCH", path: /^\/api\/bots\/[\w-]+\/model$/ },
98
+ { method: "POST", path: /^\/api\/bots\/[\w-]+\/avatar\/generate$/ },
99
+ // Full cloud desktop access. The route is narrow and the proxy applies a
100
+ // second, per-device capability check before it reaches the harness.
101
+ CLOUD_DESKTOP_JOIN_ROUTE,
102
+ CLOUD_DESKTOP_CONTROL_ROUTE,
103
+ // rooms — making one, and talking in one
104
+ { method: "POST", path: /^\/api\/groups$/ },
105
+ { method: "POST", path: /^\/api\/groups\/[\w-]+\/messages$/ },
106
+ { method: "POST", path: /^\/api\/groups\/[\w-]+\/interrupt$/ },
107
+ { method: "DELETE", path: /^\/api\/groups\/[\w-]+\/queue\/[\w-]+$/ },
108
+ { method: "POST", path: /^\/api\/groups\/[\w-]+\/read$/ },
109
+ { method: "POST", path: /^\/api\/groups\/[\w-]+\/tasks$/ },
110
+ { method: "POST", path: /^\/api\/groups\/[\w-]+\/tasks\/[\w-]+$/ },
111
+ { method: "PATCH", path: /^\/api\/groups\/[\w-]+\/tasks\/[\w-]+$/ },
112
+ { method: "DELETE", path: /^\/api\/groups\/[\w-]+\/tasks\/[\w-]+$/ },
113
+ // a transcript, its images, and answering an approval
114
+ { method: "GET", path: /^\/api\/threads\/[\w-]+\/messages$/ },
115
+ { method: "GET", path: /^\/api\/threads\/[\w-]+\/messages\/[\w-]+\/image$/ },
116
+ MESSAGE_FILE_ROUTE,
117
+ { method: "POST", path: /^\/api\/threads\/[\w-]+\/messages\/[\w-]+\/reactions$/ },
118
+ { method: "GET", path: /^\/api\/threads\/[\w-]+\/export$/ },
119
+ { method: "POST", path: /^\/api\/threads\/[\w-]+\/respond$/ },
120
+ { method: "GET", path: /^\/api\/search$/ },
121
+ // App-owned profile images. Upload is image-only and capped at 10 MB by
122
+ // the harness; GET is a single bare generated filename, never a path.
123
+ { method: "POST", path: /^\/api\/attachments$/ },
124
+ { method: "GET", path: /^\/api\/attachments\/[\w-]+\.(?:png|jpe?g|gif|webp)$/i },
125
+ // Share-sheet documents are raw, capped at 25 MiB, and stored under a
126
+ // generated filename by the harness. The display name stays in the query;
127
+ // only this exact upload route crosses the companion boundary.
128
+ { method: "POST", path: /^\/api\/files$/ },
129
+ // Renderer-neutral voice operations. These routes never expose or mutate
130
+ // the workspace ElevenLabs key; the client receives labels or audio only.
131
+ { method: "GET", path: /^\/api\/tts\/voices$/ },
132
+ { method: "POST", path: /^\/api\/tts\/prepare$/ },
133
+ { method: "POST", path: /^\/api\/tts\/speak$/ },
134
+ // Routines create ordinary tasks using an existing agent configuration.
135
+ // Webhook management remains explicitly denied below.
136
+ { method: "GET", path: /^\/api\/routines$/ },
137
+ { method: "POST", path: /^\/api\/routines$/ },
138
+ { method: "PATCH", path: /^\/api\/routines\/[\w-]+$/ },
139
+ { method: "DELETE", path: /^\/api\/routines\/[\w-]+$/ },
140
+ { method: "POST", path: /^\/api\/routines\/[\w-]+\/run$/ },
141
+ { method: "POST", path: /^\/api\/routine-runs\/[\w-]+\/(?:cancel|seen)$/ },
142
+ // Multi-account Composio management exposes opaque ids and aliases only.
143
+ // Revocation stays on the host: the account DELETE route is deliberately
144
+ // absent — a paired client can see and add accounts, never remove one.
145
+ { method: "GET", path: /^\/api\/connectors\/catalog$/ },
146
+ { method: "GET", path: /^\/api\/connectors\/connected$/ },
147
+ { method: "GET", path: /^\/api\/connectors$/ },
148
+ { method: "POST", path: /^\/api\/connectors\/[\w-]+\/authorize$/ },
149
+ // Inline connector cards are scoped by bot, transcript message, and
150
+ // thread. They expose the same opaque OAuth authorization already allowed
151
+ // above, then only poll, resume, or dismiss that exact pending card.
152
+ { method: "GET", path: /^\/api\/bots\/[\w-]+\/connector-cards\/[\w-]+\/status$/ },
153
+ { method: "POST", path: /^\/api\/bots\/[\w-]+\/connector-cards\/[\w-]+\/(?:authorize|resume|dismiss)$/ },
154
+ // A remote client may decline or retry a credential request, but never
155
+ // claim that it stored a host credential. Saving and `provided` stay local
156
+ // to the host's OS-backed credential store.
157
+ { method: "POST", path: /^\/api\/bots\/[\w-]+\/secret-cards\/[\w-]+\/(?:resume|dismiss)$/ },
158
+ ];
159
+ /** Route families worth naming in the refusal.
160
+ *
161
+ * Everything not allowed is denied either way; this only decides whether the
162
+ * person gets a sentence or a 404. These are the ones someone might
163
+ * reasonably expect to work from the phone, where "no route" would read as a
164
+ * bug in the companion rather than a decision about where host configuration
165
+ * happens. Order matters only in that the first match wins. */
166
+ const EXPLAINED = [
167
+ {
168
+ path: /^\/api\/(companion|devices)(\/|$)/,
169
+ // Losing the phone must not mean losing the ability to lock it out.
170
+ error: "Remote access settings are managed on the host computer",
171
+ },
172
+ { path: /^\/api\/config$/, error: "API keys can only be changed on your computer" },
173
+ { path: /^\/api\/local-computer(\/|$)/, error: "the Local VM is set up on your computer" },
174
+ {
175
+ // Creating one exposes an endpoint to the internet, and rotating a
176
+ // secret breaks whatever was sending to it. Neither belongs on a device
177
+ // that lives in a pocket.
178
+ path: /^\/api\/webhooks(\/|$)/,
179
+ error: "webhooks are set up on your computer",
180
+ },
181
+ { path: /^\/api\/connectors(\/|$)/, error: "connected apps are set up on your computer" },
182
+ {
183
+ path: /^\/api\/routines(\/|$)/,
184
+ error: "this routine operation is only available on your computer",
185
+ },
186
+ { path: /^\/api\/teams(\/|$)/, error: "teams are imported and exported on your computer" },
187
+ ];
188
+ /** Why this request may not go through, or null when it may.
189
+ *
190
+ * Default deny: the answer for anything not on the list is "no route", which
191
+ * is what keeps a stolen token from mapping the API. An allowlist rather than
192
+ * a blocklist is the property this whole module exists for, and the one that
193
+ * quietly stopped being true once before. */
194
+ export function denyReason({ path, method, authenticated }) {
195
+ // Pairing is the one thing a device does before it has a credential.
196
+ if (method === "POST" && path === "/api/pair")
197
+ return null;
198
+ // Liveness is the other: it exists to be the first thing anyone curls when
199
+ // pairing will not work, and behind the token check it answered 401 to
200
+ // exactly the person it was for — which reads as "broken" rather than
201
+ // "unpaired". It discloses nothing a port scan would not.
202
+ if (method === "GET" && path === "/api/health")
203
+ return null;
204
+ if (!authenticated) {
205
+ return { status: 401, error: "pair this device from Remote access settings on the host computer" };
206
+ }
207
+ if (ALLOWED.some((route) => route.method === method && route.path.test(path)))
208
+ return null;
209
+ const explained = EXPLAINED.find((family) => family.path.test(path));
210
+ if (explained)
211
+ return { status: 403, error: explained.error };
212
+ // Everything else, including routes the harness really does have. Saying
213
+ // "no route" rather than "not allowed" keeps the sidecar from enumerating
214
+ // the API to anyone holding a stolen token — and it is what the peer-agent
215
+ // endpoints under /api/internal/ always got, since off this machine they
216
+ // genuinely do not exist.
217
+ return { status: 404, error: `no route: ${method} ${path}` };
218
+ }
@@ -96,20 +96,38 @@ function connectorAdds(args) {
96
96
  if (!args || typeof args !== "object" || Array.isArray(args)) return [];
97
97
  const toolkits = args.toolkits;
98
98
  if (!Array.isArray(toolkits)) return [];
99
- return [...new Set(toolkits.flatMap((item) => {
100
- if (typeof item === "string") return [item.toLowerCase()];
101
- if (!item || typeof item !== "object" || Array.isArray(item)) return [];
99
+ const seen = /* @__PURE__ */ new Set();
100
+ const requests = [];
101
+ for (const item of toolkits) {
102
+ if (typeof item === "string") {
103
+ const slug2 = item.trim().toLowerCase();
104
+ const key2 = JSON.stringify([slug2, ""]);
105
+ if (!seen.has(key2)) {
106
+ seen.add(key2);
107
+ requests.push({ slug: slug2 });
108
+ }
109
+ continue;
110
+ }
111
+ if (!item || typeof item !== "object" || Array.isArray(item)) continue;
102
112
  const row = item;
103
- const slug = typeof row.toolkit === "string" ? row.toolkit : row.name;
104
113
  const action = String(row.action ?? "add").toLowerCase();
105
- return typeof slug === "string" && ["add", "connect", "initiate"].includes(action) ? [slug.toLowerCase()] : [];
106
- }))];
114
+ if (!["add", "connect", "initiate"].includes(action)) continue;
115
+ const slug = typeof row.toolkit === "string" ? row.toolkit : row.name;
116
+ if (typeof slug !== "string") continue;
117
+ const normalized = slug.trim().toLowerCase();
118
+ const alias = (typeof row.alias === "string" ? row.alias : typeof row.account === "string" ? row.account : "").trim();
119
+ const key = JSON.stringify([normalized, alias.toLowerCase()]);
120
+ if (seen.has(key)) continue;
121
+ seen.add(key);
122
+ requests.push({ slug: normalized, ...alias ? { alias } : {} });
123
+ }
124
+ return requests;
107
125
  }
108
- async function showConnectorCards(slugs) {
126
+ async function showConnectorCards(items) {
109
127
  const response = await fetch(`${HARNESS}/api/internal/connectors/request`, {
110
128
  method: "POST",
111
129
  headers: { "content-type": "application/json", authorization: `Bearer ${TOKEN}` },
112
- body: JSON.stringify({ botId: BOT_ID, threadId: THREAD_ID, slugs, resumeKey: randomUUID() }),
130
+ body: JSON.stringify({ botId: BOT_ID, threadId: THREAD_ID, items, resumeKey: randomUUID() }),
113
131
  signal: AbortSignal.timeout(3e4)
114
132
  });
115
133
  if (!response.ok) {
@@ -141,12 +159,13 @@ async function handle(message) {
141
159
  if (method === "tools/call") {
142
160
  const params = message.params ?? {};
143
161
  const name = String(params.name ?? "");
144
- const slugs = /MANAGE_CONNECTIONS$/i.test(name) ? connectorAdds(params.arguments) : [];
145
- if (slugs.length) {
146
- await showConnectorCards(slugs);
162
+ const requests = /MANAGE_CONNECTIONS$/i.test(name) ? connectorAdds(params.arguments) : [];
163
+ if (requests.length) {
164
+ await showConnectorCards(requests);
165
+ const labels = requests.map((r) => r.alias ? `${r.slug} (${r.alias})` : r.slug).join(", ");
147
166
  send(textResult(
148
167
  id,
149
- `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.`
168
+ `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.`
150
169
  ));
151
170
  return;
152
171
  }
@@ -15082,6 +15082,28 @@ function createGateInterceptor(options) {
15082
15082
  });
15083
15083
  };
15084
15084
  }
15085
+ function createMcpBridgeInterceptor(options) {
15086
+ const afterPing = options.gate ? createGateInterceptor({
15087
+ isHeld: options.gate.isHeld,
15088
+ forward: options.forward,
15089
+ refuse: options.answer,
15090
+ refusalText: options.gate.refusalText
15091
+ }) : options.forward;
15092
+ return (line) => {
15093
+ let frame = null;
15094
+ try {
15095
+ frame = JSON.parse(line);
15096
+ } catch {
15097
+ }
15098
+ if (frame && frame.method === "ping") {
15099
+ if (frame.id !== void 0) {
15100
+ options.answer(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: {} }));
15101
+ }
15102
+ return;
15103
+ }
15104
+ afterPing(line);
15105
+ };
15106
+ }
15085
15107
  function runMcpBridge(options) {
15086
15108
  const child = spawn(options.command, options.args, {
15087
15109
  shell: false,
@@ -15091,37 +15113,33 @@ function runMcpBridge(options) {
15091
15113
  child.stdin.on("error", () => {
15092
15114
  });
15093
15115
  child.stderr.pipe(process.stderr);
15094
- let detach;
15095
- if (options.gate) {
15096
- const client = createControlClient({ url: options.gate.url, token: options.gate.token });
15097
- const inbound = createLineSplitter(
15098
- createGateInterceptor({
15099
- isHeld: async () => (await client.state(true)).held,
15100
- forward: (line) => child.stdin.write(line + "\n"),
15101
- refuse: (line) => process.stdout.write(line + "\n")
15102
- })
15103
- );
15104
- const onStdin = (chunk) => inbound.push(chunk);
15105
- process.stdin.on("data", onStdin);
15106
- process.stdin.on("end", () => {
15107
- inbound.flush();
15108
- child.stdin.end();
15109
- });
15110
- const outbound = createLineSplitter((line) => process.stdout.write(line + "\n"));
15111
- child.stdout.on("data", (chunk) => outbound.push(chunk));
15112
- child.stdout.on("end", () => outbound.flush());
15113
- detach = () => {
15114
- process.stdin.off("data", onStdin);
15115
- process.stdin.pause();
15116
- };
15117
- } else {
15118
- process.stdin.pipe(child.stdin);
15119
- child.stdout.pipe(process.stdout);
15120
- detach = () => {
15121
- process.stdin.unpipe(child.stdin);
15122
- process.stdin.pause();
15123
- };
15124
- }
15116
+ const client = options.gate ? createControlClient({ url: options.gate.url, token: options.gate.token }) : null;
15117
+ const answer = (line) => process.stdout.write(line + "\n");
15118
+ const forward = (line) => child.stdin.write(line + "\n");
15119
+ const inbound = createLineSplitter(
15120
+ createMcpBridgeInterceptor({
15121
+ answer,
15122
+ forward,
15123
+ ...options.gate ? {
15124
+ gate: {
15125
+ isHeld: async () => (await client.state(true)).held
15126
+ }
15127
+ } : {}
15128
+ })
15129
+ );
15130
+ const onStdin = (chunk) => inbound.push(chunk);
15131
+ process.stdin.on("data", onStdin);
15132
+ process.stdin.on("end", () => {
15133
+ inbound.flush();
15134
+ child.stdin.end();
15135
+ });
15136
+ const outbound = createLineSplitter((line) => process.stdout.write(line + "\n"));
15137
+ child.stdout.on("data", (chunk) => outbound.push(chunk));
15138
+ child.stdout.on("end", () => outbound.flush());
15139
+ const detach = () => {
15140
+ process.stdin.off("data", onStdin);
15141
+ process.stdin.pause();
15142
+ };
15125
15143
  let watchdog = null;
15126
15144
  if (options.liveness) {
15127
15145
  const liveness = options.liveness;
@@ -192,6 +192,10 @@ var ROUTINE_FIELDS_SCHEMA = {
192
192
  clear_timeout: {
193
193
  type: "boolean",
194
194
  description: "Only for updates: set true to remove an existing safety limit. Do not combine with timeout_minutes."
195
+ },
196
+ continuity: {
197
+ type: "boolean",
198
+ 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."
195
199
  }
196
200
  };
197
201
  var TOOLS = [
@@ -371,6 +375,31 @@ var TOOLS = [
371
375
  required: ["routine_id", "action"]
372
376
  }
373
377
  },
378
+ {
379
+ name: "propose_profile",
380
+ 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 \u2014 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.",
381
+ inputSchema: {
382
+ type: "object",
383
+ additionalProperties: false,
384
+ properties: {
385
+ name: { type: "string", maxLength: 100, description: "New display name." },
386
+ title: { type: "string", maxLength: 200, description: "New role or title." },
387
+ description: { type: "string", maxLength: 4e3, description: "New one-line blurb shown in rosters." },
388
+ soul: { type: "string", description: "Full replacement text for SOUL.md, at most 24000 bytes." },
389
+ cwd: {
390
+ type: "string",
391
+ maxLength: 1024,
392
+ 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."
393
+ },
394
+ reason: { type: "string", minLength: 1, maxLength: 500, description: "One sentence the user will see explaining why." },
395
+ for_bot_id: {
396
+ type: "string",
397
+ description: "Chief of Staff only: the id of another bot in your section whose profile this changes. Omit to change your own."
398
+ }
399
+ },
400
+ required: ["reason"]
401
+ }
402
+ },
374
403
  {
375
404
  name: "skills_list",
376
405
  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.",
@@ -445,16 +474,17 @@ function routineFields(args) {
445
474
  if (typeof args.run_on === "string") fields.runOn = args.run_on;
446
475
  if (args.clear_timeout === true) fields.timeoutMinutes = null;
447
476
  else if (typeof args.timeout_minutes === "number") fields.timeoutMinutes = args.timeout_minutes;
477
+ if (typeof args.continuity === "boolean") fields.continuity = args.continuity;
448
478
  return { fields };
449
479
  }
450
- function confirmationResult(r, fallback) {
480
+ function confirmationResult(r, fallback, noun = "routine") {
451
481
  const summary = typeof r.summary === "string" && r.summary.trim() ? `
452
482
 
453
483
  ${r.summary.trim()}` : "";
454
484
  return {
455
485
  text: `A confirmation card is now visible to the user for ${fallback}.${summary}
456
486
 
457
- This 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.`
487
+ This 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.`
458
488
  };
459
489
  }
460
490
  function recallSpeaker(hit) {
@@ -723,6 +753,30 @@ ${JSON.stringify(routines, null, 2)}`
723
753
  });
724
754
  return confirmationResult(r, `${action.replace("_", " ")} on routine ${routineId}`);
725
755
  }
756
+ if (name === "propose_profile") {
757
+ const changes = {};
758
+ if (typeof args.name === "string") changes.name = args.name.trim();
759
+ if (typeof args.title === "string") changes.title = args.title.trim();
760
+ if (typeof args.description === "string") changes.description = args.description.trim();
761
+ if (typeof args.soul === "string") changes.soul = args.soul;
762
+ if (typeof args.cwd === "string") changes.cwd = args.cwd.trim();
763
+ if (!Object.keys(changes).length) {
764
+ return { text: "propose_profile needs at least one of name, title, description, soul, or cwd.", isError: true };
765
+ }
766
+ const forBotId = String(args.for_bot_id ?? "").trim();
767
+ const r = await api("/api/internal/profile-requests", {
768
+ method: "POST",
769
+ body: JSON.stringify({
770
+ fromBotId: BOT_ID,
771
+ fromThreadId: THREAD_ID,
772
+ changes,
773
+ reason: args.reason,
774
+ // JSON.stringify drops the key entirely when no target was named
775
+ forBotId: forBotId || void 0
776
+ })
777
+ });
778
+ return confirmationResult(r, "the profile change", "profile");
779
+ }
726
780
  if (name === "session_search") {
727
781
  const q = String(args.query ?? "").trim();
728
782
  if (!q) return { text: 'session_search needs a query, for example {"query":"site audit broken links"}.', isError: true };